通过一个例子走进管程的世界
冰墩墩的生产消费问题
22年春节期间,北京冬奥会举办得特别成功,吉祥物冰墩墩让大家“一户一墩”成了困难,现在有4个消费者同时抢冰墩墩的时候冰墩墩没货,过一会儿,冰墩墩分为两批生产,每次生产两个冰墩墩,如下图所示
示例代码
走进管程世界
上面的冰墩墩生产消费例子,用到了并发中Lock和Condition,它里面的核心思想是MESA管程来保证线程的互斥与同步,下面我们来聊聊,Java中解决并发问题的钥匙——管程
什么是管程?
管程指的是管理共享变量以及对共享变量的的操作过程,让它们线程安全的支持并发。
管程如何实现互斥?
管程模型中,将共享变量和对共享变量的操作封装,访问共享变量必须通过管程提供的方法来实现,方法保证了互斥性,只允许一个线程进入管程,与面向对象思想契合。
管程如何实现同步?
- 管程模型是封装的,多个线程试图进入管程,只允许一个线程进入,其它线程在入口等待队列中等待。
- 管程里引入了条件变量的概念,每个条件变量都有一个条件变量等待队列
MESA管程的运行机制
- 共享变量:多个线程同时操作同一个对象的共享变量,这个共享变量的操作,**管程就是来保证这个共享变量操作是线程安全的。**如上面例子中BingDwenDwen中的amount。
- 等待队列:互斥的保证,在Java语言管程的实现中,操作共享变量,需要先进入管程,进入管程实现的方式不同,比如AQS中,可重入锁进入管程代表的是state被当前线程设置为1,如果进入管程失败需要入到AQS的CLH队列中。这个CLH队列就是等待队列。
- 条件变量:我们使用lock创建的condition对象,它结合条件队列与等待队列来实现同步
- 条件队列:当进入管程内部的线程不满足竞态条件时,需要放弃时间片,调用条件变量的await进入到条件队列中,处于阻塞状态
注:图中只列举了一个条件队列,其实这块可以有多个条件队列,本文解析AQS原理的时候,也会只使用一个条件队列。
根据示例代码理解管程的运行机制
通过冰墩墩的示例代码来理解管程的运行机制,按程序运行的结果(这只是一种结果),拆解运行机制大体分为三个阶段:1.T1、T2、T3、T4全部没抢到冰墩墩、2.PT1生产两个冰墩墩,T1、T2抢到冰墩墩、3.PT2生产两个冰墩墩,T3、T4抢到冰墩墩
1. T1、T2、T3、T4全部没抢到冰墩墩
首先T1获取到锁,进入管程内部,想要对共享变量amount进行操作,但是被竞态条件while(amount==0)校验住,调用了条件变量的await方法释放时间片,进入到条件队列中处于阻塞状态
同理,T2、T3、T4全部进入到条件队列中阻塞,等待唤醒,这个时候入口的等待队列已经为空,没有线程再去争用锁
2. PT1生产两个冰墩墩,T1、T2抢到冰墩墩
PT1生产者线程进入到管程中,对共享变量amount进行加2操作,并且唤醒所有处在条件队列中的线程
T1、T2、T3、T4被唤醒,T1、T2先后重新进入管程(并不是同时进入,使用的方式是重新转移到等待队列,等待被唤醒)执行while(amount == 0)成功,获得到了冰墩墩,T3、T4再次await进入到条件队列中,等待下次被唤醒
- PT1生产者线程通过signalAll唤醒T1、T2、T3、T4线程,转移到等待队列中,PT1 unlock释放锁 T1、T2、T3、T4争用锁后重新执行之前被阻塞的逻辑(最关键的一步)
- T3、T4再次await进入到条件队列中,等待下次被唤醒
注:MESA管程需要while判断竞态条件,根据这个例子可以看出如果四个线程被唤醒向下执行,那么amount很可能T1、T2时候变为0了,T3、T4没有重新判断导致超卖
3. PT2生产两个冰墩墩,T3、T4抢到冰墩墩
PT2生产者线程进入到管程中,对共享变量amount进行加2操作,并且唤醒所有处在条件队列中的线程
T3、T4被唤醒,T3、T4执行while(amount == 0)成功,获得到了冰墩墩
通过ReentrantLock走进AQS的世界
通过上面的例子,我们对通过MESA管程的运行机制和思想来理解冰墩墩的示例,那么在Java并发包中,Doug Lea使用这个思想实现了所有类型锁的核心AQS,接下通过ReentrantLock的具体实现,走进AQS的世界。注:本文讲的例子是AQS独占模式非公平锁,非共享模式公平锁
AQS中的相关核心ADT介绍
- AQS是个抽象模板类,ReentrantLock中Sync抽象类继承AQS,NonfairSync与FairSync实现抽象Sync类,示例代码中Lock lock = new ReentrantLock(),sync成员变量就是NonfairSync对象
- Node类是AQS和Condition的队列节点类,是核心ADT,CLH同步队列就是管程模型中的等待队列,条件队列顾名思义则是管程模型中的条件队列,如下图所示
- condition对象通过lock.newCondition()创建的ConditionObject类型对象,那么它如何与lock关联上的呢?通过lock创建CondtionObject对象,其实CondtionObject是通过lock的成员变量sync创建的,上面已经说过了sync的类型NonfairSync是AQS的抽象实现类Sync的抽象实现类,所以CondtionObject对象通过内部类的方式可以间接使用AQS的CLH队列数据,这样就可以实现,自己的条件队列像CLH队列转移数据
通过示例理解AQS
还是按照管程的冰墩墩示例,来详细解读AQS的原理
T1、T2、T3、T4全部没抢到冰墩墩
-
T1 lock.lock()成功(并不是一定是该情况,只是举例一种情况),此时amount=0,所以也没抢到
lock.lock()通过自己的成员变量sync调用NonfairSync的lock方法,首先进行CAS
state = 1操作,如果设置成功代表获取锁成功,把当前线程设置到AQS中 -
T2、T3、T4失败进入CLH等待队列,AQS
acquire(int arg)方法的出现(非常重要的方法,也是不好理解的方法)AQS acquire方法主要流程:
- 获取同步状态:调用的是sync#tryAcquire的具体实现,也就是尝试CAS state = 1
- 入队操作:addWaiter的具体逻辑,向队列的中添加节点
- 申请入队操作:队列节点创建完成会,如果前驱节点是头结点,会再次尝试CAS state = 1,再次失败会进入是否线程进入等待状态(Lock.park(thread)) 申请入队操作有点复杂,下面是流程:
final boolean acquireQueued(final Node node, int arg) { // 标记是否成功拿到资源 boolean failed = true; try { // 标记等待过程中是否中断过 boolean interrupted = false; // 开始自旋,要么获取锁,要么中断 for (;;) { // 获取当前节点的前驱节点 final Node p = node.predecessor(); // 如果p是头结点,说明当前节点在真实数据队列的首部,就尝试获取锁(别忘了头结点是虚节点) if (p == head && tryAcquire(arg)) { // 获取锁成功,头指针移动到当前node setHead(node); p.next = null; // help GC failed = false; return interrupted; } // 说明p为头节点且当前没有获取到锁(可能是非公平锁被抢占了)或者是p不为头结点,这个时候就要判断当前node是否要被阻塞(被阻塞条件:前驱节点的waitStatus为-1),防止无限循环浪费资源。具体两个方法下面细细分析 if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt()) interrupted = true; } } finally { if (failed) cancelAcquire(node); } } // 靠前驱节点判断当前线程是否应该被阻塞 private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) { // 获取头结点的节点状态 int ws = pred.waitStatus; // 说明头结点处于唤醒状态 if (ws == Node.SIGNAL) return true; // 通过枚举值我们知道waitStatus>0是取消状态 if (ws > 0) { do { // 循环向前查找取消节点,把取消节点从队列中剔除 node.prev = pred = pred.prev; } while (pred.waitStatus > 0); pred.next = node; } else { // 设置前任节点等待状态为SIGNAL compareAndSetWaitStatus(pred, ws, Node.SIGNAL); } return false; }waitStatus:
以上分析了AQS#
acquire详细的流程,回到示例中,T2、T3、T4进入到阻塞队列 -
T1执行示例代码中的condition.await();
- 创建条件节点,如果队列不存在的时候初始化队列,如果存在则把当前节点加入到条件队列中(addConditionWaiter())
- 释放当前线程占有的锁,通过fullyRelease(node)间接执行AQS release操作
- 挂起当前线程(LockSupport.park(this))
T1释放了锁,T2、T3、T4同理的逻辑,也都会执行condition.await(),并且都进入到条件队列中
PT1生产两个冰墩墩,T1、T2抢到冰墩墩
随着消费者T1、T2、T3、T4都await,AQS state=0,生产者PT1进入管程内,开始执行生产冰墩墩
PT1执行完amount+2操作,生产了两个冰墩墩后,执行condition.signalAll(非阻塞),直接执行lock.unlock操作,释放了锁,下面来看下condition.signalAll与lock.unlock的实现
- PT1生产者线程执行condition.signalAll,将条件队列中的T1、T2、T3、T4全部转移到CLH队列中
- 将条件队列中的节点转移到同步队列中,涉及到间接访问AQS CLH队列头结点(上面已经说过如何间接访问),来完成转移
- 转移到同步队列中的节点waitStatus设置为-1
- PT1生产者线程执行lock.unlock操作
- 先释放锁;
- 通过head找到节点找到后继节点,然后唤醒节点的线程;
当前CLH队列的head后继节点是T1线程,所以PT1释放锁后,持有锁最有可能是T1(有可能这个时候T5进来了,T1就有可能没有CAS state = 1),同理T2、T3、T4都会依次被唤醒,但是T3、T4因为被唤醒后,while (amount == 0)时失败,重新进入到条件队列,T1、T2抢到冰墩墩。
PT2生产两个冰墩墩,T3、T4抢到冰墩墩
PT2生产者生产冰墩墩过程,T3、T4被重新唤醒的过程和上一步完全类似,重复上面的过程最后得到如下结果
总结
管程是操作系统中的概念,可以看下下面北京大学的操作系统关于MESA管程的课程,管程是解决并发的万能钥匙,Java中无论是synchronized还是Lock(基于AQS)都是对MESA管程做了微小的改造,利用语言本身的特性来实现“锁”的功能。