• 你有一个思想,我有一个思想,我们交换后,一个人就有两个思想

  • If you can NOT explain it simply, you do NOT understand it well enough

现陆续将Demo代码和技术文章整理在一起 Github实践精选 ,方便大家阅读查看,本文同样收录在此,觉得不错,还请Star

并发编程为什么会有等待通知机制

上一篇文章说明了 Java并发死锁解决思路 , 解决死锁的思路之一就是 破坏请求和保持条件, 所有柜员都要通过唯一的账本管理员一次性拿到所有转账业务需要的账本,就像下面这样:

没有等待/通知机制之前,所有柜员都通过死循环的方式不断向账本管理员申请所有账本,程序的体现就是这样:

while(!accountBookManager.getAllRequiredAccountBook(this, target))

假如账本管理员是年轻小伙,腿脚利落(即执行 getAllRequiredAccountBook方法耗时短),并且多个柜员转账的业务冲突量不大,这个方案简单粗暴且有效,柜员只需要尝试几次就可以成功(即通过少量的循环可以实现)

过了好多年,年轻的账本管理员变成了年迈的老人,行动迟缓(即执行 getAllRequiredAccountBook 耗时长),同时,多个柜员转账的业务冲突量也变大,之前几十次循环能做到的,现在可能就要申请成千上百,甚至上万次才能完成一次转账

人工无限申请浪费口舌, 程序无限申请浪费CPU。聪明的人就想到了 等待/通知 机制

等待/通知机制

无限循环实在太浪费CPU,而理想情况应该是这样:

  • 柜员A如果拿不到所有账本,就傲娇的不再继续问了(线程阻塞自己 wait)
  • 柜员B归还了柜员A需要的账本之后就主动通知柜员A账本可用(通知等待的线程 notify/notifyAll)

做到这样,就能避免循环等待消耗CPU的问题了


现实中有太多场景都在应用等待/通知机制。欢迎观临红浪漫,比如去XX办证,去医院就医/体检。

下面请自行脑补一下去医院就医或体检的画面, 整体流程类似这样:

序号 就医 程序解释(自己的视角)
1 挂号成功,到诊室门口排号候诊 排号的患者(线程)尝试获取【互斥锁】
2 大夫叫到自己,进入诊室就诊 自己【获取到互斥锁】
3 大夫简单询问,要求做检查(患者缺乏报告不能诊断病因) 进行【条件判断】,线程要求的条件【没满足】
4 自己出去做检查 线程【主动释放】持有的互斥锁
5 大夫叫下一位患者 另一位患者(线程)获取到互斥锁
6 自己拿到检测报告 线程【曾经】要求的条件得到满足(实则【被通知】)
7 再次在诊室门口排号候诊 再次尝试获取互斥锁
8 ... ...

在【程序解释】一列,我将关键字(排队、锁、等待、释放....)已经用 【】 框了起来。Java 语言中,其内置的关键字 synchronized 和 方法wait(),notify()/notifyAll() 就能实现上面提到的等待/通知机制,我们将这几个关键字实现流程现形象化的表示一下:

这可不是一个简单的图,下面还要围绕这个图做很多文章,不过这里我必须要插播几个面试基础知识点了:

  1. 一个锁对应一个【入口等待队列】,不同锁的入口等待队列没任何关系,说白了他们就不存在竞争关系。你想呀,不同患者进入眼科和耳鼻喉科看大夫一点冲突都没有
  2. wait(), notify()/notifyAll() 要在 synchronized 内部被使用,并且,如果锁的对象是this,就要 this.wait(),this.notify()/this.notifyAll() , 否则JVM就会抛出 java.lang.IllegalMonitorStateException 的。你想呀,等待/通知机制就是从【竞争】环境逐渐衍生出来的策略,不在锁竞争内部使用或等待/通知错了对象, 自然是不符合常理的

有了上面知识的铺垫,要想将无限循环策略改为等待通知策略,你还需要问自己四个问题:

灵魂 4 问

我们拿钱庄账本管理员的例子依依做以上回答:

我们优化钱庄转账的程序:

public class AccountBookManager {

