☰
深入AQS:doReleaseShared如何用PROPAGATE守住共享锁唤醒链
2026/10/16 4:29:11 网站建设 项目流程

做Java并发这么久,我建议每个想深入AQS的人都认真读一遍doReleaseShared。这个方法在共享模式(共享锁)的释放链路里承担着最后一公里的唤醒职责:一个线程释放共享资源后,谁来唤醒下一个等待者,下一个等待者拿到资源后如何继续把唤醒传递下去,全压在它身上。如果你看Semaphore、CountDownLatch、ReentrantReadWriteLock的源码看得云里雾里,多半就是这块没打通。

doReleaseShared在JDK8的AbstractQueuedSynchronizer里只有不到40行,是一个for循环加两个CAS分支,表面简洁到不行。但简洁的背后,是一段真实并发bug修复留下的痕迹,涉及waitStatus从0到PROPAGATE的转换、与setHeadAndPropagate的配合、以及一套“循环重查head”的防竞态手法。这篇文章我会从整条调用链讲起,逐行拆解它的实现,推演最经典的丢失唤醒场景,最后聊几个源码阅读和线上排查的实操经验。适合已经对AQS队列模型有基础认知、想彻底啃下共享模式的读者,也适合准备并发方向面试的人拿来梳理线索。

1. 先把它放进整张图里:共享锁唤醒的责任链

1.1 共享模式的两个入口

AQS内部的同步器分两套玩法:独占模式和共享模式。独占模式同一时刻只有一个线程持有资源,典型代表是ReentrantLock;共享模式允许多个线程同时持有资源,典型代表是Semaphore、CountDownLatch以及ReentrantReadWriteLock的读锁。doReleaseShared这个名字里的“Shared”已经暗示了它只服务于共享模式。

共享模式的入口有两个,而且都绕不开doReleaseShared:

第一个入口是释放路径。调用releaseShared(arg)时,AQS先调用子类实现的tryReleaseShared(arg),尝试释放资源,成功后就进入doReleaseShared()去唤醒后继节点。这一段逻辑非常直白,releaseShared本身代码只有几行,真正的唤醒逻辑全部下沉到了doReleaseShared里。

第二个入口是获取路径。调用acquireShared(arg)时,如果tryAcquireShared(arg)返回负数,表示获取失败,线程要进入doAcquireShared(arg)排队等待。等到它被前面的线程唤醒、成功拿到资源后,会进入setHeadAndPropagate(node, propagate),这个方法内部又可能再次调用doReleaseShared(),把唤醒继续向后传递。

换句话说,doReleaseShared同时被“释放方”和“获取方”调用。理解这个双入口,是读懂它的前提:它不是一个简单的“释放一次、唤醒一个”的方法,而是一个要保证“唤醒事件不被丢失”的接力机制。

1.2 为什么唤醒要“接力”而不是“广播”

有人可能会问:共享锁意味着多个线程可以同时用资源,那释放的时候干脆把等待队列里所有线程全部唤醒不就行了?这个想法听着合理,实际实现却不是这么干的。

原因在于,AQS的等待队列是按FIFO组织的一个双向链表,每个节点代表一个等待线程。当一个共享资源被释放时,真正能安全推进的只有“队首的下一个节点”。如果一次性去唤醒队列里所有的线程,虽然这些线程被唤醒后都会去抢资源,但将会产生大量的竞争和上下文切换。以Semaphore为例,假如只有1个许可、队列里排了5个线程,把它们全唤醒,最终也只有1个线程成功拿到许可,其余4个都得再睡回去,白白空转一轮。

所以AQS采用的策略是“一次只唤醒一个,由被唤醒者接力向后传”。doReleaseShared的职责就是:确认队首的下一个节点需要被唤醒,然后精确地unpark那一个线程。等这个线程成功获取资源、成为新的队首之后,再根据情况决定是否继续向下一个节点传播唤醒。这个“接力”设计在正常情况下效率很高,但也给并发正确性埋下了伏笔:万一某个释放事件发生在接力交接的窗口期,唤醒信号可能被丢掉。这正是PROPAGATE状态和doReleaseShared里那些看似多余判断存在的意义。

