摘要:语言在之前,提供的唯一的并发原语就是管程,而且之后提供的并发包,也是以管程技术为基础的。但是管程更容易使用,所以选择了管程。线程进入条件变量的等待队列后,是允许其他线程进入管程的。并发编程里两大核心问题互斥和同步,都可以由管程来帮你解决。
并发编程这个技术领域已经发展了半个世纪了。有没有一种核心技术可以很方便地解决我们的并发问题呢?这个问题, 我会选择 Monitor(管程)技术。
Java 语言在 1.5 之前,提供的唯一的并发原语就是管程,而且 1.5 之后提供的 SDK 并发包,也是以管程技术为基础的。除此之外,C/C++、C# 等高级语言也都支持管程。
操作系统原理课程告诉我们,用信号量能解决所有并发问题。但是为什么 Java 在 1.5 之前仅仅提供了 synchronized 关键字及 wait()、notify()、notifyAll() 这三个看似从天而降的方法?当然这里因为 Java 采用的是管程技术,synchronized 关键字及 wait()、notify()、notifyAll() 这三个方法都是管程的组成部分。并且
管程和信号量是等价的,所谓等价指的是用管程能够实现信号量,也能用信号量实现管程。
但是管程更容易使用,所以 Java 选择了管程。
管程,对应的英文是 Monitor,很多 Java 领域的同学都喜欢将其翻译成“监视器”,这是直译。操作系统领域一般都翻译成“管程”,这个是意译,在这里我更倾向于使用“管程”。
管程,指的是管理共享变量以及对共享变量的操作过程,让他们支持并发。
翻译为 Java 领域的语言,就是管理类的成员变量和成员方法,让这个类是线程安全的。那管程是怎么管的呢?
MESA 模型在管程的发展史上,先后出现过三种不同的管程模型,分别是:Hasen 模型、Hoare 模型和 MESA 模型。其中,现在广泛应用的是 MESA 模型,并且 Java 管程的实现参考的也是 MESA 模型。所以我们重点介绍一下 MESA 模型。
在并发编程领域,有两大核心问题:
一个是互斥,即同一时刻只允许一个线程访问共享资源;
另一个是同步,即线程之间如何通信、协作。这两大问题,管程都是能够解决的。
我们先来看看管程是如何解决互斥问题的。
管程解决互斥问题的思路很简单,就是将共享变量及其对共享变量的操作统一封装起来。在下图中,管程 X 将共享变量 queue 这个队列和相关的操作入队 enq()、出队 deq() 都封装起来了;线程 A 和线程 B 如果想访问共享变量 queue,只能通过调用管程提供的 enq()、deq() 方法来实现;enq()、deq() 保证互斥性,只允许一个线程进入管程。从中可以看出,管程模型和面向对象高度契合的。而我在前面章节介绍的互斥锁用法,其背后的模型其实就是它。
管程模型的代码化语义
那管程如何解决线程间的同步问题的。
这个就比较复杂了,我们来看下 MESA 管程模型示意图,它详细描述了 MESA 模型的主要组成部分。
在管程模型里,共享变量和对共享变量的操作是被封装起来的,图中最外层的框就代表封装的意思。框的上面只有一个入口,并且在入口旁边还有一个入口等待队列。当多个线程同时试图进入管程内部时,只允许一个线程进入,其他线程则在入口等待队列中等待。
管程里还引入了条件变量的概念,而且每个条件变量都对应有一个等待队列,如下图,条件变量 A 和条件变量 B 分别都有自己的等待队列。
MESA 管程模型图
那条件变量和等待队列的作用是什么呢?其实就是解决线程同步问题。你也可以结合上面提到的入队出队例子加深一下理解。
其他关于管程的定义,加深我们的理解
管程是定义了一个数据结构和能为并发所执行的一组操作,这组操作能够进行同步和改变管程中的数据。这相当于对临界资源的同步操作都集中进行管理,凡是要访问临界资源的进程或线程,都必须先通过管程,由管程的这套机制来实现多进程或线程对同一个临界资源的互斥访问和使用。管程的同步主要通过condition类型的变量(条件变量),条件变量可执行操作wait()和signal()。管程一般是由语言编译器进行封装,体现出OOP中的封装思想,也如老师所讲的,管程模型和面向对象高度契合的。
假设有个线程 T1 执行出队操作,不过需要注意的是执行出队操作,有个前提条件,就是队列不能是空的,而队列不空这个前提条件就是管程里的条件变量。 如果线程 T1 进入管程后恰好发现队列是空的,那怎么办呢?等待啊,去哪里等呢?就去条件变量对应的等待队列里面等。此时线程 T1 就去“队列不空”这个条件变量的等待队列中等待。线程 T1 进入条件变量的等待队列后,是允许其他线程进入管程的。
再假设之后另外一个线程 T2 执行入队操作,入队操作执行成功之后,“队列不空”这个条件对于线程 T1 来说已经满足了,此时线程 T2 要通知 T1,告诉它需要的条件已经满足了。当线程 T1 得到通知后,会从等待队列里面出来,但是出来之后不是马上执行,而是重新进入到入口等待队列里面。
条件变量及其等待队列我们讲清楚了,下面再说说 wait()、notify()、notifyAll() 这三个操作。前面提到线程 T1 发现“队列不空”这个条件不满足,需要进到对应的等待队列里等待。这个过程就是通过调用 wait() 来实现的。如果我们用对象 A 代表“队列不空”这个条件,那么线程 T1 需要调用 A.wait()。同理当“队列不空”这个条件满足时,线程 T2 需要调用 A.notify() 来通知 A 等待队列中的一个线程,此时这个队列里面只有线程 T1。至于 notifyAll() 这个方法,它可以通知等待队列中的所有线程。
下面的代码实现的是一个阻塞队列,阻塞队列有两个操作分别是入队和出队,这两个方法都是先获取互斥锁,类比管程模型中的入口。
对于入队操作,如果队列已满,就需要等待直到队列不满,所以这里用了notFull.await();
对于出队操作,如果队列为空,就需要等待直到队列不空,所以就用了notEmpty.await();
如果入队成功,那么队列就不空了,就需要通知条件变量:队列不空notEmpty对应的等待队列。
如果出队成功,那就队列就不满了,就需要通知条件变量:队列不满notFull对应的等待队列。
public class BlockedQueue{ 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(); } } }
在这段示例代码中,我们用了 Java 并发包里面的 Lock 和 Condition,这个例子只是先让你明白条件变量及其等待队列是怎么回事。
注意这里只是举个例子,这里的行为只是跟管程类似,但并不是具体实现,这里拿来举个例子。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 再次执行的时候,可能曾经满足的条件,现在已经不满足了,所以需要以循环方式检验条件变量。
notify() 何时可以使用还有一个需要注意的地方,就是 notify() 和 notifyAll() 的使用,前面章节,我曾经介绍过,
除非经过深思熟虑,否则尽量使用 notifyAll()。那什么时候可以使用 notify() 呢?
需要满足以下三个条件:
所有等待线程拥有相同的等待条件;
所有等待线程被唤醒后,执行相同的操作;
只需要唤醒一个线程。
比如上面阻塞队列的例子中,对于“队列不满”这个条件变量,其阻塞队列里的线程都是在等待“队列不满”这个条件,反映在代码里就是下面这 3 行代码。对所有等待线程来说,都是执行这 3 行代码,重点是 while 里面的等待条件是完全相同的
while (队列已满){ // 等待队列不满 notFull.await(); }
所有等待线程被唤醒后执行的操作也是相同的,都是下面这几行:
// 省略入队操作... // 入队后, 通知可出队 notEmpty.signal();
同时也满足第 3 条,只需要唤醒一个线程。所以上面阻塞队列的代码,使用 signal() 是可以的。
总结Java 参考了 MESA 模型,语言内置的管程(synchronized)对 MESA 模型进行了精简。MESA 模型中,条件变量可以有多个,Java 语言内置的管程里只有一个条件变量。具体如下图所示。
Java 内置的管程方案(synchronized)使用简单,synchronized 关键字修饰的代码块,在编译期会自动生成相关加锁和解锁的代码,但是仅支持一个条件变量;而 Java SDK 并发包实现的管程支持多个条件变量,不过并发包里的锁,需要开发人员自己进行加锁和解锁操作。
并发编程里两大核心问题——互斥和同步,都可以由管程来帮你解决。学好管程,理论上所有的并发问题你都可以解决,并且很多并发工具类底层都是管程实现的,所以学好管程,就是相当于掌握了一把并发编程的万能钥匙。
文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。
转载请注明本文地址:https://www.ucloud.cn/yun/74346.html
摘要:那并发里面的理论和模型是什么呢那便要从操作系统中解决并发问题的一种模型管程讲起了。当一个进程使用完管程后,它必须释放管程并唤醒等待管程的某一个进程。总结在并发编程领域,有两大核心问题互斥和同步,而这两个问题,管程模型都可以解决。 为什么需要了解管程 Java并发编程是Java中高级程序员必备的一项技能,但是真正学明白并发编程也并非易事。正如Java并发编程实践中的一句话编写正确的程序并...
摘要:请参看前一篇文章并发学习笔记一原子性可见性有序性问题六等待通知机制什么是等待通知机制当线程不满足某个条件,则进入等待状态如果线程满足要求的某个条件后,则通知等待的线程重新执行。经极客时间并发编程实战专栏内容学习整理 请参看前一篇文章:Java 并发学习笔记(一)——原子性、可见性、有序性问题 六、等待—通知机制 什么是等待通知—机制?当线程不满足某个条件,则进入等待状态;如果线程满足要...
摘要:当前线程使用将对象头的替换为锁记录指针,如果成功,当前线程获得锁如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。重量级锁是悲观锁的一种,自旋锁轻量级锁与偏向锁属于乐观锁。 操作系统在面对线程间同步的时候,会支持例如semaphore信号量和mutex互斥量等同步原语,而monitor是在编程语言中被实现的,下面介绍一下java中monitor(监视器/管程:管理共享变量以...
摘要:诞生之处就支持多线程,所以自然有解决这些问题的办法,而且在编程语言领域处于领先地位。,线程规则这条是关于线程启动的。在语言里面,的语义本质上是一种可见性,意味着事件对事件来说是可见的,无论事件和事件是否发生在同一个线程里。 之前我们说了:1,可见性2,原子性3,有序性3个并发BUG的之源,这三个也是编程领域的共性问题。Java诞生之处就支持多线程,所以自然有解决这些问题的办法,而且在编...
摘要:但是有引入了新的问题线程不安全,返回的对象可能还没有初始化。如果只有一个线程调用是没有问题的因为不管步骤如何调换,保证返回的对象是已经构造好了。这种特殊情况称之为指令重排序采用了允许将多条指令不按程序规定的顺序分开发送给各相应电路单元处理。 目录 双重检测锁的演变过程 利用HappensBefore分析并发问题 无volatile的双重检测锁 双重检测锁的演变过程 synch...
阅读 1060·2021-11-12 10:34
阅读 988·2021-09-30 09:56
阅读 670·2019-08-30 15:54
阅读 2604·2019-08-30 11:14
阅读 1467·2019-08-29 16:44
阅读 3205·2019-08-29 16:35
阅读 2490·2019-08-29 16:22
阅读 2443·2019-08-29 15:39