Java并发编程 | 并发编程的万能钥匙:管程

1,530 阅读7分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第24天,点击查看活动详情

本系列专栏 Java并发编程专栏 - 元浩875的专栏 - 掘金 (juejin.cn)

前言

并发编程已经发展了这么多年,那有没有一种核心技术来解决并发问题呢 当然是有的,这里的管程就是一种。在Java 1.5之前,提供的唯一的并发原语就是管程,而且1.5之后提供的SDK并发包也是以管程技术为基础。

那本章就来看看这个可以说是并发编程万能钥匙的管程。

正文

如果直接来说概念的话,可能会觉得突兀,我们就来了解一下什么是管程。

管程

在Java 1.5之前,仅仅提供了synchronized关键字以及wait()、notify()、notifyAll()这几个方法就可以完成并发编程,而且这几个方法就是实现前面文章所说的等待-通知机制,为什么会突然给出这么一套方案呢 因为synchronized关键字以及这几个方法都是管程的组成部分。

管程,对应的英文是Monitor很多翻译为监视器,不过在操作系统领域一般翻译为管程,是一种意译

所谓管程,指的就是管理共享变量以及对共享变量的操作过程,让他们支持并发。在Java中,就是管理类的成员变量和成员方法,让这个类是线程安全的

MESA模型

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

管程有很多模型,不过现在使用最广泛的是MESA模型,而且Java也是使用的是MESA模型,我们先来看看管程是如何解决互斥问题。

解决互斥问题

管程解决互斥问题的思路很简单,就是将共享变量以及对共享变量的操作统一封装起来。比如要实现一个线程安全的阻塞队列,那一个最简单的思路就是将线程不安全的队列给封装起来,对外只提供安全的操作方法:入队和出队。

而管程很容易实现,比如下图:

image.png

这里有个管程X,它就把一个线程不安全的共享变量queue以及入队和出队给封装了起来,而线程A和线程B想访问共享变量queue,只能通过管程提供的enq()和deq()方法,而这2个方法保证互斥性,只允许一个线程进入管程,这样的话就解决了互斥问题

解决同步问题

既然可以通过封装共享变量以及其操作来实现互斥,那如何解决同步问题呢 这个可以借鉴一下之前文章所说的就医流程,以及就医流程所引出的等待-通知机制,这里管程也是通过这种模型来解决同步问题的。

比如下图:

image.png

上面的入口就是保证互斥的,假如有N条线程想访问共享变量V,但是只有一条线程能进入,其他的线程就在入口等待队列中等待。

在上图中还有个概念叫做条件变量而且每个条件变量对应着有一个等待队列,比如条件变量A和条件变量B都有一个等待队列。而这个条件变量和条件变量等待队列就是解决线程同步问题。

何为线程同步,在本系列第一章就说过就是一个线程干完一件事了,需要通知其他线程去干后续的事。这里是不是有点似曾相识,在之前文章所说的转账那个例子中,我们就是让线程等待,当有账户被释放时,再去唤醒,其实道理是一样的,我们本篇以实现阻塞队列为例。

假设有个线程T1执行阻塞队列的出队操作,执行出队操作,这里有个前提条件就是阻塞队列不为空,阻塞队列不为空就是管程里的条件变量。如果T1进入管程后发现阻塞队列是空的,那就去这个条件变量对应的等待队列中等待;这时线程T1进入等待队列中时,其他线程是可以进入管程的。

在假如线程T2执行阻塞队列入队操作,入队操作执行成功后,阻塞队列不为空这个条件对于T1来说已经满足了,这时T2要通知T1,告诉它条件满足了,当T1得到通知后,会从等待队列中出来,重新进入到入口等待队列中

条件变量以及其等待队列我们搞清楚后,我们就来说说wait()、notify()和notifyAll()这三个操作。假设我们用对象A代表"阻塞队列不为空"这个条件,那么当T1发现A不满足时,就需要调用A.wait()。而当线程T2操作完,发现A满足时,这时就要调用**A.notify()**来通知A等待队列中的一个线程。

安全阻塞队列

说完了管程是如何解决互斥和同步问题后,我们就来看看一个完整的代码例子。思路如下:

  1. 阻塞队列中有2个操作分别是入队和出队,这2个方法都是先获取互斥锁,类比管程模型中的入口。
  2. 对于阻塞队列的入队操作,如果阻塞队列已满,就需要等待直到阻塞队列不满,所以这里用了notFull.await()。
  3. 对于阻塞队列的出队操作,如果阻塞队列为空,就需要等待直到阻塞队列不为空,所以这里用了notEmpty.await()。
  4. 如果入队成功,那么阻塞队列就不空了,就要通知条件变量:阻塞队列不空notEmpty对应的等待队列
  5. 如果出队成功,那么阻塞队列就不满了,就要通知条件变量:阻塞队列不满notFull对应的等待队列

完整代码如下:

public class BlockedQueue<T>{
  final Lock lock =
    new ReentrantLock();
  // 条件变量:队列不满  
  final Condition notFull =
    lock.newCondition();
  // 条件变量:队列不空  
  final Condition notEmpty =
    lock.newCondition();

  // 入队
  void enq(T x) {
    lock.lock();
    try {
      while (队列已满){
        // 线程会等待队列直到不满 ,同时释放锁
        notFull.await();
      }  
      // 省略入队操作...
      //入队后,通知可出队
      notEmpty.signal();
    }finally {
      lock.unlock();
    }
  }
  // 出队
  void deq(){
    lock.lock();
    try {
      while (队列已空){
        // 线程会等待队列不空,同时释放锁
        notEmpty.await();
      }
      // 省略出队操作...
      //出队后,通知可入队
      notFull.signal();
    }finally {
      lock.unlock();
    }  
  }
}

这里使用Lock和Condition,其实大致意思就能知道,指的就是条件变量。而这里的await()就和wait()语义一样,signal()和前面notify()语义一样。

wait()使用的范式

在前面说等待-通知机制时,我们说过一个编程范式,那么在MESA管程模型中也有个编程范式就是下面:

while(条件不满足) {
  wait();
}

这是因为MESA模型通知线程的操作不同引起的,T2通知完T1后,T2还会接着执行,T1并不会立即执行,仅仅是从条件变量的等待队列进入到入口等待队列里面。而其他管程模型有的是T2通知完T1后,T1立即执行等情况;虽然MESA模型不错,但是有个缺点,就是当T1再去执行时,可能条件已经不满足了,所以需要循环验证条件变量。

Java的synchronized

Java参考了MESA模型,而语言内置的管程synchronized就是对MESA模型的精简,在MESA模型中,条件变量可以有多个,而Java语言内置的管程即synchronized其条件变量只有一个,如下图:

image.png

Java内置的管程方案synchronized使用简单,在编译期会自动加锁和解锁,而且仅支持一个条件变量。后续学习Java SDK并发包时,再说如何自己加锁和解锁以及支持多个条件变量。

总结

搞明白管程后,有一种豁然开朗的感觉,Java的并发编程也不过如此,后续会继续学习一些Java并发包中的工具类,来看看规范的并发类都是如何实现的。

image.png