目录
管程是一种可以很方便解决并发问题的核心技术,Java 语言在 1.5 之前,提供的唯一的并发原语就是管程,而且 1.5 之后提供的 SDK并发包,也是以管程技术为基础的。可以说,管程就是一把解决并发问题的万能钥匙。
1. 什么是管程
不知道你是否曾思考过这个问题:为什么 Java 在 1.5 之前仅仅提供了 synchronized 关键字及 wait()、notify()、notifyAll() 这三个看似从天而降的方法?在刚接触 Java 的时候,我以为它会提供信号量这种编程原语,因为操作系统原理课程告诉我,用信号量能解决所有并发问题,结果我发现不是。后来我找到了原因:Java 采用的是管程技术,synchronized 关键字及 wait()、notify()、notifyAll() 这三个方法都是管程的组成部分。而管程和信号量是等价的,所谓等价指的是用管程能够实现信号量,也能用信号量实现管程。但是管程更容易使用,所以 Java 选择了管程。
所谓管程,指的是管理共享变量以及对共享变量操作的过程,让它们支持并发。翻译为 Java 语言,就是管理类的成员变量和成员方法,让这个类是线程安全的。管程对应的英文名是 Monitor。那管程是怎么管的呢?
2. 管程模型
在管程的发展史上,先后出现过三种不同的管程模型,分别是:Hasen(哈森)模型、Hoare(霍尔)模型和MESA(梅萨)模型。其中,现在广泛应用的是 MESA模型,并且 Java 管程的实现参考的也就是 MESA 模型。
在并发编程领域,有两大核心问题:一个是互斥,即同一时刻只允许一个线程访问共享资源;另一个是同步,即线程间如何通信、协作。这两大问题,管程都是能够解决的。
2.1 解决互斥问题
管程解决互斥问题的思路很简单,就是将共享变量和对共享变量的操作统一封装起来。在下图中,管程 X 将共享变量 queue 这个队列和相关的操作入队 enq()、出队 deq() 都封装起来了;线程 A 和线程 B 如果想访问共享变量 queue,只能通过调用管程提供的 enq()、deq() 方法来实现, enq()、deq() 保证互斥性,只允许一个线程进入管程,你会发现,管程模型和面向对象高度契合,估计这就是 Java 选择管程的原因,而前面介绍的互斥锁的用法,其背后的模型其实就是它。
2.2 解决线程间的同步问题
解决线程间的同步问题,比较复杂,但是可以借鉴一下前面的就医流程,为了进一步理解,下面展示 MESA 管程模型的示意图,它详细介绍了 MESA 模型的主要组成部分。
在管程模型里,共享变量和对共享变量的操作是被封装起来的。图中最外层的框就代表封装的意思。框的上面只有一个入口,并且在入口旁边还有一个入口等待队列。当多个线程同时如图进入管程内部时,只允许一个线程进入,其他线程则在入口等待队列中等待。这个过程类似就医流程的分诊,只允许一个患者就诊,其他患者都在门口等待。
管程里还引入了条件变量的概念,而且每个条件变量都对应有一个等待队列,如下图,条件变量 A 和条件变量 B 分别都有自己的等待队列。
那条件变量和条件队列的作用是什么呢?其实就是解决线程同步问题的。你也可以结合上面提到的入队出队的例子加深一下理解。
假设有个线程 T1 执行出队操作,不过出队操作有个前提,就是队列不能为空,而队列为空这个前提条件就是管程里的条件变量。如果线程 T1 进入管程里发现队列是空的,就去条件变量对应的等待队列中等待。
在假设之后另一个线程 T2 执行入队操作,入队成功后,队列不为空条件对线程 T1 来说以及满足了,此时线程 T2 要通知 T1,当线程 T1 得到通知后,会从等待队列中出来,但是出来后不是马上执行,而且从新进入入口等待队列,这个过程类似于你化验完了,回来找大夫,需要从新排队分诊。
条件变量和等待队列清楚了,下面说说 wait()、notify()、notifyAll() 这三个操作。前面提到线程 T1 发现队列为空不满足时,需要进入等待队列里等待,这个过程就是通过调用 wait() 方法来实现的,同理当队列不空条件满足时,线程 T2 需要调用 A.notify() 来通知 A 等待队列中的一个。
2.3 代码实现
下面用代码说明一下,下面实现的是一个阻塞队列,队列有两个操作分别是入队和出队,这两个方法都是先获取互斥锁。类比管程模型中的入口。
public class BlockedQueue<T>{
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();
}
}
}
3. wait() 的正确姿势
有一点需要再次提醒,对于 MESA 管程来说,有一个范式,就是需要在一个 while 循环里调用 wait()。这个是 MESA管程持有的。
while(条件不满足) {
wait();
}
Hasen(哈森)模型、Hoare(霍尔)模型和MESA(梅萨)模型的一个核心区别就是当条件满足后,如何通知相关线程。管程要求同一时刻只允许一个线程执行,那当线程 T2 的操作使线程 T1 等待的条件满足时,T1 和 T2 究竟谁可以执行?
- Hasen(哈森)模型里面,要求 notify() 放在代码的最后,只有 T2 通知完 T1 后,T2 就结束了,然后 T1 在执行,只有就能保证同一时刻只有一个线程执行。
- Hoare(霍尔)模型里面,T2 通知完 T1后,T2 阻塞,T1 马上执行;等 T1 执行完,在唤醒 T2 执行,也能保证同一时刻只有一个线程执行,但是相比Hasen(哈森)模型,T2 多了一次阻塞唤醒操作。
- MESA(梅萨)模型里面,T2 通知完 T1 后,T2 还会接着执行,T1 并不会立即执行,仅仅是从条件变量的等待队列进到入口等待队列里面,只样做的好处是 notify() 不用放在代码的最后,T2 也没有多余的阻塞唤醒操作。但是也有个副作用,就是当 T1 再次执行的时候,可以曾经满足的条件,现在已经不满足了,所以需要以循环方式检验条件变量。
4. notify() 何时可以使用
还有一个需要注意的地方,就是 notify() 和 notifyAll() 的使用,前面章节,我曾经介绍过,除非经过深思熟虑,否则尽量使用 notifyAll()。那什么时候可以使用 notify() 呢?需要满足以下三个条件:
- 所有等待线程拥有相同的等待条件;
- 所有等待线程被唤醒后,执行相同的操作;
- 只需要唤醒一个线程。
5.并发编程相关的一些概念
- 线程安全:本质上就是正确性,而正确性的含义就是程序按照我们期望的执行,不要让我们感到意外;
- 数据竞争:当多个线程同时访问同一数据,并且至少有一个线程会写这个数据的时候,如果我们不采取防护措施,那么就会导致并发 Bug;
- 竞态条件:指的是程序的执行结果依赖线程执行的顺序;
- 死锁:线程互相等待,而且会一直等待下去,导致线程永久地“阻塞”;
- 活锁:线程虽然没有发生阻塞,但仍然会存在执行不下去的情况;
- 饥饿:线程因无法访问所需资源而无法执行下去的情况;
- 吞吐量:指的是单位时间内能处理的请求数量。吞吐量越高,说明性能越好;
- 延迟:指的是从发出请求到收到响应的时间。延迟越小,说明性能越好;
- 并发量:指的是能同时处理的请求数量,一般来说随着并发量的增加、延迟也会增加。所以延迟这个指标,一般都会是基于并发量来说的。例如并发量是 1000 的时候,延迟是 50 毫秒。