	List<Object> accounts = new ArrayList<>(2);

	synchronized boolean getAllRequiredAccountBook( Object from, Object to){
if(accounts.contains(from) || accounts.contains(to)){
try{
this.wait();
}catch(Exception e){ }
} else{
accounts.add(from);
accounts.add(to); return true;
}
}
// 归还资源
synchronized void releaseObtainedAccountBook(Object from, Object to){
accounts.remove(from);
accounts.remove(to);
notify();
}
}

就这样【看】 【似】 【完】 【美】的解决了,其实上面的程序有两个大坑:

坑一

在上面 this.wait() 处,使用了 if 条件判断,会出现天大的麻烦,来看下图(从下往上看):

notify 唤醒的那一刻,线程【曾经/曾经/曾经】要求的条件得到了满足,从这一刻开始,到去条件等队列中唤醒线程,再到再次尝试获取锁是有时间差的,当再次获取到锁时,线程曾经要求的条件是不一定满足,所以需要重新进行条件判断,所以需要将 if 判断改成 while 判断

synchronized boolean getAllRequiredAccountBook( Object from, Object to){
while(accounts.contains(from) || accounts.contains(to)){
try{
this.wait();
}catch(Exception e){ }
} else{
accounts.add(from);
accounts.add(to); return true;
}
}

一个线程可以从挂起状态变为可运行状态(也就是被唤醒),即使线程没有被其他线程调用notify()/notifyAll() 方法进行通知,或被中断,或者等待超时,这就是所谓的【虚假唤醒】。虽然虚假唤醒很少发生,但要防患于未然,做法就是不停的去测试该线程被唤醒条件是否满足

——摘自《Java并发编程之美》


有同学可能还会产生疑问,为什么while就可以?

因为被唤醒的线程再次获取到锁之后是从原来的 wait 之后开始执行的,wait在循环里面,所以会再次进入循环条件重新进行条件判断。

如果不理解这个道理就记住一句话:

从哪里跌倒就从哪里爬起来;在哪里wait,就从wait那里继续向后执行

所以,这也就成了使用wait()的标准范式

至于坑二,是线程归还所使用的账户之后使用 notify 而不是 notifyAll 进行通知,由于坑很大,需要一些知识铺垫来说明

为什么说尽量使用 notifyAll

notify() 和 notifyAll() 到底啥区别?

notify() 函数

随机唤醒一个:一个线程调用共享对象的 notify() 方法,会唤醒一个在该共享变量上调用 wait() 方法后被挂起的线程,一个共享变量上可能有多个线程在等待,具体唤醒那一个,是随机的

notifyAll() 函数

唤醒所有: 与notify() 不同,notifyAll() 会唤醒在该共享变量上由于调用wait() 方法而被挂起的所有线程

看个非常简单的程序例子吧

示例程序一

@Slf4j
public class NotifyTest { private static volatile Object resourceA = new Object(); public static void main(String[] args) throws InterruptedException {
Thread threadA = new Thread(() -> {
synchronized (resourceA){
log.info("threadA get resourceA lock"); try{
log.info("threadA begins to wait");
resourceA.wait();
log.info("threadA ends wait");
}catch (InterruptedException e){
log.error(e.getMessage());
}
}
}); Thread threadB = new Thread(() -> {
synchronized (resourceA){
log.info("threadB get resourceA lock"); try{
log.info("threadB begins to wait");
resourceA.wait();
log.info("threadB ends wait");
}catch (InterruptedException e){
log.error(e.getMessage());
}
}
}); Thread threadC = new Thread(() -> {
synchronized (resourceA){
log.info("threadC begin to notify");
resourceA.notify();
}
}); threadA.start();
threadB.start(); Thread.sleep(1000); threadC.start(); threadA.join();
threadB.join();
threadC.join(); log.info("main thread over now");
}
}

来看运行结果

程序中我们使用notify()随机通知resourceA的等待队列的一个线程,threadA被唤醒,threadB却没有打印出 threadB ends wait 这句话,遗憾的死掉了

将 notify() 换成 notifyAll() 的结果想必你已经知道了

