目录
1. 什么是管程
2. 管程模型
2.1 解决互斥问题
2.2 解决线程间的同步问题
2.3 代码实现
3. wait() 的正确姿势
4. notify() 何时可以使用
5. 总结
6. 思考
管程是一种可以很方便解决并发问题的核心技术,Java 语言在 1.5 之前,提供的唯一的并发原语就是管程,而且 1.5 之后提供的 SDK并发包,也是以管程技术为基础的。可以说,管程就是一把解决并发问题的万能钥匙。
不知道你是否曾思考过这个问题:为什么 Java 在 1.5 之前仅仅提供了 synchronized 关键字及 wait()、notify()、notifyAll() 这三个看似从天而降的方法?在刚接触 Java 的时候,我以为它会提供信号量这种编程原语,因为操作系统原理课程告诉我,用信号量能解决所有并发问题,结果我发现不是。后来我找到了原因:Java 采用的是管程技术,synchronized 关键字及 wait()、notify()、notifyAll() 这三个方法都是管程的组成部分。而管程和信号量是等价的,所谓等价指的是用管程能够实现信号量,也能用信号量实现管程。但是管程更容易使用,所以 Java 选择了管程。
所谓管程,指的是管理共享变量以及对共享变量操作的过程,让它们支持并发。翻译为 Java 语言,就是管理类的成员变量和成员方法,让这个类是线程安全的。管程对应的英文名是 Monitor。那管程是怎么管的呢?
在管程的发展史上,先后出现过三种不同的管程模型,分别是:Hasen(哈森)模型、Hoare(霍尔)模型和MESA(梅萨)模型。其中,现在广泛应用的是 MESA模型,并且 Java 管程的实现参考的也就是 MESA 模型。
在并发编程领域,有两大核心问题:一个是互斥,即同一时刻只允许一个线程访问共享资源;另一个是同步,即线程间如何通信、协作。这两大问题,管程都是能够解决的。
管程解决互斥问题的思路很简单,就是将共享变量和对共享变量的操作统一封装起来。在下图中,管程 X 将共享变量 queue 这个队列和相关的操作入队 enq()、出队 deq() 都封装起来了;线程 A 和线程 B 如果想访问共享变量 queue,只能通过调用管程提供的 enq()、deq() 方法来实现, enq()、deq() 保证互斥性,只允许一个线程进入管程,你会发现,管程模型和面向对象高度契合,估计这就是 Java 选择管程的原因,而前面介绍的互斥锁的用法,其背后的模型其实就是它。
解决线程间的同步问题,比较复杂,但是可以借鉴一下前面的就医流程,为了进一步理解,下面展示 MESA 管程模型的示意图,它详细介绍了 MESA 模型的主要组成部分。
在管程模型里,共享变量和对共享变量的操作是被封装起来的。图中最外层的框就代表封装的意思。框的上面只有一个入口,并且在入口旁边还有一个入口等待队列。当多个线程同时如图进入管程内部时,只允许一个线程进入,其他线程则在入口等待队列中等待。这个过程类似就医流程的分诊,只允许一个患者就诊,其他患者都在门口等待。
管程里还引入了条件变量的概念,而且每个条件变量都对应有一个等待队列,如下图,条件变量 A 和条件变量 B 分别都有自己的等待队列。
那条件变量和条件队列的作用是什么呢?其实就是解决线程同步问题的。你也可以结合上面提到的入队出队的例子加深一下理解。
假设有个线程 T1 执行出队操作,不过出队操作有个前提,就是队列不能为空,而队列为空这个前提条件就是管程里的条件变量。如果线程 T1 进入管程里发现队列是空的,就去条件变量对应的等待队列中等待。
在假设之后另一个线程 T2 执行入队操作,入队成功后,队列不为空条件对线程 T1 来说以及满足了,此时线程 T2 要通知 T1,当线程 T1 得到通知后,会从等待队列中出来,但是出来后不是马上执行,而且从新进入入口等待队列,这个过程类似于你化验完了,回来找大夫,需要从新排队分诊。
条件变量和等待队列清楚了,下面说说 wait()、notify()、notifyAll() 这三个操作。前面提到线程 T1 发现队列为空不满足时,需要进入等待队列里等待,这个过程就是通过调用 wait() 方法来实现的,同理当队列不空条件满足时,线程 T2 需要调用 A.notify() 来通知 A 等待队列中的一个。
下面用代码说明一下,下面实现的是一个阻塞队列,队列有两个操作分别是入队和出队,这两个方法都是先获取互斥锁。类比管程模型中的入口。
public class BlockedQueue{
final Lock lock = new ReentrantLock();
// 条件变量:队列不满
final Condition notFull = lock.newCondition();
// 条件变量:队列不空
final Condition notEmpty = lock.newCondition();
// 入队
void enq(T x) throws Exception {
lock.lock();
try {
while (队列已满){
// 等待队列不满
notFull.await();
}
// 省略入队操作...
// 入队后, 通知可出队
notEmpty.signal();
}finally {
lock.unlock();
}
}
// 出队
void deq() throws Exception {
lock.lock();
try {
while (队列已空){
// 等待队列不空
notEmpty.await();
}
// 省略出队操作...
// 出队后,通知可入队
notFull.signal();
}finally {
lock.unlock();
}
}
}
有一点需要再次提醒,对于 MESA 管程来说,有一个范式,就是需要在一个 while 循环里调用 wait()。这个是 MESA管程持有的。
while(条件不满足) {
wait();
}
Hasen(哈森)模型、Hoare(霍尔)模型和MESA(梅萨)模型的一个核心区别就是当条件满足后,如何通知相关线程。管程要求同一时刻只允许一个线程执行,那当线程 T2 的操作使线程 T1 等待的条件满足时,T1 和 T2 究竟谁可以执行?
《Effective Java》第二版中文版第69条244页位置对这一点说了一页,我看着一知半解。我能理解的一点是:对于从wait中被notify的进程来说,它在被notify之后还需要重新检查是否符合执行条件,如果不符合,就必须再次被wait,如果符合才能往下执行。所以:wait方法应该使用循环模式来调用。按照上面的生产者和消费者问题来说:错误情况一:如果有两个生产者A和B,一个消费者C。当存储空间满了之后,生产者A和B都被wait,进入等待唤醒队列。当消费者C取走了一个数据后,如果调用了notifyAll(),注意,此处是调用notifyAll(),则生产者线程A和B都将被唤醒,如果此时A和B中的wait不在while循环中而是在if中,则A和B就不会再次判断是否符合执行条件,都将直接执行wait()之后的程序,那么如果A放入了一个数据至存储空间,则此时存储空间已经满了;但是B还是会继续往存储空间里放数据,错误便产生了。错误情况二:如果有两个生产者A和B,一个消费者C。当存储空间满了之后,生产者A和B都被wait,进入等待唤醒队列。当消费者C取走了一个数据后,如果调用了notify(),则A和B中的一个将被唤醒,假设A被唤醒,则A向存储空间放入了一个数据,至此空间就满了。A执行了notify()之后,如果唤醒了B,那么B不会再次判断是否符合执行条件,将直接执行wait()之后的程序,这样就导致向已经满了数据存储区中再次放入数据。错误产生。
还有一个需要注意的地方,就是 notify() 和 notifyAll() 的使用,前面章节,我曾经介绍过,除非经过深思熟虑,否则尽量使用 notifyAll()。那什么时候可以使用 notify() 呢?需要满足以下三个条件:
Java 参考了 MESA 模型,语言内置的管程(synchronized)对 MESA 模型进行了精简。MESA 模型中,条件变量可以有多个,Java 语言内置的管程里只有一个条件变量,如图所示:
Java 内置的管程方案 (synchronized)使用简单,synchronized 关键字修饰的代码块,在编译期会自动生成相应的加锁和解锁代码,但是仅支持一个条件变量;而 Java SDK 并发包实现的管程支持多个条件变量,不过并发包里的锁,需要开发人员总结进行加锁和解锁操作。
wait() 方法,在Hasen(哈森)模型、Hoare(霍尔)模型里面,都是没有参数的,而在MESA(梅萨)模型里面,增加了超时参数,你觉得这个参数有必要么?
wait() 不加超时参数,相当于得一直等着别人叫你去门口排队,加了超时参数,相当于等一段时间,再没人叫的话,我就受不了自己去门口排队了,这样就诊的机会会大一点,避免没人叫的时候傻等。