Java语言在1.5之前,提供的唯一的并发原语就是管程,1.5之后的提供的SDK并发包,也是以管程技术为基础的。C/C++、C#等高级语言也都支持管程。管程是一把解决并发问题的万能钥匙。
管程定义
操作系统原理,用信号量能解决所有并发问题。
Java采用的是管程管程,synchronized关键字及wait()、notify()、notifyAll()这三个方法都是管程的组成部分。
管程和信号量是等价的,所谓等价指的是用管程能够实现信号量,也能用信号量实现管程。但是管程更容易使用。
管程,对应的英文是Monitor(Java领域直译为监视器,操作系统领域翻译为管程)。
所谓管程,指的是管理共享变量以及对共享变量的操作过程,让他们支持并发。翻译为Java领域的语言,就是管理类的成员变量和成员方法,让这个类时线程安全的。
MESA模型
在管程的发展史上,先后出现过三种不同的管程模型,分别是:Hasen模型、Hoare模型和MESA模型。现在广泛应用的是MESA模型,Java管程的实现参考的也是MESA模型。
Hasen是执行完,再去唤醒另外一个线程,能够保证线程的执行。
Hoare是中断当前线程,唤醒另外一个线程,执行完再去唤醒原线程,也能够保证完成。
mesa是进入等待队列,不一定有机会能够执行。
在并发编程领域,有两大核心问题:一个是互斥,即同一时刻只允许一个线程访问共享资源;另一个是同步,即线程之间如何通信、协作。这两大问题,管程都是能够解决的。
互斥问题
管程解决互斥问题的思路,就是将共享变量及其对共享变量的操作统一封装起来。假如要实现一个线程安全的阻塞队列,一个最直观的想法就是:将线程不安全的队列封装起来,对外提供线程安全的操作方法-入队操作&出队操作。
管程模型和面向对象高度契合。
同步问题