使用 notifyAll() 确实不会遗落等待队列中的线程,但也产生了比较强烈的竞争,如果notify() 设计的本身就是 bug,那么这个函数应该早就从 JDK 中移除了,它随机通知一个线程的形式必定是有用武之地的

什么时候可以使用 notify()

notify() 的典型的应用就是线程池(按照上面的三个条件你自问自答验证一下是这样吗?)

这里我们拿一个 JUC 下的类来看看 notify() 的用处

Tips:

  • notify() 等同于 signal()
  • wait() 等同于 await()

在IDE中,打开 ArrayBlockingQueue.java

所有的入队 public 方法offer()/put() 内部都调用了 private 的 enqueue() 方法

所有的出队 public 方法poll()/take() 内部都调用了 private 的 dequeue() 方法

将这个模型进行精简就是下面这个样子:

public class SimpleBlockingQueue<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();
}
}
}

如果满足上面这三个条件,notify() 的使用就恰到好处;我们用使用 notify()的条件进行验证

有的同学看到这里可能会稍稍有一些疑惑,await()/signal()wait()/notify() 组合的玩法看着不太一样呢,你疑惑的没有错

因为 Java 内置的监视器锁模型是 MESA 模型的精简版

MESA模型

MESA 监视器模型中说,每一个条件变量都对应一个条件等待队列

对应到上面程序:

  • 队列已满是前提条件,条件变量A就是notFull,也就是notFull.await; notFull.signal
  • 队列已空是前提条件,条件变量B就是notEmpty,也就是notEmpty.await; notEmpty.signal/sign

即便notFull.signalAll, 也和await在notEmpty 条件变量队列的线程没半毛钱关系

而Java内置监视器模型就只会有一个【隐形的】条件变量

  • 如果是synchronized修饰的普通方法,条件变量就是 this
  • 如果是synchronized修饰的静态方法,条件变量就是类
  • 如果是synchronized块,条件变量就是块中的内容了

说完了这些,你有没有恍然大悟的感觉呢

总结

如果业务冲突不大,循环等待是一种简单粗暴且有效的方式;但是当业务冲突大之后,通知/等待机制是必不可少的使用策略

通过这篇文章,相信你已经可以通过灵魂4问,知道如何将循环等待改善成通知/等待模型了;另外也知道如何正确的使用通知/等待机制了

灵魂追问

  1. 钱庄转账的业务,条件都是判断账户是否被支配,都是执行相同的转账业务,为什么就不可以用notify() 而只能用notifyAll() 呢
  2. ResourceA的例子,为什么使用notify通知,程序没有打印出 main thread over now, 而使用notifyAll() 却打印出来了呢?

参考

感谢前辈们总结的精华,自己所写的并发系列好多都参考了以下资料


下面的文章,就需要聊聊【线程的生命周期】了,只有熟知线程的生命周期,你才能更好的编写并发程序。

我这面也在逐步总结常见的并发面试问题(总结ing......)答案整理好后会通知大家,请持续关注

同时,这里也整理了一点 Java 硬核资料,有需要的就公众号回复【资料】/【666】吧