2. doReleaseShared逐行拆解

2.1 方法全貌:JDK8里的实现

先把JDK8里doReleaseShared的完整源码贴出来,我加了中文注释,后面逐行讲:

private void doReleaseShared() { for (;;) { // 自旋,乐观地反复检查 Node h = head; if (h != null && h != tail) { // 队列里至少有两个节点 int ws = h.waitStatus; if (ws == Node.SIGNAL) { // 队首明确表示后继需要唤醒 if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0)) continue; // CAS失败,说明状态被别人改了,重来 unparkSuccessor(h); // 唤醒后继节点 } else if (ws == 0 && !compareAndSetWaitStatus(h, 0, Node.PROPAGATE)) continue; // 标记'传播中'失败则重来 } if (h == head) // 队首没变,说明没有新情况,退出 break; } }

别看代码少,它身上背着两套职责:一是“正常唤醒”,处理队首节点状态为SIGNAL的情况;二是“布防标记”,处理队首节点状态为0、但可能正好处于释放窗口期的情况。两个分支分别对应不同的并发场景,下面逐个拆。

2.2 第一个分支:SIGNAL的CAS唤醒

先回忆一下AQS里waitStatus的取值:CANCELLED是1,SIGNAL是-1,CONDITION是-2,PROPAGATE是-3,0表示初始状态。每个节点在等待时,会把自己的前驱节点的waitStatus设置成SIGNAL,意思是“兄弟,你被唤醒的时候,记得顺手unpark我一下”。这个约定在shouldParkAfterFailedAcquire里完成。

所以在doReleaseShared里,如果读到队首head的waitStatus是SIGNAL,说明队首后面一定有一个等待线程需要被唤醒。接下来的操作是:先把head的waitStatus从SIGNAL改成0,然后再去unpark后继线程。

这里有个关键点:为什么要先CAS改成0,再unpark?因为这是一个“占用标记”的语义。多个线程可能同时进入doReleaseShared,如果大家都读到SIGNAL,然后都去unpark同一个后继节点,虽然LockSupport.unpark可以重复执行没有严重后果,但会让同一个被唤醒线程被打扰多次,而且错误地消耗了本应该留给其他节点的唤醒机会。先CAS成0抢到“唤醒权”,只有抢到的人才能去unpark,CAS失败的线程直接continue重新走循环,避免重复唤醒。

CAS失败后continue的另一个含义是:状态已经被别的线程改了,当前线程基于旧状态做的判断失效了,必须回头重新读head、重新判断。这种“读-改-失败-重读”的模式在整个AQS里极其常见。

2.3 第二个分支:从0到PROPAGATE的标记

第一个分支好理解,第二个分支才是doReleaseShared的精华。

当读到头节点waitStatus是0的时候,说明当前没有明确的唤醒需求。但注意,0这个状态本身藏着一个风险:它有两种可能。一种是队列确实风平浪静,不需要做任何事;另一种是刚刚发生过一次唤醒,上一个释放线程已经把SIGNAL清零了,但被唤醒的线程还没来得及执行、还没有把自己变成新的队首。在这个窗口期里,如果又有新的释放事件发生,它看到队首状态是0,就会误以为无事可做,从而放弃传播。这一放弃,可能就让队列里更靠后的节点永远等不到唤醒。

防止这个问题的办法,就是在这个窗口期里,给队首节点打一个“标记”:把waitStatus从0改成PROPAGATE。PROPAGATE是负数,也就是小于0。将来这个被唤醒的线程跑起来、拿到资源、执行setHeadAndPropagate时,会去检查旧的队首(也就是现在这个打了标记的节点)的waitStatus。只要看到负值,它就知道“在我成为队首之前,有人释放过资源但没能完成传播,我必须接手把唤醒继续传下去”。

这里还有一个细节:为什么当ws等于0的时候,CAS改成PROPAGATE失败也要continue?因为CAS失败意味着waitStatus已经被别人改成别的值了。如果被别人改成了SIGNAL,说明新的等待者已经入队并且发出了唤醒请求,当前线程应该重新走一遍第一个分支;如果被改成CANCELLED,说明节点状态也变了。总之旧判断作废,必须重来。

2.4 收尾条件:h == head 为什么必须重查

最容易被忽略的是循环末尾的“if (h == head) break”。看起来没什么技术含量,实际上是整个方法防重入的关键。

看仔细一点:方法入参没有任何参数,每次循环都是重新读一次head。执行完唤醒或者打标记之后,如果head和最初读到的h一样,说明在这段时间里没有新节点变成队首,队列前沿没有变化,可以安全退出。但如果head变了,说明有新节点在并发执行过程中插队或者前移了,当前的循环需要继续处理这个新的队首,否则可能漏掉对它的唤醒。

举个例子。线程A在doReleaseShared里读到h为节点N,正准备处理。与此同时,某个被唤醒的线程B已经执行了setHead操作,把自己的节点B变成了新的head。此时如果A只处理完N就退出,那么节点B如果本身也需要被唤醒,就没人管了。所以A循环回来重新读head,发现读的是B而不是N,说明队列拓扑变了,要继续。只有当它处理完一圈、确认head没变,说明所有并发变化都被收敛了,才break。

理解这个小判断,才能真正理解doReleaseShared为什么敢自称“不会丢失唤醒”。它本质上是一个乐观自旋,反复试图走在队列变化的前面,走不过去就重来,直到确认队列前沿稳定为止。

3. PROPAGATE背后的血泪史:一个真实并发bug

3.1 丢失唤醒场景推演

说PROPAGATE是历史bug的产物,不是夸张。在早期的JDK版本里,基于AQS实现的共享锁曾经存在一个罕见的丢失唤醒问题,触发条件非常刁钻,理解它,比背任何八股都更有价值。

构造一个场景。假设有一个Semaphore,初始许可数为2。线程A和线程A2各持有一个许可,许可数归0。此时线程B和线程C依次尝试获取许可,都失败,进入等待队列。队列结构是:队首节点是A的节点,后面依次是B的节点、C的节点。B在入队时把前驱(A的节点)的waitStatus设置成了SIGNAL。

现在线程A2释放许可。releaseShared走进去,tryReleaseShared把许可数变成1,接着进入doReleaseShared。它读到队首是A的节点,waitStatus是SIGNAL,于是CAS成0,unpark线程B。A2的循环继续,发现head没变,break退出。此时A的节点waitStatus归0。

接下来发生的事是时间窗口的关键。线程B被唤醒,开始执行doAcquireShared里的获取逻辑,tryAcquireShared返回了0。注意,返回值0意味着B成功拿到了许可,并且这是最后一个许可,资源再次归0。按照AQS的设计,当tryAcquireShared返回值大于等于0就算获取成功,但propagate这个值等于0。

然后B进入setHeadAndPropagate,要把自己变成新的队首。假设它还没跑完这个方法,队列里C还在后面等着。那么问题来了:C什么时候被唤醒?A2在doReleaseShared里只负责唤醒队首的后继节点,即只unpark了B。按接力设计,这个唤醒责任应该在B成为新队首后,由B传给C。

但旧版本里的代码逻辑是:setHeadAndPropagate只有在propagate大于0时才继续传播。而这里propagate恰好等于0,因为B拿的是最后一个许可。于是B不再传播唤醒。C被彻底遗忘,永远等不到许可。除非后续再有其他线程释放许可,C才能被救活。

这就是一个典型的丢失唤醒。本质原因是:释放方只唤醒了一个节点,而获取方以为没有余量就不需要继续传播,但实际上队列里还有等待者。一句“propagate > 0”的判断,在一个极其窄的窗口里,成了压死骆驼的稻草。

3.2 修复思路:给“过路”的释放者留个记号

修复这个bug的思路,可以理解为:让“释放事件”本身留下痕迹,而不是只依赖返回值传播。

回到doReleaseShared的第二个分支。当某个释放线程发现队首waitStatus是0时,它并不急着判断“无事发生”然后退出,而是尝试把waitStatus从0改成PROPAGATE。如果改成功,就等于在队首节点上刻了一句话:“这里发生过一次未能完成传播的释放,后来的新队首看到这个标记,必须继续传播。”

这个标记本身就是负值(-3),小于0。而setHeadAndPropagate判断是否继续传播的条件恰好包含对负值的检查。所以即使propagate等于0,只要旧队首或新队首的waitStatus小于0,传播就会继续。

回到刚才的场景:A2释放完、unpark了B之后,如果在线程B还没来得及变成新队首的窗口里,恰好又有一个线程进入doReleaseShared(比如线程A随后也释放许可),它看到队首A节点的waitStatus是0,就会把状态改成PROPAGATE。等线程B醒来执行setHeadAndPropagate时,读到的旧队首waitStatus已经变成PROPAGATE,小于0,于是B会再次调用doReleaseShared,把C唤醒。

一句话总结修复思路:唤醒信号不再单纯依赖“释放时现场计算”,而是通过PROPAGATE标记,让唤醒责任可以跨线程交接。负责交接的线程可以变,但标记一直在,只要交接链上没有断点,唤醒就不会丢。

3.3 setHeadAndPropagate是如何接过接力棒的

既然doReleaseShared负责“发信号”,那接收信号并继续传递的角色就是setHeadAndPropagate。这个方法的源码在JDK8里长这样:

private void setHeadAndPropagate(Node node, int propagate) { Node h = head; // 记录旧队首,用于下面的检查 setHead(node); // 把当前节点变成新队首 if (propagate > 0 || h == null || h.waitStatus < 0 || (h = head) == null || h.waitStatus < 0) { Node s = node.next; if (s == null || s.isShared()) doReleaseShared(); } }

这个方法的分支判断值得细品。触发继续传播的条件有四种:propagate大于0;旧队首为空;旧队首waitStatus小于0;新队首waitStatus小于0。前两种一目了然,后两种分别对应说过的窗口期情况。

旧队首waitStatus小于0,就是PROPAGATE标记被刻在上面的场景。新队首waitStatus小于0也有自己的含义:它说明当前线程成为新队首之前,已经有后继节点把状态设置成SIGNAL了,也就是说队列后面有等待者,当前线程不能装死,必须把唤醒传下去。这两种情况配合doReleaseShared的PROPAGATE分支,形成了一张严密的网。

另外注意,setHeadAndPropagate里调用doReleaseShared之前还检查了后继节点是不是共享模式:s == null || s.isShared()。如果后继节点是独占模式的节点(比如读写锁里排队的写锁请求),唤醒逻辑就交给独占锁路径去处理,这里不越界。这个细节提醒我们,AQS的独占和共享虽然是两套模式,但它们在同一个等待队列里共存,谁也不能乱碰谁的节点。

4. 完整场景演练与配套方法解读

4.1 典型信号量场景:一个线程按部就班走完全程

光看代码容易眼晕,我习惯把doReleaseShared放到一个具体例子里走一遍。还是用Semaphore,许可数初始为1,三个线程参与,命名成A、B、C。

第一阶段,线程A获取唯一许可。AQS里第一次获取是直接走tryAcquireShared成功,不会进等待队列,此时队列里只有头节点。

第二阶段,线程B来获取。tryAcquireShared发现许可数为0,返回-1,于是进入doAcquireShared,创建共享节点接到队列尾部,然后执行shouldParkAfterFailedAcquire,把前驱节点(头节点)的waitStatus设置成SIGNAL,最后park挂起。线程C同样操作,队列变成“头节点 -> B节点 -> C节点”,头节点waitStatus为SIGNAL。

第三阶段,线程A释放许可。releaseShared调用tryReleaseShared把许可数改回1,然后进入doReleaseShared。此时h是头节点,h != tail成立,h.waitStatus是SIGNAL。第一个分支命中,CAS把头节点状态改成0,unpark线程B。循环末尾检查h还是head,break退出。

第四阶段,线程B被唤醒。它的doAcquireShared循环继续跑,tryAcquireShared把许可数改成0并返回1,获取成功。随后调用setHeadAndPropagate(nodeB, 1):propagate大于0,条件成立,于是又调用doReleaseShared。这一次h已经是B节点本身,h == tail,说明队列里没有等待者了,什么都不做直接break。B正式持锁,C继续等待。

这个流程里最值得注意的位置,是B获取成功后调用的那次doReleaseShared。它多了一次队首检查,这次检查看起来很浪费时间,但在高并发场景下正是它保证了传播不断链。而且注意,releaseShared返回true之后,A线程就彻底退出同步器了,后续C的唤醒完全由B的“顺手”行为负责。

4.2 多线程并发释放与获取时的交错

再看一个复杂的交错场景,用来理解why continue和PROPAGATE分支。

假设Semaphore许可数为2,线程A持有一个许可,线程A2也持有一个许可,队列里B、C在等待,此时许可数为0,队首节点waitStatus为SIGNAL。

线程A释放进入doReleaseShared,读到SIGNAL,CAS成0,unpark B。与此同时,线程A2也释放进入doReleaseShared,它读到队首waitStatus已经变成0,于是走第二个分支:CAS从0改成PROPAGATE成功。这里的关键是,A2其实也想干点什么,但B还没被唤醒、还没有成为新队首,A2无法直接unpark B后面的C,因为它不知道B是否会成功拿到许可、B会不会接管队首。于是它选择打标记,把责任留给B。

假如A2的CAS失败了呢?比如它读到队首状态已经从0变成了SIGNAL。这说明在窗口期里,有个新来的线程D成功入队并把头节点状态设成SIGNAL,意思是需要唤醒。A2读到这个变化后,continue回到循环开头,重新读head。如果此时head已经变成了D之前的某个节点,它可能再走一遍SIGNAL分支。总之,CAS失败就意味着局势变了,重来永远比顺着旧判断执行下去更安全。

再补一种情况:如果A2读到的head恰好等于tail,说明队列里只有头节点没有任何等待者,直接就走到h == head的判断,break退出。这种情况下资源足够、没人等待,释放方完全没有唤醒义务,打标记的必要都没有。

4.3 周边方法串讲:shouldParkAfterFailedAcquire与unparkSuccessor

读完doReleaseShared,建议把守着队列的两个门神也一起看了:入队挂起前的shouldParkAfterFailedAcquire,和出队唤醒时的unparkSuccessor。

shouldParkAfterFailedAcquire做的事很单纯:当前线程获取失败,准备park之前,先检查前驱节点的waitStatus。如果前驱是SIGNAL,说明前驱承诺会唤醒自己,可以安心park;如果前驱是0,就把前驱改成SIGNAL再park;如果前驱是CANCELLED,就往前跳过取消节点。这个方法在doAcquireShared和独占模式的doAcquire里都有,是AQS队列机制的地基。

unparkSuccessor就更直接了:找到节点的后继,如果是CANCELLED状态就一路向后找第一个有效节点,然后LockSupport.unpark它的线程。注意一个细节:当后继节点的waitStatus小于0时,unparkSuccessor会先把它的状态改成0。把唤醒前状态清零这个动作和doReleaseShared里CAS成0的做法如出一辙,都是在告诉并发环境“我准备动这个节点了,其他人别重复动手”。

把三个方法放在一起看,AQS的等待队列就像一个接力赛:shouldParkAfterFailedAcquire负责在起跑前跟前一棒选手约定信号,doReleaseShared负责关键时刻发出起跑信号,setHeadAndPropagate负责把接力棒传给下一棒。任何一棒断了,等待队列都会出事。

5. 源码阅读与线上排查的实战经验

5.1 带着场景读源码的三个技巧

源码不是拿来背的,是拿来对质的。我读AQS这些方法时,试过很多笨办法,最后沉淀出三个有效的:

第一个技巧是“单线程走查”。把方法当作单线程代码,不带并发脑洞,先保证每一步的逻辑在无竞争情况下是自洽的。doReleaseShared里单线程走查很简单:队首SIGNAL就唤醒、改状态、退出;队首0就标记PROPAGATE、退出。先建立这个基线,再去想并发。

第二个技巧是“两个线程对撞”。每读一个CAS都要问自己:如果此刻另一个线程刚好把这里的状态改了,我接下来走哪条路?CAS失败后的continue和重查head,就是给这些对撞场景留的后路。这一步会在脑内模拟大量线程交错,比较累,但恰恰是AQS正确性的真正所在。

第三个技巧是“对着工具看状态”。用IDE的调试器在doReleaseShared入口打断点,在高并发测试里跑,观察head、tail、waitStatus各个瞬间的值。配合一个打印工具类,把每次CAS前后状态打出来,你很快能把源码里的抽象概念落到具体数字上。比如看到waitStatus从0变成-3,再去看setHeadAndPropagate里的分支,一切都通了。

5.2 不同JDK版本的行为差异

doReleaseShared在不同JDK版本里的演进,很容易踩坑。JDK8里的实现已经比较成熟,PROPAGATE分支和setHeadAndPropagate配合完好。但在更早的JDK版本里,你可能会看到不一样的写法,尤其是丢失唤醒问题修复前后的代码,差异非常明显。

如果拿早期版本的实现去对比,你会发现那时的doReleaseShared里没有PROPAGATE分支,setHeadAndPropagate里对waitStatus小于0的检查也没有今天这么完整。读旧版源码时如果没意识到这是个历史版本,很容易被带偏,以为AQS的设计存在某些明显的漏洞,其实是版本差异。

另外需要注意的是,JDK8之后虽然doReleaseShared本体的逻辑没有大变,但AQS在JDK9以后引入了VarHandle替代了原来的sun.misc.Unsafe操作,源码的可读性反而变差了。如果你看的是JDK11或17的源码,看到的一堆VarHandle.compareAndSet调用,不要觉得陌生,语义和JDK8里的compareAndSetWaitStatus完全一样,只是底层实现换了壳。所以想学AQS,JDK8源码依然是最佳的入门教材。

5.3 需要特别关注doReleaseShared的排查点

回到工程实战。你在什么情况下会真正关心doReleaseShared?我碰到最多的是三类问题。

第一类是“共享锁线程卡死”。比如用CountDownLatch,await的线程迟迟不醒。排查时先抓线程栈,如果看到大量线程停在LockSupport.park,再往上翻,看是谁持有资源、谁负责唤醒。doReleaseShared本身不会主动出错,但它是排查重点的检查点:可以借助JFR或者Arthas看等方法,观察等待队列中节点的waitStatus分布。如果CANCELLED节点过多,说明有线程拿不到资源后取消排队,这会影响唤醒的传播效率。

第二类是“共享锁性能陡降”。在极端情况下,如果经常出现等待队列很长、唤醒频繁在多个线程间交接的场景,doReleaseShared的自旋循环会反复执行。正常情况下自旋次数很少,但如果发现这段代码成为热点,往往意味着锁竞争太激烈、临界区太小,或者线程轮询策略不合理。这时候别急着优化AQS,先优化业务侧的锁粒度。

第三类是“读写锁读线程饥饿”。ReadWriteLock的读锁是共享模式,恰好踩中doReleaseShared的传播机制。当写锁频繁进入队列时,读锁的传播可能被写锁请求阻塞。很多人不理解为什么读锁不是一口气把所有读线程唤醒,看了setHeadAndPropagate里对后继节点必须isShared的判断就明白了:队列里一旦出现写锁(独占节点),传播就停了,这是刻意的优先级设计。排查这类问题,从doReleaseShared入手能少走很多弯路。

最后再分享一个读这段源码的体会:doReleaseShared本身不生产许可、不管理状态机,它只是把“有资源释放了”这件事,安全、不漏地传递给了队列里真正需要它的人。这种设计在并发世界里有一个朴素的道理:重要的往往不是谁发了信号,而是信号在传递过程中不丢失。理解了这一点,再看AQS其他部分会觉得顺滑很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询