并发番@AbstractQueuedSynchronizer一文通
并发
1.8版
- 并发番@AbstractQueuedSynchronizer一文通
- 推荐先阅读笔者的 并发番@AQS框架一文通 一文
- 感谢支持 笔者掘金专栏博文
1.AQS概述
1.1 AQS综述
AQS(队列同步器)是一个用来构建锁和同步器的框架,Doug Lea期望其作为大部分同步需求的基础:
作用: AQS是实现锁的关键,在锁的实现中聚合同步器,利用同步器实现锁的语义
功能: AQS框架提供实现阻塞锁和依赖FIFO等待队列的关联同步器
按需调用: AQS并不会实现任何同步接口,相反仅是提供一些方法以便具体锁和关联同步器按需调用
适用: 该类适用于依赖单个原子int变量表示同步状态的多种形式的同步器
原理: 内部使用一个volatile int变量表示同步状态,通过FIFO同步队列实现资源获取线程的排队工作,通过UnSafe实现底层的等待与唤醒操作,通过ConditionObject实现条件变量的使用
1.2 AQS组件
整个AQS由4个核心组件构成:
1.同步状态: 即「volatile int State」变量,该状态其实质就是可用资源数(因此是数值而不是布尔值)
2.Node节点: 队列操作的基本元素,线程入队前会先被封装成Node节点,其会记录队列操作所需的重要属性
3.同步队列: FIFO等待队列,CLH锁的变种实现并同时支持独占和共享模式,当线程获取锁失败会进入同步队列中等待,成功获取锁或因中断、异常等原因获取锁失败时出队
4.条件队列: 只用于独占模式, 且使用Condition的前提是线程已经获取到锁,并发番@ConditionObject一文通
1.3 AQS核心功能
1.3.1 加锁
AQS中加锁的基本流程如下(以独占模式为例):
- 论文版:
1 | 复制代码//循环判断同步状态是否可取 |
- 实现版:
1 | 复制代码//1.tryAcquire会CAS更新State,更新成功获取锁,否则进入同步队列以自旋方式获取锁 |
1.3.2 解锁
AQS中解锁的基本流程如下(以独占模式为例):
- 论文版:
1 | 复制代码//更新同步状态 |
- 实现版:
1 | 复制代码//1.tryRelease会CAS更新State为0,成功返回true |
1.3.3 独占与共享
AQS同时支持两种模式,分别是独占模式和共享模式:
独占模式: 即只有一个线程能持有锁(单资源,排他性),AQS提供「acquire」「release」方法
共享模式: 即有多个线程能持有锁(多资源),AQS提供「acquireShared」「releaseShared」方法
1.3.4 中断与超时
AQS同时新增对中断和超时的响应支持,同时也区分独占模式和共享模式:
独占模式: 即只有一个线程能持有锁(单资源),AQS提供「acquireInterruptibly」「tryAcquireNanos」方法
共享模式: 即有多个线程能持有锁(多资源),AQS提供「acquireSharedInterruptibly」「tryAcquireSharedNanos」方法
1.4 AQS使用
自定义同步器只需实现「State」的获取和释放即可,即如下模板方法,状态维护和队列管理等已由AQS实现:
实现独占: 「tryAcquire」「tryRelease」「isHeldExclusively」
实现共享: 「tryAcquireShared」「tryReleaseShared」
补充: 在使用时使用者只需要实现独占和共享的其中一种即可(如「ReentrantLock」),当然也支持两者都实现(如「ReentrantReadWriteLock」)
2.AQS组成
2.1 类定义
1 | 复制代码public abstract class AbstractQueuedSynchronizer |
2.2 构造器
1 | 复制代码protected AbstractQueuedSynchronizer() { } |
2.3 重要变量
1 | 复制代码/** |
2.4 内部类
AQS内包含两个内部类,分别是:
Node: 每个线程会被封装成一个Node节点,其中会记录线程、节点状态和前后节点等信息,详情请参见3.Node节点
ConditionObject: 条件变量,用于实现管程形式的条件控制,详情请参见并发番@AbstractQueuedSynchronized一文通
2.5 UnSafe
作用: 用于提供CAS原子更新操作
1 | 复制代码private static final Unsafe unsafe = Unsafe.getUnsafe(); |
3.Node节点
3.1 节点概述
待入队线程会在入队前被封装成一个Node节点,其中会记录线程、节点状态和前后节点等信息
作用: AQS框架的变种CLH锁借由Node组成的「FIFO双向链表队列」实现
链接: 每个Node通过「pred」链接其前驱节点,通过「next」链接其后继节点
条件支持: 每个Node同时会通过「nextWaiter」提供对「Condition」的支持
模式: 每个Node都可以支持独占「EXCLUSIVE」或共享「SHARED」模式
初始化: CLH锁只有在第一次入队时(即第一次出现竞争时)会初始化「Head」和「Tail」,主要是性能考究(默认少竞争)
3.2 节点类
1 | 复制代码static final class Node { |
3.2 节点状态
每个Node都有持有对应的「线程ID」,并通过int类型的「waitStatus」标记节点状态
SIGNAL(1): 标记唤醒,当前节点被释放后必须唤醒后继节点
CANCELLED(-1): 标记已取消,当Node因超时或中断被取消,取消状态不可变且对应线程不可再次阻塞 : CONDITION(-2): 标记条件阻塞,即Node位于条件变量的阻塞队列中(或者说是条件阻塞队列)
PROPAGATE(-3): 标记传递中,仅用于标记位于同步队列的头节点,表示共享状态该正在被传递中
0: 默认为0,当为条件阻塞时默认为-2,非负数意味着无须唤醒,此值使用CAS原子更新
4.状态维护
「State」状态主要用于记录线程获取锁的次数,其实质就是可用资源数,所有操作的目的其实都是为了获得她的青睐
1.必须使用CAS对「State」状态进行原子更新
2.当「State」状态>0时,说明当前线程已持有该锁;当「State」状态=0时,说明当前线程无该锁
3.由于State可自增,因此可用于实现可重入,如「ReentrantLock.lock()」
4.独占模式中「State」的值最多为1 ,共享模式中「State」的值可以任意大(如「CountDownLatch」)
1 | 复制代码/** |
5.同步队列
5.1 同步队列入队
同步队列入队主要涉及4个操作:
1.封装Node: 将当前线程封装成Node同时指定独占或共享模式
2.初始化: 当未初始化时,初始化头节点head和尾节点tail
3.队尾追加: 根据FIFO原则,新增节点会被追加到队尾
4.CAS更新tail: 通常将新增节点作为新的tail
小问:如何保证新增节点一定入队并且tail设置成功?
友情小提示:读者可以思考CAS结合重试的解决方案
小答:由于入队很可能发生并发竞争,为了处理这种情况,Doug Lea老师使用了经典的并发解决方案-
自旋CAS变更volatile变量:通过反复重试结合CAS的方式保证至少有一次能入队成功,一次不行就再来一次
1 | 复制代码/** |
小问:为神马说next是非线程安全的?有什么隐患吗?
友情小提示:读者可以从tail的更新时机角度考虑
小答:有心的读者会发现在执行CAS更新tail成功之后才会执行t.next=node
,此时节点已经真正入队;
但问题是在并发情况下,由于t.next=node
非CAS操作,因此是非线程安全的;
但由于后续操作是依赖于tail的,next更多是个优化,因此即使非安全也没关系
小问:为神马要加入一个 dummy 节点(head)?
友情小提示:读者可以从CLH锁变种考虑
小答:原因是同步队列是CLH锁的一个变种
1.线程节点能否获取锁的判断就是通过其前继节点的状态,当前节点若想获取锁需要给前驱节点设置为SIGNAL状态,作用是当前驱节点释放锁后能通知其后继节点去获取锁
2.head节点用来表示当前已获得锁的节点,其无须存储线程,它的核心功能是作为必然存在的前驱节点通过记录waitStatus状态作为是否需要唤醒后继节点的判断依据
5.2 同步队列出队
5.2.1 独占模式成功获取锁后出队
独占模式成功获取锁出队会做三个操作:
1.设置Head: Head指向成功获取锁(即待出队)的节点,作为新的头节点
2.清空当前节点引用: 待出队的节点需要将thread和prev属性设置为null,help gc
3.清空前驱节点next: 由于当前节点需要被GC,因此也要清除其前驱节点的next
1 | 复制代码/** |
小问:为神马要清空节点引用??
友情小提示:读者可以从Node的内部变量的作用以及head作用在同步队列中的作用这方面去考虑
小答:Head在同步队列中的定位就是作为待出队节点的一个状态记录点,以作为唤醒后继节点的依据
1.其核心在于waitStatus的值(当为SIGNAL时才会去唤醒后继节点),因此Node的其他属性并不重要,Head更多是虚拟节点的存在,只存储waitStatus即可,以作为唤醒后继的依据
2.同时清空thread、prev、prev.next有利于GC回收待出队的节点,因为该节点在给head设置完waitStatus之后就完成了使命,线程可以出队了
5.2.2 共享模式成功获取锁后出队
共享模式成功获取锁出队会做两个操作:
1.设置Head: 设置头节点,同时检测在共享模式下是否有后继者等待获取锁
2.向后传播唤醒: 如果存在,则在满足(propagate > 0 或 节点状态为PROPAGATE)时传播唤醒
1 | 复制代码/** |
小问:何时会出现h==null的情况?
友情小提示:这个可能需要测试极端环境,如果有读者有想法的欢迎留言指导笔者一二
5.2.3 因中断/超时获取锁失败后出队
友情小提示:建议在看完aquire操作之后再回看5.2.2
因中断/超时而放弃获取锁失败的出队会做三个操作:
1.清空线程引用:清空当前节点的线程引用,便于当前节点出队和GC
2.寻找非取消状态前驱节点:沿prev反向遍历直到找到一个非取消状态前驱节点
3.设置CANCELLED状态:核心步骤,失败的节点需要设置为取消状态
4.清除、重置、唤醒:见步骤4-8
1 | 复制代码/** |
6.独占模式
6.1 独占式获取锁
独占式获取锁总共有三种方式:
1.不响应中断获取锁-acquire: 不响应中断指的是线程获取锁时被中断后能被重新唤醒并继续获取锁,在方法返回后会根据中断状态决定是否重新设置中断
2.响应中断获取锁-acquireInterruptibly: 响应中断指的是当线程获取锁时被中断会立即抛出异常,获取失败
3.响应中断和超时获取锁-tryAcquireNanos-: 处理方式等同响应中断获取
,区别是多了超时后直接返回fasle,获取失败
(核心重点)独占式如何成功获取锁:
1.调用tryAcquire方法成功时才能成功获取锁
2.其他所有手段(比如同步队列、CAS自旋volatile变量等)全部是为了辅助调用tryAcquire方法
3.tryAcquire方法中会通过对state进行CAS操作判断是否能够获取锁,即获取锁的根源在于state的值
6.1.1 不响应中断获取锁
不响应中断主要遵循如下四步:
1.tryAcquire: 初次调用子类自实现的tryAcquire方法获取锁,成功即获得锁,否则进入第二步
2.addWaiter: 当前线程封装为独占Node并进入同步队列,等待前驱节点的SIGNAL状态并进入第三步
3.acquireQueued: 自旋获取锁直到调用tryAquire成功获取锁为止(前驱节点为Head时就会尝试调用tryAcquire),自旋过程中可能多次阻塞和解除阻塞,值得注意的是park是进入等待状态
4.selfInterrupt: 若线程获取锁途中被中断,当成功获取锁后,由于中断状态被中途清除,需要补中断状态
1 | 复制代码/** |
小问:为神马前驱节点为head时需要再次尝试获取锁呢?
友情小提示:读者可以从前驱节点为head时的锁获取情况去考虑
小答:因为当前驱节点为head时会有两种情况:
1.前驱节点已成功获取锁并正在占用该锁,但可能很快释放
2.前继节点是空节点, 此时已经释放锁, 因此后继节点就有机会获取锁了
6.1.2 响应中断获取锁
与不响应中断获取锁相比,响应中断获取锁只有两个区别:
1.当获取锁的过程中发生中断,立即抛出中断异常,然后进入finally处理失败
2.同时少了设置中断状态的步骤
1 | 复制代码/** |
小问: 若 if (p == head && tryAcquire(arg)) { //恰好在执行到该方法内部时发生中断 }
,此时会如何处理中断?
友情小提示:读者可以从线程状态角度去考虑
小答:此时该中断会被忽略,原因是该线程目前是运行态,而运行态是不响应中断的
友情推荐:关于中断响应机制读者可参看笔者的 并发番@Thread一文通(1.7版)
6.1.3 响应超时与中断获取锁
与响应中断获取锁相比,响应超时与中断获取锁有三个区别:
1.若获取锁的过程中超过超时阈值,会先执行超时阻塞;否则会先再次自旋
2.一旦超时立即返回fasle,然后进入finally处理失败
3.有返回值返回是否获取锁,成功返回true,失败返回false
1 | 复制代码/** |
小问:为神马要设置超时阈值?
友情小提示:读者可能从锁优化角度考虑
小答:在剩余时间(nanosTimeout)小于超时阈值(spinForTimeoutThreshold)时,自旋的效率比LockSupport.park更高且开销更少
6.2 独占式释放
独占式释放锁遵循如下步骤:
1.调用子类的tryRelease释放锁资源,若有重复锁需要完全释放
2.当head的waitStatus状态非0,意味着同步队列为空,需要尝试唤醒同步队列中的下一个等待唤醒的线程
1 | 复制代码/** |
7.共享模式
7.1 共享式获取锁
共享式获取锁也有三种方式,与独占式保持一致:
1.不响应中断获取锁-acquireShared
2.响应中断获取锁-acquireSharedInterruptibly
3.响应中断和超时获取锁-tryAcquireSharedNanos
共享式和独占式获取锁原理基本一致,主要区别在于:
1.子类需要实现共享方式获取锁tryAcquireShared
2.节点出队方式变更,当获取锁成功时前驱节点出队时会传播唤醒操作
7.1.1 不响应中断获取锁
不响应中断主要遵循如下两步:
1.tryAcquireShared: 先调用子类自实现的tryAcquireShared方法获取锁,成功即获得锁,否则进入第二步
2.doAcquireShared: 当前线程封装为共享Node并进入同步队列,自旋+共享方式获取锁
1 | 复制代码/** |
小问:为神马共享式不响应中断获取锁可以直接设置中断,而独占式却是返回中断状态(核心区别之三)?
友情小提示:读者可以用selfInterrupt()方法的复用情况方面考虑
小答:这涉及到条件队列的部分内容,条件队列只能用于独占模式(因为使用条件队列的前提就是先获取到锁 – 管程要求),而在条件队列的多个方法中会根据判断条件决定是否要执行selfInterrupt()方法,因此在共享模式中可以直接中断,而独占需要返回中断状态告知独占或条件队列是否执行selfInterrupt()方法
7.1.2 响应中断获取锁
1 | 复制代码/** |
7.1.3 响应超时与中断获取锁
1 | 复制代码/** |
7.2 共享式释放
共享式释放相比独占式来说有个特色:
当同步队列中存在多个共享节点且资源够数时可能会并发地唤醒后继节点,因为共享模式下获取锁后就近唤醒后继节点
1 | 复制代码/** |
小问:共享模式为何采用自旋方式释放锁?
友情小提示:遇到CAS操作,我们都需要考虑三个点:1.为何此处用CAS 2.若CAS成功呢? 3.若CAS失败呢?
补充小提示:CAS之所以会失败,通常都是发生了竞争,当然也可能是异常、中断等,因此失败原因很重要
8.线程中断、阻塞、唤醒
8.1 shouldParkAfterFailedAcquire
当线程无法获取锁时,会先通过该方法处理三种状态情况:
1.前驱节点状态为SIGNAL: 一定会唤醒后继节点,直接返回true,说明可以安心park
2.前驱节点状态为CANCELLED: 需要反向找到一个非取消状态节点作为新前驱节点,返回false,此时不允许park
3.前驱节点为0或PROPAGATE(共享): 由于前驱节点和当前节点都存在,因此存在前驱节点需要唤醒后继节点的必要性,因此前驱节点需要被设置为SIGNAL状态
1 | 复制代码/** |
小问:为神马compareAndSetWaitStatus(pred, ws, Node.SIGNAL)
后仍要返回false,而不是直接返回true?
友情小提示:读者可以从自旋意义这一方面入手考虑
小答:其实从首先判断 ws==Node.SIGNAL
我们就可以一窥一二,理由有三:
1.CAS是允许失败的,当CAS失败时说明前驱节点已经出现了问题(比如刚释放完),很明显是不安全的
2.即使CAS失败也可补救,因此搭配自旋,大不了再自旋一轮嘛,多大点事!确保安全第一位嘛!
3.当CAS成功后,结合首先判断ws==Node.SIGNAL
,我们可以确保在安全环境下快速返回,提高效率
核心重点:该方法根本作用就是要确保成功地设置前驱节点的SIGNAL状态,以确保前驱节点释放锁后一定能唤醒被park阻塞的后继节点
8.2 parkAndCheckInterrupt
注意前提:只有在前驱节点为SIGNAL状态下才能park阻塞当前节点
1 | 复制代码/** |
友情推荐:
- 关于中断机制读者可参看笔者的 并发番@Thread一文通(1.7版)
6.线程中断
- 关于park读者可参看笔者的 并发番@LockSupport一文通(1.8版)
8.3 selfInterrupt
1 | 复制代码/** |
8.4 unparkSuccessor
唤醒后继节点有三个注意事项:
- 1.状态位变化:初始状态0 -> Node.SIGNAL(作为唤醒后继节点的依据) -> 0 (唤醒后继节点后回归纯真)
- 2.若当前节点的后继节点不存在或是取消状态,需要反向遍历去找最靠近当前节点的非取消状态后继节点
- 3.若存在非取消状态的后继节点就唤醒他,当然找不到就算了(说明没有需要获取锁的节点存在啦)
1 | 复制代码/** |
小问:为神马要沿着prev从tail往前获取非取消节点呢?
友情小提示:读者可以从入队、出队、取消操作入手考虑prev和next的线程安全问题
小答:这个问题实际上考察了两个知识点,一个是为何要反向遍历,另一个是为何要跳过取消节点
1.反向遍历:prev赋值线程安全,next赋值非线程安全且在CLH队列不同形态会有不同的表现,判断逻辑会比prev复杂很多
2.跳过取消节点:取消状态节点都是需要被GC回收的节点,因为其放弃了获取锁的机会,正所谓强扭的瓜不甜呀!
小问:有心的读者会发现在unparkSuccessor
方法中其实并没有操作队列,那么线程节点是何时出队的呢?
友情小提示:读者可以回顾一下获取锁的整个过程重新梳理一下
小答:unparkSuccessor
方法主要做了个唤醒操作,真正的出队操作(比如调整prev、next)是在setHead
、shouldParkAfterFailedAcquire
、cancelAcquire
等方法中实际完成的
9.题外话
笔者看了一下,距离上篇已过了一月有余,以至于期间不止一人问笔者是否已断更(疼)。笔者正好借此机会谈一下笔者的想法,笔者会一直写下去,之所以长时间不更新,主要有如下几点原因:
1.不可原谅的客观原因:笔者近期的确事情很多,不止于工作
2.出一篇令自己满意的、接近”精品”的文章真的给了笔者很大的压力,唯恐误人子弟
3.为了此篇,笔者花了很长时间去看了很多资料(也包括翻译Doug Lea老师的论文),但由于水平有限,在加上此篇的确需要好好研读,因此无论从构思还是内容都花了大量的时间,期间笔者都不知修改了多少次都不甚满意,甚至重写的情况,排期真是一变再变
4.由于并发的难以预料带来的难以理解,因此在理解AQS时,需要从多个维度去思考问题,包括每行代码的意义、CAS操作(如为何此处用CAS?CAS成功后?CAS失败后?)、从单个线程到并发、从入队到出队、从好人模式切换到坏人模式等等,AQS篇的精妙之处还远不止笔者目前所述(此篇还是略显仓促),因此随着笔者功力的加深,笔者会坚持继续维护此篇
5.因为种种原因造成的此篇内容有错或者不足之处,笔者十分抱歉,也感谢读者能够热心指点一二,不胜感激!!!
6.最好还是希望读者们继续支持笔者,支持XX番系列~
本文转载自: 掘金