前情回顾
上篇文章为你叙述了 Java 语言为了解决多线程并发问题,提出的方案,其中包括:
- Happens-Before 规则
- Volatile 关键字
着重讲了 volatile 的原理以及使用场景,单单靠上述方案,能够解决的并发问题实在有限,因此本文为你介绍并发编程界的『老大哥』,管程。
管程是什么?
很多小伙伴多多少少听说过管程这个词,但就是感觉它虚无缥缈的,说不清道不明,确实是这样,因为管程是一种思想,而这种思想超脱于语言之上,绝大多数语言实现并发编程都是依靠管程思想。
所谓管程,指的是管理共享数据以及对共享数据的操作过程,让它们支持并发,对应的英文单词是 Monitor。
根据实现的不同,衍生出了几种不同的管程模型:
- Mesa 模型
- Hasen 模型
- Hoare 模型
今天主要介绍的是 Mesa 模型,Java 语言中并发编程就是依赖于这种模型实现的。
Mesa 模型
并发程序中两大核心问题:互斥与同步,即同一时间,只能有一个线程访问共享变量,以及线程之间如何通信与协作。我们来看一下管程是如何解决这两个问题的。
互斥问题
这个问题其实很容易,如何限制多线程访问共享变量,可以把共享变量封装起来,为访问变量,操作变量提供统一入口,并对这个入口进行加锁操作,这就好比足球场的检票入口一般,保证同一时间同一检票口只能通过一个人。
而 Java 中互斥锁的实现,其实就是依赖于 Mesa 模型。
同步问题
关于线程同步,大家可以想象一下医院就诊的过程,患者挂完号之后要进行分诊,一个医生同一时间只能接待一位患者,而其它患者需要在门外等待,这个场景下医生就像是共享变量,而门外的座位对应的就是管程中的入口等待队列,而等待的患者就是入口等待队列中的线程。
下面这幅图展现了管程模型的结构:
可以看到 Mesa 管程还引入了条件变量的概念,还是回到现实生活中就诊流程中。
假设分诊已经轮到你了,医生询问了一番之后,可能会叫你去验血,或者拍 CT,完成之后再回来进一步诊断,而这个验血,拍 CT 就可以看作是条件变量,每个条件变量都对应有一个条件变量等待队列,就好比现实生活中验血,拍 CT 排队等待的过程。
等一切检查都完成之后再回到之前就诊的科目处重新进行分诊,这个就对应管程中线程获取到条件变量后,重新回到入口等待队列,等待进入管程的机会。
管程实现
Java 语言内置的管程方案 synchronized 相信大家都不陌生,使用起来也方便,加锁释放锁都在底层隐式进行,但是它仅支持一个条件变量,就是 synchronized 锁住的那个对象。
而 Java 中另外一种管程实现——Lock 则更加灵活,支持多个条件变量,可以应对比较复杂的场景,例如生产者消费者模式。
阻塞队列
Java 语言提供阻塞队列,就是通过生产者消费者模式实现的,而其中对并发的控制则是依赖于 Lock 实现,这边参照的是 ArrayBlockingQueue 源码,很典型的管程模型。
首先在成员变量列表中可以看到管程变量的定义,这边定义了两个条件变量,notEmpty,notFull
/** Main lock guarding all access */
final ReentrantLock lock;
/** Condition for waiting takes */
private final Condition notEmpty;
/** Condition for waiting puts */
private final Condition notFull;
阻塞队列进行 put 操作时,会检查是否满足入队操作,不满足则进行条件变量 notFull 的等待
public void put(E e) throws InterruptedException {
checkNotNull(e);
final ReentrantLock lock = this.lock;
lock.lockInterruptibly();
try {
while (count == items.length)
notFull.await();
enqueue(e);
} finally {
lock.unlock();
}
}
成功入队之后需要唤醒等待条件变量 notEmpty 的线程,通知它们队列不为空
private void enqueue(E x) {
final Object[] items = this.items;
items[putIndex] = x;
if (++putIndex == items.length)
putIndex = 0;
count++;
notEmpty.signal();
}
阻塞队列进行 take 操作时,同样会检查是否满足出队条件,不满足则进行条件变量 notEmpty 的等待
public E take() throws InterruptedException {
final ReentrantLock lock = this.lock;
lock.lockInterruptibly();
try {
while (count == 0)
notEmpty.await();
return dequeue();
} finally {
lock.unlock();
}
}
成功出队之后需要环形等待条件变量 notFull 的线程,通知它们队列不满
private E dequeue() {
final Object[] items = this.items;
@SuppressWarnings("unchecked")
E x = (E) items[takeIndex];
items[takeIndex] = null;
if (++takeIndex == items.length)
takeIndex = 0;
count--;
if (itrs != null)
itrs.elementDequeued();
notFull.signal();
return x;
}
可以看到阻塞队列就是依赖于 Mesa 管程模型保证线程安全:
- 生产者线程对阻塞队列操作前,先检查 lock 锁,获取到锁则进入管程
- 检查是否满足操作(入队)条件,不满足则进入条件变量(notFull)的等待队列当中,并释放之前获取的管程锁
- 另一方消费者线程操作(出队)完毕之后,通知 notFull 条件变量等待线程(生产者线程)
而以上被保护起来的区域,我们可以看做是管程监控管辖的区域,管程保证这块区域是线程安全的。
注意
在 Mesa 模型的管程实现中,可以看到我们使用 while 循环重复检查条件是否满足。
try {
while (count == 0)
notEmpty.await();
return dequeue();
} finally {
lock.unlock();
}
因为该模型下,执行线程唤醒操作(singal)之后线程不会马上被唤醒,还要进入 monitor 入口队列中等待 monitor 锁,得到 monitor 锁之后才能被唤醒,因此从执行唤醒命令,到线程真正被唤醒,中间有一个时间间隔,很有可能条件已经不满足,需要重新检查。
总结
本文主要介绍了并发编程中的管程思想,并选择 Mesa 管程模型展开叙述,其中涉及:
- Mesa 管程模型的结构
- Java 语言内置管程实现
- ArrayBlockingQueue 源码分析
相关知识
-
synchronized 内置锁原理
-
ReentrantLock 可重入锁
-
生产者消费者模式