超强图文|并发编程【等待/通知机制】就是这个feel~的更多相关文章

  1. Java并发编程实战 05等待-通知机制和活跃性问题

    Java并发编程系列 Java并发编程实战 01并发编程的Bug源头 Java并发编程实战 02Java如何解决可见性和有序性问题 Java并发编程实战 03互斥锁 解决原子性问题 Java并发编程实 ...

  2. Java并发编程,Condition的await和signal等待通知机制

    Condition简介 Object类是Java中所有类的父类, 在线程间实现通信的往往会应用到Object的几个方法: wait(),wait(long timeout),wait(long tim ...

  3. Java并发编程(04):线程间通信,等待/通知机制

    本文源码:GitHub·点这里 || GitEE·点这里 一.概念简介 1.线程通信 在操作系统中,线程是个独立的个体,但是在线程执行过程中,如果处理同一个业务逻辑,可能会产生资源争抢,导致并发问题, ...

  4. java并发编程实战《六》等待-通知机制

    用"等待-通知"机制优化循环等待 前言 在破坏占用且等待条件的时候,如果转出账本和转入账本不满足同时在文件架上这个条件,就用死循环的方式来循环等待. 1 // 一次性申请转出账户和 ...

  5. 【Java并发基础】使用“等待—通知”机制优化死锁中占用且等待解决方案

    前言 在前篇介绍死锁的文章中,我们破坏等待占用且等待条件时,用了一个死循环来获取两个账本对象. // 一次性申请转出账户和转入账户,直到成功 while(!actr.apply(this, targe ...

  6. Java并发读书笔记:线程通信之等待通知机制

    目录 synchronized 与 volatile 等待/通知机制 等待 通知 面试常问的几个问题 sleep方法和wait方法的区别 关于放弃对象监视器 在并发编程中,保证线程同步,从而实现线程之 ...

  7. 《java多线程编程核心技术》不使用等待通知机制 实现线程间通信的 疑问分析

    不使用等待通知机制 实现线程间通信的 疑问分析 2018年04月03日 17:15:08       ayf 阅读数:33 编辑 <java多线程编程核心技术>一书第三章开头,有如下案例: ...

  8. java多线程系列(三)---等待通知机制

    等待通知机制 前言:本系列将从零开始讲解java多线程相关的技术,内容参考于<java多线程核心技术>与<java并发编程实战>等相关资料,希望站在巨人的肩膀上,再通过我的理解 ...

  9. 12.详解Condition的await和signal等待通知机制

    1.Condition简介 任何一个java对象都天然继承于Object类,在线程间实现通信的往往会应用到Object的几个方法,比如wait(),wait(long timeout),wait(lo ...

随机推荐

  1. AngularJS前端以ArrayBuffer类型请求后端数据以生成文件时,出现异常的处理

    .error(function(error){ var decodedString = String.fromCharCode.apply(null, new Uint8Array(error)); ...

  2. OpenCV 对两幅图像求和(求混合(blending))

    #include <cv.h> #include <highgui.h> #include <iostream> using namespace cv; int m ...

  3. Redis4配置文件详解

    转载链接https://www.cnblogs.com/jeffen/p/6077661.html 守护进程模式 默认情况下 redis 不是作为守护进程运行的,如果你想让它在后台运行,你就把它改成 ...

  4. SWUST OJ 东6宿舍灵异事件(0322)

    东6宿舍灵异事件(0322) Time limit(ms): 1000 Memory limit(kb): 65535 Submission: 88 Accepted: 31   Descriptio ...

  5. linux下光标操作

    Ctrl+左右键    单词间跳转 Ctrl+a    跳到行首 Ctrl+e    跳到行尾 Ctrl+u    删除当前光标前的文字 Ctrl+k    删除当前光标后的文字 Ctrl+w    ...

  6. docker-compose的安装和设定

    docker的1.12版本中,swarm已经合体,docker-engine/swarm/docker-compose的三件套装已经变成两件.后续会不会将docker-compose进一步合体呢,想做 ...

  7. ArrayList与LinkList对比

    本文简要总结一下java中ArrayList与LinkedList的区别,这在面试中也是常常会问到的一个知识点. 先来看一下ArrayList和LinkedList的关系是怎样的: 从继承体系可以看到 ...

  8. Qt类声明中Q_OBJECT的作用与报错解决

    2017-06-22 周四 大雨 北京 院里 新建作图类,继承自QCUstomPlot类 因为需要同时作8张图,都要单坐标缩放的功能,因此想干脆新建一个类,继承自QCUstomPlot,把需要的功能都 ...

  9. 脸书VS微软,为何“老年创业者”更担忧AI失控?

    作为互联网行业最知名的大会之一,近日举行的微软Build 2017大会,却增加了与以往不同的"调味品".除了新技术.智能硬件.系统.平台之外,微软CEO纳德拉在大会上对科技带给人类 ...

  10. Ubunt 16.04 安装 Beyond compare 4

    1. 下载安装包: 2. 安装步骤 3. 运行并注册 之前Beyond compare 3 只有32位,在Ubunt 16.04上运行效率非常低,所以只有安装最新的Beyond compare 4,安 ...