1. wait & notify
wait(等待)和notify(通知)和使用是用来协调线程之间执行逻辑的顺序的.
通过wait和notify, 可以把控先后逻辑的执行顺序, 让先执行的逻辑先跑, 让后执行的逻辑进行等待操作(wait), 等先执行的逻辑跑完了, 再通知(notify)后执行的逻辑跑.
1.1 wait等待 / join等待 / 锁等待 区别
这里着重强调一下 wait等待 / join等待 / 锁等待 这三个等待之间的区别:
- wait 是等待另一个线程 notify 后, 才继续执行, 不用等另一个线程全部执行完
- join 是等待另一个线程彻底执行完后, 才继续执行
- 锁等待 是不受控制的等待. 执行到synchronized时, 是不一定触发等待的, 只有当其他线程对同一个锁对象进行"加锁"操作后, 才会阻塞等待
2. wait & notify 使用场景
2.1 线程饿死 / 线程饥饿
关于什么是线程饿死, 这里为大家举个例子:
大家都知道ATM机, a b c 三个人在ATM机前轮流排队取钱, 轮到 a 取钱时, a 进门上锁后, 发现ATM里面的钱被取光了, 这时 a 释放锁出门, 应该轮到 b 或 c 进门上锁了, 但是 a 不放心, 又进去看了一眼有钱没有, 出来后又不放心, 又进去看了一眼发现没钱出来, 出来后又不放心又进去, ......
此时 a 一直在进进出出, b c 就一直处在了阻塞等待的状态.
为什么 a 能一直进进出出呢??? 换句话说, 当多个线程竞争一把锁的时候, 在获取锁的线程释放锁后, 其他的哪个线程会获取到锁呢?
答案是不确定的(随机调度).
虽然是操作系统的调度是随机的是随机的, 但是其他线程由于竞争锁的缘故都处在阻塞等待的状态, 而当前释放锁的线程处于就绪状态. 所以当前线程有很大的概率能够再次获取到锁对象(假设在循环下).
这样以来, 其他线程获取锁的几率就会很小, 就会大概率的处在阻塞等待的状态而不受调度, 这种情况就称为 线程饥饿 / 线程饿死.
上述就是wait & notify 的典型使用场景. 当 a 发现ATM中没有钱后, 应该让 a 进行 wait 等待操作, 把机会让给 b / c 去获取锁, 等运钞机把RMB送来后, 再通知(notify) a 去获取锁.
3. wait 的使用 & notify 的使用
通过上文的举例, 我们了解了 wait 和 notify 使用时机:
当拿到锁的线程, 发现要执行的任务时机还不成熟, 就要使用 wait 进行阻塞等待, 等到时机成熟后, 通知当前线程(notify)再去重新获取锁重新去执行.
wait 和 notify 都是 Object 的方法, 意味着 Java 中的任意一个对象, 都提供了 wait 和 notify.
3.1 wait 的使用
wait 等待最关键的一点在于, 释放获取的锁对象.
要想释放锁, 那么肯定要先获取锁, 所以 wait 应该用于 synchronized 代码块中:
所以有 wait 等待有以下过程:
- 进入 synchronized, 加锁.
- 进入 wait, 首先会解锁,线程进入阻塞状态.
- 等待其他线程notify时,线程再重新加锁, 往下执行.
- 代码走出 synchronized, 解锁.
要注意的是:
synchronized 的锁对象 和调用 wait 的对象必须是同一个!!!
在使用 wait 时, 我们发现, 同样会抛出 InterruptedException 异常,
其实, Java 中的每一个阻塞方法(sleep, join, wait, ...)都会抛出这个异常, 也就意味着随时会被Interrupt方法唤醒.
3.1.1 wait( timeout ) --- "超时时间"的 wait 等待
wait 和 join 类似, 一共提供了两个版本的 wait 等待.
- 一个版本的 wait 就是上文提到的不带参数的"死等", 即 wait 线程会一直等另一个线程 notify 后, 才会继续往下执行.
- 另一个版本的 wait 就是带有"超时时间"的 wait , 即最多等待这么长时间, 如果超过了这个时间, 其他线程仍旧没有 notify , 那这个线程也不会再等待下去了, 而是继续往下执行.
wait 带有超时时间的版本, 和 sleep 的形式很像, 我们需要着重注意一下两者的区别:
- wait 必须要和 锁 搭配使用, 先加锁, 才能使用 wait , 而 sleep 则不需要加锁.
- 如果都在 synchronized 内部使用, 那么 wait 会先释放锁后再进入阻塞状态. 而 sleep 则不需要释放锁.
其实, sleep 只是在我们学习过程中用的多, 在真正开发时, 很少使用(因为 sleep 就是在纯纯浪费时间).
3.2 notify 的使用
使用 notify , 同样也需要先拿到锁. 也就是说, notify 同样需要搭配 synchronized 一起使用.
注意: wait 和 notify 针对的必须是同一个锁对象, notify的通知才能能生效. 这个相同的对象, 就是这两个线程间沟通的桥梁~~
也就是说, 以下四个对象, 必须是同一个(如果 wait 的对象和 notify 的对象不同, 则没有任何相互的作用和影响):
如果有多个线程在同一个锁对象上进行了 wait 操作, 那么进行 notify 的时候, 只能随机唤醒(哪个wait的线程先被唤醒(先被调度), 是随机的)其中一个线程.
但是实际上, 同一个对象多次 wait 的这种情况下, 一般这些 wait 的线程都是干同样的工作, 所以唤醒谁, 其实都一样~~
如果想唤醒多个线程, 那么就需要多次 notify .也就是说, 一次 notify 只能唤醒一个 wait.
3.2.1 notifyAll
notifyAll 方法可以一次唤醒这个锁对象的所有的 wait 线程.
但是要注意的一点是:
notifyAll 虽然一次全部唤醒了 t1 和 t2 线程的 wait, 但是由于 wait 后要重新进行加锁, 所以其中某一个线程加锁后, 另一个线程会因为加锁失败而再次进入阻塞等待, 等第一个线程解锁后才会往下执行.
所以并不是 notifyAll 后两个线程都同时可以往下执行了, 而是其中一个线程还会继续阻塞等待.
3.3 wait 和 notify 使用的先后顺序
使用 wait 和 notify 进行线程中的操作时, 务必要确保 notify 的操作, 要在 wait 之后, 只有这样 notify 的唤醒操作才有作用.
如果是先 notify , 后 wait , 那么那个 wait 的线程就无法被唤醒(就相当于偶像剧中的"miss" , 错过了~~). 但是那个进行 notify 的线程也没有副作用(打了个空炮而已~~), 也就是说,如果一个线程T notify 了一个没有 wait 的线程, 则 T 线程自身不会抛异常 / 报错.
如下代码中, 就是通过 t2 等待用户的 IO 输入而进入的阻塞, 从而确保 t1 的 wait 在 t2 的 notify 之前:
public static void main(String[] args) throws InterruptedException {
Object locker1 = new Object();
Thread t1 = new Thread(() -> {
synchronized (locker1) {
try {
System.out.println("t1 进入等待");
locker1.wait();
System.out.println("t1 结束等待");
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
Thread t2 = new Thread(() -> {
Scanner scanner = new Scanner(System.in);
System.out.println("输入任意内容, 结束 t1 等待");
// 等待 IO 进入阻塞
scanner.next();
synchronized (locker1) {
locker1.notify();
}
});
t1.start();
t2.start();
}
END