背景
这几天一直在回归 Java 并发编程相关的知识,发现有些琐碎的知识点并不能理清,而且在网络上寻找答案,各种回答质量参差不齐,比如管程中的条件变量,入口等待队列,notify() 和 notifyAll() 的区别等,在此做个总结。
管程
管程的英文是 monitor,很多 Java 领域的朋友喜欢将其翻译为“监视器”,而在操作系统领域一般翻译为管程。操作系统在面对 进程/线程 间同步的时候,所支持的一些同步原语,其中 semaphore 信号量 和 mutex 互斥量是最重要的同步原语。为了更容易的编出正确的并发程序,所以在 mutex 和 semaphore 的基础上,提出了更高层次的同步原语 monitor(管程)。
semaphore 和 mutex 在编程上容易出错,因为我们需要去亲自操作变量以及对 进程/线程 进行阻塞和唤醒。monitor 这个机制之所以被称为“更高级的原语”,那么它就不可避免地需要对外屏蔽掉这些机制,并且在内部实现这些机制,使得使用 monitor 的人看到的是一个简洁易用的接口。
不过需要注意的是,操作系统本身并不支持 monitor 机制,实际上 monitor 是属于编程语言的范畴,比如 C语言就不支持 monitor,Java 语言则支持(Java 中 Monnitor 底层是用 C++实现的,直接和系统内核交互)。一般的 monitor 实现模式是在编程语言上提供语法糖支持,而如何实现 monitor 机制,则属于编译器的工作,比如 Java。
所谓的管程,指的是管理共享变量以及对共享变量的操作过程,让他们支持并发。Java 采用的是管程技术,synchronized 关键字及 wait(),notify(),notifyAll() 这三个方法都是管程的组成部分。从某种意义上说管程和信号量是等价的,所谓的等价指的是用管程能够实现信号量,也能用信号量实现管程。
为什么 Java 选择了管程
并发编程领域有两大核心问题,互斥和同步。先来看看管程如何解决互斥问题,就是将共享变量及其对共享变量的操作统一封装起来。管程 X 将共享变量 queue 这个线程不安全的队列和相关的操作入队操作 enq()、出队操作 deq() 都封装起来了;线程 A 和线程 B 如果想访问共享变量 queue,只能通过调用管程提供的 enq()、deq() 方法来实现;enq()、deq() 保证互斥性,只允许一个线程进入管程。
可以看到管程模型和面向对象高度契合的,估计这也是 Java 选择管程的原因吧。
再来看看如何解决线程间的同步问题,前面说过共享变量和对共享变量的操作是被封装起来的。图中最外层的框就代表封装的意思。框的上面只有一个入口,并且在入口旁边还有一个入口等待队列。当多个线程同时试图进入管程内部时,只允许一个线程进入,其他线程则在入口等待队列中等待。这个过程类似就医流程的分诊,只允许一个患者就诊,其他患者都在门口等待。
管程里还引入了条件变量的概念,而且每个条件变量都对应有一个等待队列,如下图,条件变量 A 和条件变量 B 分别都有自己的等待队列。
条件变量和条件变量等待队列的作用是什么呢?其实就是解决线程同步问题。
Java 内置的管程方案(synchronized)使用简单,synchronized 关键字修饰的代码块,在编译期会自动生成相关加锁和解锁的代码,但是仅支持一个条件变量;而 Java SDK 并发包实现的管程支持多个条件变量,不过并发包里的锁,需要开发人员自己进行加锁和解锁操作。
如何更好的理解管程
管程是一个解决并发问题的模型,可以参考医院就医的流程来加深理解。理解这个模型的重点在于理解条件变量及其等待队列的工作原理。
管程至少有两个等待队列。一个是进入管程的入口等待队列一个是条件变量对应的等待队列。前者只有一个,就像排队等待某个科室医生看病的队列只有一个;后者可以有多个(类似于患者分诊),就像一个病人进入门诊室诊断后,需要去验血,那么它需要去抽血室排队等待。另外一个病人心脏不舒服,需要去拍胸片,去拍摄室等待,也就是说可能有多个条件。
wait() 的正确姿势
对于 MESA 管程来说,有一个编程范式,就是需要在一个 while 循环里面调用 wait()。这个是 MESA 管程特有的。
while(条件不满足) {
wait();
}
锁池和等待池 VS 条件变量等待队列和入口等待队列
锁池:假设有多个线程竞争一个资源,如果某个线程 A 已经占用这个资源,那么其他线程想要对用这个对象的 synchronized 方法,由于这些线程在进入对象的 synchronized 方法之前必须先获得该对象的锁的拥有权,但是该对象的锁目前正被线程 A 拥有,所以这些线程就进入了该对象的锁池中。
等待池:假设一个线程 A 调用了某个对象的 wait() 方法,线程 A 就会释放该对象的锁后,进入到了该对象的等待池中。这里的对象的等待池就是 waitSet。
条件变量等待队列(等待互斥锁队列):以 synchronized 举例来说,同一时刻只允许一个线程进入 synchronized 保护的临界区,一个线程进入临界区后,其他线程就只能在条件变量等待队列中进行等待(相当于患者分诊)。
入口等待队列(等待通知队列):
当一个线程进入临界区以后,如果某些条件不满足,需要进入等待状态(比如调用 wait 方法),当前线程就会被阻塞,进入到入口等待队列中(waitSet)。当条件满足时调用 notify(),通知入口等待队列中的某个线程,告诉它条件曾经满足过(因为 notify() 只能保证在通知时间点,条件是满足的。而被通知线程的执行时间点和通知的时间点基本上不会重合,所以线程执行的时候,很可能条件已经不满足了(保不齐有其他线程插队)),唤醒某个线程后进入条件变量等待队列中重新去竞争获取 synchronized 锁。
很多人可能会对 ContentionList 和 EntryList 会有所疑惑,这里说一下他们的区别:锁未释放时进入 ContentionList,锁释放,进入竞争时,进入 EntryList。
锁池和等待池是网上看到的一篇文章中写的概念,文章写得非常好,个人感觉锁池对应条件变量等待队列,等待池对于入口等待队列。
notify() 和 notifyAll() 的区别
notify() 是会随机地通知等待队列中的一个线程,而 notifyAll() 会通知等待队列中的所有线程。感觉上是使用 notify() 更好一些,因为即使通知了所有的线程也只有一个线程进入临界区。但那所谓的感觉往往都蕴藏着风险,实际上使用 notify() 也很有风险,它的风险在于可能导致某些线程永远不会被通知到。比如下面这个生动的例子:
有两个顾客要买水果,但同时只能有一个人进店里买(也就是只有有抢到锁的人才能进去买水果),顾客A想要买橘子,顾客B想要买苹果,但是目前店里什么都没有,那么A和B都在while循环里面调wait方法进行阻塞等待(这时候锁已经释放),然后店员C去进货进了苹果,然后开始通知大家可以来买水果了(也就是调用锁的notify方法),这里notify方法随机唤醒一个顾客,假设唤醒了顾客B,顾客B拿到锁之后发现要的橘子还是没有(对应while循环的条件还是没满足)又调了wait进行阻塞等待,结果这样就导致明明有苹果,但是A还是等在死那。但如果是notifyAll方法的话,那么就同时通知A和B(唤醒A和B),这时两个顾客竞争锁,假设拿到锁的还是B,他发现没有橘子于是接着wait释放锁,这时候A就能拿到B释放的锁,然后就可以买到想要的苹果了,这样就不会出现上面发生的死等现象。
也就是说当我们使用 notifyAll() 时,它将唤醒所有的 wait 队列线程争夺锁,假如线程不满足条件再次进入 wait 时,其他唤醒的 wait 队列的线程仍旧会去争夺锁。
使用notify() 需要满足一下三个条件
- 所有等待线程拥有相同的等待条件;
- 所有等待线程被唤醒后,执行相同的操作;
- 只需要唤醒一个线程。
总结
本文先讲了管程是什么,然后讲解了 Java 为什么选择了管程以及如何更好的理解管程。wait() 的正确姿势,同时还对比了网上说的锁池和等待池 VS 条件变量等待队列和入口等待队列,notify 和 notifyAll 的的区别,这一点是网上很多文章都没讲清楚的。
简单的描述一下 synchronized 的条件变量和入口等待队列,当多个线程(比如A,B,C)想要进入一个 synchronized 临界区时(如果此时已经有某个线程D占有锁了),那么 A,B,C 线程会进入条件变量等待队列进行排队等待;如果那个已经占有锁的线程 D 在 synchronized 中调用 wait 方法进行等待,同时释放锁,这个时候该线程 D 会进入入口等待队列(waitSet)进行等待,假设这个时候 A,B,C 分别抢到了锁,当时同样也不符合某种条件,在该 synchronized 中调用了 wait() 方法,进入入口等待队列(waitSet);
现在的情况是 A,B,C,D 都在入口等待队列(waitSet)中,此时如果某个方法调用了 notify 方法,它随机唤醒某个线程 B,然后该线程进入 (入口等待队列)EntryList 队列中(为什么是进入它呢,因为锁未释放时进入 ContentionList,锁释放,进入竞争时,进入 EntryList),此时的 EntryList 中只有 B 线程,没人和他抢自然就能获取到锁;
为什么说 notify 它的风险在于可能导致某些线程永远不会被通知到,因为假如上面的 B 进入 synchronized 后发现不符合某个条件后自己调用了 wait(),又进入了等待状态了,但是符合条件的 D 线程没有抢到锁,那它就可能就永远不会被通知到了(如果没有人再调用 notify() 的话)。而为什么 notifyAll() 方法可以呢,原因是这样的:
调用了 notifyAll 后,这个时候因为是所有线程都被唤醒然后去竞争锁,还是像上面那样 B 线程抢到了锁但是不符合条件调用了wait() 进入等待状态,这个时候 A,C,D 还可以继续竞争锁,这个时候 D 抢到了锁,而且符合条件,那么 D 线程的内容就会被执行了。
并发编程的互斥问题的解决就是将共享变量及其对共享变量的操作统一封装起来,同一时间只能由一个进入管程中(排队出入),而同步问题是通过条件变量和条件变量等待队列进行解决的。
MESA 管程里面,T2 通知完 T1 后,T2 还是会接着执行,T1 并不立即执行,仅仅是从条件变量的等待队列进到入口等待队列里面。这样做的好处是 notify() 不用放到代码的最后,T2 也没有多余的阻塞唤醒操作。但是也有个副作用,就是当 T1 再次执行的时候,可能曾经满足的条件,现在已经不满足了,所以需要以循环方式检验条件变量。
最后,本文参考了很多优秀的文章,感谢他们!