1、什么是管程

一个管程是一个由过程、变量及数据结构组成的一个集合,它们组成一个特殊的模块或软件包。进程可以在任何需要的时候调用管程中的过程,但不能在管程之外声明的过程中直接访问管程内的数据结构,即不能在管程之外的方法直接访问管程内的数据结构,必须通过管程提供的方法访问。

一个管程由四个部分组成,管程名称共享数据说明对数据进行操作的一组过程对共享变量初始化的语句。管程能够保证共享资源的互斥性,即同一时刻只能有一个进程可以在管程内活动。该功能由管程本身是实现的,因此,程序员不用显示的去实现这种同步约束。

Java 的并发问题解决方案,采用的是管程技术,其 synchronized 关键字及 Object 的 wait、notifyAll、notify 这三个方法都是管程的组成部分。而管程和信号量是等价的,所谓等价指的是用管程能够实现信号量,也能够使用信号量实现管程。由于管程更容易使用,所以 Java 选择了管程的方案。

管程的英语是 Monitor,很多地方会翻译成监视器,这是直译,操作系统一般都翻译成管程,这个是意译。所谓管程指的是管理共享变量以及对共享变量的操作过程,让他们支持并发。对 Java 而言,就是管理类的成员变量和成员方法,让这个类是线程安全的。

2、MESA 模型

在管程的发展史上,先后出现过三种不同的管程模型,分别是:Hasen、Hoare 和 MEAS 模型。其中,现在最广泛使用的是 MESA 模型,并且 Java 管程的实现参考的也是 MESA 模型。

在并发变成领域,有两大核心问题:一个是互斥,即同一时刻只允许一个线程访问共享资源;另一个是同步,即线程之间如何通信、协作。这两个问题,管程都能够解决。

2.1、互斥问题

管程解决互斥问题的思路,就是将共享变量及其对共享变量的操作统一封装起来。假如我们实现一个线程安全的阻塞队列,一个最直观的想法就是,将线程不安全的队列封装起来,对外提供线程安全的操作方法,如入队和出队操作。

利用管程,如何快速实现这个直观的想法呢?如下图,管程 X 将共享变量 queue 这个线程不安全的队列和相关的入队 enq 和出队 deq 操作封装起来;线程 A 和线程 B 如果想要访问队列 queue,只能通过调用管程提供的 enq 和 deq 方法来实现。其中 enq 和 deq 方法保证了互斥性,即同一时刻只允许一个线程进入管程。

image.png

2.2、同步问题

在管程模型里,共享变量和对共享变量的操作是被封装起来的,图中最外层的框代表封装的意思。框的上面有一个入口,并且入口旁边还有一个入口等待队列。当多个线程同时试图进入管程内部时,只允许一个线程进入,其他线程则在入口等待队列中等待。

管程还引入了条件变量的概念,而且每个条件变量都对应有一个等待队列。如图,条件变量 A 和条件变量 B 分别都有自己的等待队列。图中的条件变量条件变量等待队列,其实是用来解决线程同步的问题。

image.png

假设线程 1 调用 deq 方法,执行出队操作,这里有个前提条件是阻塞队列不能为空,阻塞队列不为空对应的就是管程里的条件变量。如果线程 1 进入管程恰好发现阻塞队列时空的,则会去条件变量对应的等待队列里等待。线程 1 进入条件变量的等待队列后,是允许其他线程进入管程的。

线程 1 进入等待队列后,另外一个线程 2 执行阻塞队列的入队操作。入队操作成功后,阻塞队列不为空这个条件对于线程 1 来说已经满足了,此时线程 2 需要通知线程 1。线程 1 收到通知后,会从等待队列里面出来,但是出来之后不是马上执行,而是重新进入到入口等待队列中。

2.3、JVM 提供的等待/通知

Object 通过提供了 wait、notify 和 notifyAll 三个方法,实现上面的等待/通知操作。前面提到线程 1 发现阻塞队列不为空这个条件不满足时,需要进入到条件变量对应的等待队列中等待。这个过程就是通过 wait 方法实现的。如上图所示,条件变量 A 代表阻塞队列不为空这个条件,则线程 1 需要调用 A.wait。同理当阻塞队列不为空满足时,线程 2 需要调用 A.notify 或 A.notifyAll 来通知 A 等待队列中的线程。其中 notify 只会通知条件变量等待队列中的一个线程,notifyAll 会通知所有线程。

对于 MESA 模型的管程来说,wait 方法的编码范式如下,需要在一个 while 循环里面调用 wait。

  1. while(条件不满足) {
  2. wait();
  3. }

Hasen、Hoare 和 MESA 模型的主要区别就是当条件满足后,如何通知相关的线程。管程要求同一时刻只允许一个线程执行,那当线程 2 的操作使线程 1 等待的条件满足时,线程 1 和线程 2 究竟谁可以执行?

  1. Hasen 模型,要求 notify 放在代码的最后,这样线程 2 通知完线程 1 后,线程 2 就结束了;然后线程 1 再执行,这样就能保证同一时刻只允许一个线程执行
  2. Hoare 模型,线程 2 通知完线程 1 后,线程 2 阻塞,线程 1 立即执行;等线程 1 执行完,在唤醒线程 2,同样可以保证同一时刻只允许一个线程执行。但相比 Hasen 模型,线程 2 多了一次等待/通知操作
  3. MESA 模型,线程 2 通知完线程 1 后,线程 2 还会接着执行,线程 1 不会立即执行,仅仅是从变量的等待队列进到入口等待队列里。这样做的好处是 notify 不用放到代码的最后,线程 2 也没有多余的等待/通知操作。缺点是当线程 1 再次执行的时候,可能曾经满足的条件,又不满足了,所以需要以 while 自旋的方式检验条件变量。

使用 notify 方法时需要同时满足下面三个条件:

  1. 所有等待线程拥有相同的等待条件
  2. 所有等待线程被唤醒后,执行相同的操作
  3. 仅需要唤醒一个线程

3、总结

并发编程里两大核心问题,互斥和同步,管程就是用来解决这两个问题的模型,管程模型的重点是条件变量及条件变量对应的等待队列。Java 语言内置的管程实现是 synchronized 关键字,它参考了 MESA 模型,并对 MESA 模型进行了精简,MESA 模型中条件变量可以有多个,Java 语言内置的管程里只有一个条件变量,如图所示:

image.png

Java 内置的管程方案使用简单,synchronized 关键字修饰的代码块,在编译器会自动生成相关加锁和解锁的代码,但仅支持一个条件变量;而 JDK 并发包里实现的管程支持多个条件变量,如 ReentrantLock、ReentrantReadWriteLock 等。并发包里的锁,需要开发人员自己进行过加锁和解锁操作。