查看原文
其他

为什么说“除非深思熟虑,尽量使用 notifyAll()”?

承香墨影 承香墨影 2022-04-23


一. 序

当做多线程同步时,等待-通知机制是我们比较常用的一种选择,而 Java 中,等待-通知机制有多种实现,我们接触最早也是最熟悉的,应该就是 Java 语言内置的 synchronized 配合 wait()、notify()、notifyAll() 这三个方法来实现。

如何利用 synchronized 实现等待-通知机制,我想大家都比较熟悉,就无需多说了。notify()notifyAll() 都可以唤醒等待的线程,但是应该使用 notify() 还是 notifyAll() 就比较有争议了。

一种比较流行的说法就是,除非深思熟虑,否则尽量使用 notifyAll()

我们今天就这个问题,来讨论一下这两个方法如何选择。

二. 等待-通知机制

2.1 什么是等待通知机制

在此之前,先来聊聊什么是等待-通知机制,以及它能解决什么问题?

在使用并发编程时,利用多线程来提高任务的执行效率,但是每个线程在执行时,都有一些先决条件需要被满足。例如生产者消费者模式下,消费者线程能够执行的先决条件,就是生产者产生了一个待消费的数据。

那么如果线程要求的条件,不满足时,循环等待是一种方案,循环间隔一段时间,再重新尝试验证条件是否满足,但是这样的循环等待,在某些场景下,可能会循环很多次,导致大量消耗 CPU 资源。

更好的方案,则是等待-通知机制。当线程要求的条件不满足时,主动进入等待状态,等线程等待的条件,再次被满足后,通知这个等待的线程重新执行。这就解决了 CPU 资源,因为循环而导致消耗的问题。

对标到 Synchronized 中,被 Synchronized 标记的代码块,被称为临界区,在同一时刻,只有一个线程能过获取 Synchronized 的互斥锁,进入临界区执行。

线程处于临界区时,一旦发现执行条件不满足时,则可以调用 wait() 或者 wait(time_out) 方法,进入等待,例如消费者发现没有更多需要处理的数据,此时就调用 wait() 方法,进入等待,等待生产者产生一条新的待消费数据。接下来如果生产者线程,产生了一个新的数据后,就需要唤醒之前等待的消费者线程,去处理这条数据。这里的唤醒操作,就是调用 notify() 或者 notifyAll() 方法。

可以看到,notify()notifyAll() 的作用是一致的,都是去唤醒等待中的线程。但是也正如他们方法名所描述的,notify() 会"随机"唤醒一个等待线程,而 notifyAll() 会尝试唤醒所有的等待中的线程。

注意这里的唤醒,并不是真的唤醒去执行,实际上只是让处于等待的线程,有重新获取锁的争抢权,也就是说,哪怕此时有一百个线程处于等待状态,此时调用 notifyAll() 也只会有一个线程获取到锁,允许进入临界区执行。

这在底层中,其实是利用了两个等待队列来实现的,分别是入口队列EntrySet)和等待队列WaitSet)。

被 Synchronized 阻塞等待的线程,会进入入口队列,而当条件不满足时,主动调用 wait() 方法进入等待的线程,则会进入等待队列

在等待队列中的线程,如果不被唤醒,则永远没有锁的争抢权,无法获取锁也就无法被执行。

2.2 为什么说尽量使用 notifyAll?

终于进入主题了,就前面的描述,看似应该是使用 notify() 更好一些。因为即便我们通知了等待队列中,所有的线程,但同一时刻,也只有一个线程可以获取互斥锁,进入临界区执行,这么看来 notify() 会更高效一些。

但是这里埋下来一个风险,就是只使用 notify() 可能会导致某些线程,一直处于等待队列中,而永远不会被唤醒并获得执行权

理想情况下,一次等待(wait)对应一次通知(notify),是非常完美的,但是实际业务场景下,可能做不到。

举个例子,在多生产者消费者模式下,待处理的数据队列只有一条数据了,理想场景下,消费者在处理掉一条数据后,理论上应该唤醒生产者再生产一条新的待消费数据。可是 notify() 是随机唤醒,也就是它可能会唤醒一个消费者线程,这个消费者线程,发现没有待处理的数据,此时条件不满足,又主动进入等待队列。

也正是因为如此,在并发编程中有个范式模板:

  synchronized(this){
    while(条件不满足) {
      wait();
    }
    // ...
  }

这段代码,大家应该很熟悉,notify() 只能保证唤醒一个线程,但是不保证线程执行的时候,曾经的等待条件已经被满足了。为了保证可靠性,此处使用循环检测的方式,只有必要条件满足时,才继续执行。

正是因为 notify() 随机唤醒的特点,导致在多条件的情况下,会导致某些线程永远不会被通知到。稳妥的方式,是使用 notifyAll(),让等待中的线程,都有一次再执行的权利。

这也就是为什么说,除非深思熟虑,否则尽量使用 notifyAll()

2.3 什么是深思熟虑?

使用 notifyAll() 主要是为了稳定,减少程序的复杂度,我们程序员,解决的是一系列工程问题,虽然有时候需要挑战一些性能的极限,但是大多数时候应该是以稳定且易读易维护为出发点实现功能。

但是在程序的世界中,永远没有绝对的银弹。不带场景去分析问题,都是耍流氓。

前面说 “除非深思熟虑”,那什么场景下才可以用到深思熟虑?

其实只要满足三个条件即可:

  1. 线程进入等待队列的条件相同。

  2. 在满足条件时,所有线程执行的逻辑相同。

  3. 当一个线程执行完(无论是否异常),必定唤醒一个线程。

怎么理解呢?

当所有的线程,执行相同的业务逻辑,那么触发等待的条件也必定相同,只要保证一个线程在退出的时候,必定调用 notify() 唤醒一个线程。满足这些条件,就可以只使用 notify() 方法。

生产者消费者模式,必定是不满足的,它把线程分为两个性质,做着不同的任务,等着着不同的条件。但是这种情况下,使用 Lock&Condition 会更适合。

三. 小结时刻

到这里,我相信大家已经理解了 notify 和 notifyAll 的区别了,在多数场景下,推荐使用 notifyAll() 的主要原因就是因为稳定,毕竟我们大多数时候解决的是工程问题。

具体怎么使用就见仁见智了,你有什么不同的见解,欢迎留言讨论。转发、点好看是最大的支持,谢谢!


「」👈推荐我的知识星球,一年 50 个优质问题,一起上桌联机学习。

公众号后台回复成长『成长』,将会得到我准备的学习资料,也能回复『加群』,一起学习进步。


您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存