在管程模型里,共享变量和对共享变量的操作是被封装起来的,图中最外层的框就代表封装的意思。框的上面只有一个入口,并且在入口旁边还有一个入口等待队列。当多个线程同时试图进入管程内部时,只允许一个线程进入,其他线程则在入口等待队列中等待。(这个过程类似就医流程的分诊,只允许一个患者就诊,其他患者都在门口等待)
管程里还引入了条件变量的概念,而且每个条件变量都对应有一个等待队列(条件变量A和条件变量B分别都有自己的等待队列)。条件变量和条件变量等待队列的作用就是解决线程同步问题。(用管程来实现线程安全的阻塞队列,这个阻塞队列和管程内部的等待队列没有关系,阻塞队列和等待队列是不同的)
假设有个线程T1执行阻塞队列的出队操作(执行出队操作,有个前提条件就是阻塞队列不能是空的,空队列只能出Null值,是不允许的),阻塞队列不空这个前提条件对应的就是管程里的条件变量。如果线程T1进入管程后恰好发现阻塞队列是空的,就去条件变量对应的等待队列里面等。此时线程T1就去“队列不空”这个条件变量的等待队列中等待。(这个过程类似于大夫发现你要去验个血,于是给你开了个验血的单子,你就去验血的队伍里排队。线程T1进入条件变量的等待队列后,是允许其他线程进入管程的。你去验血的时候,医生可以给其他患者诊治)
假设之后另外一个线程T2执行阻塞队列的入队操作,入队操作执行成功之后,“阻塞队列不空”这个条件对于线程T1来说已经满足了,此时线程T2要通知T1,告诉它需要的条件已经满足了。当线程T1得到通知后,会从等待队列里面出来,但是出来之后不是马上执行,而是重新进入到入口等待队列里面。(这个过程类似你验血完,回来找大夫,需要重新分诊)
线程T1发现“阻塞队列不空”这个条件不满足,需要进到对应的等待队列里等待。这个过程就是通过调用wait()来实现的。如果用对象A代表“阻塞队列不空”这个条件,那么线程T1需要调用A.wait()。同理当“阻塞队列不空”这个条件满足时,线程T2需要调用A.notify()来通知A等待队列中的一个线程,此时这个等待队列里面只有线程T1。至于notifyAll()这个方法,它可以通知等待队列中的所有线程。
阻塞队列和管程内部的等待队列没有关系。
阻塞队列有两个操作分别是入队和出队,这两个方法都是先获取互斥锁,类比管程模型中的入口。
1 | public class BlockedQueue<T> { |
- 对于阻塞队列的入队操作,如果阻塞队列已满,就需要等待直到阻塞队列不满,所以这里用了notFull.await();
- 对于阻塞队列的出队操作,如果阻塞队列为空,就需要等待直到阻塞队列不空,所以这里用了notEmpty.await();
- 如果入队成功,那么阻塞队列就不空了,就需要通知条件变量:阻塞队列不空notEmpty对应的等待队列。
- 如果出队成功,那么阻塞队列就不满了,就需要通知条件变量:阻塞队列不满notFull对应的等待队列。
在示例代码中,使用了Java并发包里面的Lock和Condition。
await()和wait()语义是一样的;signal()和notify()语义是一样的。
wait()的正确姿势
对于MESA管程来说,有一个编程范式,就是需要再一个while循环里面调用wait()。这个是MESA管程特有的。while(条件不满足) { wait(); }
调用wait的线程被唤醒,不一定100%能通过条件测试,需要再次判断条件,如果不能通过,while可以继续调用wait等待,但是if做不到(被唤醒后直接执行wait之后的代码)。
Hasen模型、Hoare模型和MESA模型的一个核心区别就是当条件满足后,如何通知相关线程。管程要求同一时刻只允许一个线程执行,当线程T2的操作使线程T1等待的条件满足时:
- Hasen模型里面,要求notify()放在代码的最后,这样T2通知完T1后,T2就结束了,然后T1再执行,这样就能保证同一时刻只有一个线程执行。
- Hoare模型里面,T2通知完T1后,T2阻塞,T1马上执行;等T1执行完,再唤醒T2,也能保证同一时刻只有一个线程执行。但是相比Hasen模型,T2多了一次阻塞唤醒操作。
- MESA管程里面,T2通知完T1后,T2还是会接着执行,T1并不立即执行,仅仅是从条件变量的等待队列进入到入口等待队列里面,这样做的好处是notify()不用放到代码的最后,T2也没有多余的阻塞唤醒操作。副作用是,当T1再次执行的时候,可能曾经满足的条件,现在已经不满足了,所以需要以循环方式检验条件变量。
wait中添加超时时间参数,避免条件一直不满足时死等;或者条件满足但通知失败了还在傻等。
notify()何时可以使用
除非经过深思熟虑,否则尽量使用notifyAll()。使用notify()的条件:
- 所有等待线程拥有相同的等待条件;
- 所有等待线程被唤醒后,执行相同的操作;
- 只需要唤醒一个线程。
阻塞队列的例子中,对于“阻塞队列不满”这个条件变量,其等待线程都是在等待“阻塞队列不满”这个条件,反映在代码里就是:while(阻塞队列已满){ notFull.await(); }
对所有等待线程来说,都是执行这段代码,重点是while里面的等待条件是完全相同的。
所有等待线程被唤醒后执行的操作也是相同的:notEmpty.signal();
同时满足第三条,只需要唤醒一个线程。
所以阻塞队列代码中,使用signal()是可以的。
总结
管程是一个解决并发问题的模型,理解这个模型的重点在于理解条件变量及其等待队列的工作原理。
Java参考了MESA模型,语言内置的管程(synchronized)对MESA模型进行了精简(ObjectMonitor)。MESA模型中,条件变量可以有多个,Java语言内置的管程里只有一个条件变量。
Java内置的管程方案(synchronized)使用简单,synchronized关键字修饰的代码块,在编译期间会自动生成相关加锁和解锁的代码,但是仅支持一个条件变量(调用wait方法的句柄就是条件变量。没有持有锁的对象,不允许调用wait()方法);
(synchronized+wait、notify、notifyAll)
而Java SDK并发包实现的管程支持多个条件变量,不过并发包里的锁,需要开发人员自己进行加锁和解锁操作。
(lock+condition)
并发编程里两大核心问题-互斥和同步,都可以由管程来解决。管程理论上可以解决所有的并发问题,很多并发工具类底层都是管程实现的。