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

简介: 超强图文|并发编程【等待/通知机制】就是这个feel~

微信图片_20220510165814.png


  • 你有一个思想,我有一个思想,我们交换后,一个人就有两个思想
  • If you can NOT explain it simply, you do NOT understand it well enough


微信图片_20220510165834.png


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


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


微信图片_20220510165851.png


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


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


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


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


微信图片_20220510165920.png


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


等待/通知机制


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


  • 柜员A如果拿不到所有账本,就傲娇的不再继续问了(线程阻塞自己 wait)


  • 柜员B归还了柜员A需要的账本之后就主动通知柜员A账本可用(通知等待的线程 notify/notifyAll)


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


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


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


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


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


微信图片_20220510165946.png


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


  1. 一个锁对应一个【入口等待队列】,不同锁的入口等待队列没任何关系,说白了他们就不存在竞争关系。你想呀,不同患者进入眼科和耳鼻喉科看大夫一点冲突都没有


  1. wait(), notify()/notifyAll() 要在 synchronized 内部被使用,并且,如果锁的对象是this,就要 this.wait(),this.notify()/this.notifyAll() , 否则JVM就会抛出 java.lang.IllegalMonitorStateException 的。你想呀,等待/通知机制就是从【竞争】环境逐渐衍生出来的策略,不在锁竞争内部使用或等待/通知错了对象, 自然是不符合常理的


微信图片_20220510170007.png


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


灵魂 4 问


微信图片_20220510170030.png


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


微信图片_20220510170047.png


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


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


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


微信图片_20220510170119.gif


坑一


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


微信图片_20220510170138.png


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()的标准范式


微信图片_20220510170219.png


至于坑二,是线程归还所使用的账户之后使用 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");
    }
}


来看运行结果


微信图片_20220510170313.png


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

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


微信图片_20220510170334.png


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


什么时候可以使用 notify()


微信图片_20220510170411.png


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


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


Tips:


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


在IDE中,打开 ArrayBlockingQueue.java


微信图片_20220510170443.png


所有的入队 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()的条件进行验证


微信图片_20220510170519.png


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

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


MESA模型


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


微信图片_20220510170540.png


对应到上面程序:


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


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


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


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


  • 如果是synchronized修饰的普通方法,条件变量就是 this


  • 如果是synchronized修饰的静态方法,条件变量就是类


  • 如果是synchronized块,条件变量就是块中的内容了


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


微信图片_20220510170609.png


总结


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


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


灵魂追问


  1. 钱庄转账的业务,条件都是判断账户是否被支配,都是执行相同的转账业务,为什么就不可以用notify() 而只能用notifyAll() 呢


  1. ResourceA的例子,为什么使用notify通知,程序没有打印出 main thread over now, 而使用notifyAll() 却打印出来了呢?


目录
打赏
0
0
0
0
1
分享
相关文章
Celery 高效异步任务队列:打破常规,颠覆认知,应用实践全攻略在此!
【8月更文挑战第5天】Celery 是一款强大的异步任务队列框架,适用于后台执行耗时任务如邮件发送、报表生成等。可通过`pip install celery`安装,并配置消息代理(如Redis)以启动服务。定义异步任务使用装饰器`@app.task`,并通过`.delay()`方法执行。任务状态和结果可通过`.ready()`和`.get()`查询。异常处理支持任务重试,性能优化包括调整并发数和选用高效消息代理。Celery 能显著提升应用效率与用户体验。
263 0
事件驱动+推拉结合:智慧社区服务解耦新玩法
本文介绍了智慧社区项目中“关联微服务”的设计,涵盖智慧通行、安全社区、全屋智能和增值服务四大模块。通过限界上下文划分,明确各模块职责,实现服务解耦和高效协作。文章还探讨了充血模型与贫血模型的应用,以及通过领域事件通知机制实现数据同步的方法。最终,智慧社区的增值服务通过个性化推送和定向推荐,为用户提供更加智能、便捷的生活体验。
53 1
【编程高手必备】Java多线程编程实战揭秘:解锁高效并发的秘密武器!
【8月更文挑战第22天】Java多线程编程是提升软件性能的关键技术,可通过继承`Thread`类或实现`Runnable`接口创建线程。为确保数据一致性,可采用`synchronized`关键字或`ReentrantLock`进行线程同步。此外,利用`wait()`和`notify()`方法实现线程间通信。预防死锁策略包括避免嵌套锁定、固定锁顺序及设置获取锁的超时。掌握这些技巧能有效增强程序的并发处理能力。
31 2
【颠覆传统!】Kotlin协程魔法:解锁Android应用极速体验,带你领略多线程优化的无限魅力!
【8月更文挑战第12天】多线程对现代Android应用至关重要,能显著提升性能与体验。本文探讨Kotlin中的高效多线程实践。首先,理解主线程(UI线程)的角色,避免阻塞它。Kotlin协程作为轻量级线程,简化异步编程。示例展示了如何使用`kotlinx.coroutines`库创建协程,执行后台任务而不影响UI。此外,通过协程与Retrofit结合,实现了网络数据的异步加载,并安全地更新UI。协程不仅提高代码可读性,还能确保程序高效运行,不阻塞主线程,是构建高性能Android应用的关键。
79 4
Netty实战巅峰:从零构建高性能IM即时通讯系统,解锁并发通信新境界
【8月更文挑战第3天】Netty是一款高性能、异步事件驱动的网络框架,适用于开发高并发网络应用,如即时通讯(IM)系统。本文将指导你利用Netty从零构建高性能IM程序,介绍Netty基础及服务器/客户端设计。服务器端使用`ServerBootstrap`启动,客户端通过`Bootstrap`连接服务器。示例展示了简单的服务器启动过程。通过深入学习,可进一步实现用户认证等功能,打造出更完善的IM系统。
228 1
|
6月前
|
【浅入浅出】Qt多线程机制解析:提升程序响应性与并发处理能力
在学习QT线程的时候我们首先要知道的是QT的主线程,也叫GUI线程,意如其名,也就是我们程序的最主要的一个线程,主要负责初始化界面并监听事件循环,并根据事件处理做出界面上的反馈。但是当我们只限于在一个主线程上书写逻辑时碰到了需要一直等待的事件该怎么办?它的加载必定会带着主界面的卡顿,这时候我们就要去使用多线程。
201 6
【优化技术专题】「线程间的高性能消息框架」再次细节领略Disruptor的底层原理和优势分析
【优化技术专题】「线程间的高性能消息框架」再次细节领略Disruptor的底层原理和优势分析
215 0
【优化技术专题】「线程间的高性能消息框架」再次细节领略Disruptor的底层原理和优势分析
【优化技术专题】「线程间的高性能消息框架」深入浅出Disruptor的使用和原理
【优化技术专题】「线程间的高性能消息框架」深入浅出Disruptor的使用和原理
328 0
【优化技术专题】「线程间的高性能消息框架」深入浅出Disruptor的使用和原理
语音直播系统,常见的软件架构模式及优缺点
语音直播系统,常见的软件架构模式及优缺点
AI助理

你好,我是AI助理

可以解答问题、推荐解决方案等