多线程(六):wait & notify

发布于:2024-10-18 ⋅ 阅读:(11) ⋅ 点赞:(0)

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 等待有以下过程:

  1. 进入 synchronized, 加锁.
  2. 进入 wait, 首先会解锁,线程进入阻塞状态.
  3. 等待其他线程notify时,线程再重新加锁, 往下执行.
  4. 代码走出 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 的形式很像,  我们需要着重注意一下两者的区别:

  1. wait 必须要和 锁 搭配使用, 先加锁, 才能使用 wait , 而 sleep 则不需要加锁.
  2. 如果都在 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