做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是1SIGNAL是-1CONDITION是-2PROPAGATE是-30表示初始状态。每个节点在等待时会把自己的前驱节点的waitStatus设置成SIGNAL意思是“兄弟你被唤醒的时候记得顺手unpark我一下”。这个约定在shouldParkAfterFailedAcquire里完成。所以在doReleaseShared里如果读到队首head的waitStatus是SIGNAL说明队首后面一定有一个等待线程需要被唤醒。接下来的操作是先把head的waitStatus从SIGNAL改成0然后再去unpark后继线程。这里有个关键点为什么要先CAS改成0再unpark因为这是一个“占用标记”的语义。多个线程可能同时进入doReleaseShared如果大家都读到SIGNAL然后都去unpark同一个后继节点虽然LockSupport.unpark可以重复执行没有严重后果但会让同一个被唤醒线程被打扰多次而且错误地消耗了本应该留给其他节点的唤醒机会。先CAS成0抢到“唤醒权”只有抢到的人才能去unparkCAS失败的线程直接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背后的血泪史一个真实并发bug3.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成0unpark线程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把头节点状态改成0unpark线程B。循环末尾检查h还是headbreak退出。第四阶段线程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读到SIGNALCAS成0unpark 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完全一样只是底层实现换了壳。所以想学AQSJDK8源码依然是最佳的入门教材。5.3 需要特别关注doReleaseShared的排查点回到工程实战。你在什么情况下会真正关心doReleaseShared我碰到最多的是三类问题。第一类是“共享锁线程卡死”。比如用CountDownLatchawait的线程迟迟不醒。排查时先抓线程栈如果看到大量线程停在LockSupport.park再往上翻看是谁持有资源、谁负责唤醒。doReleaseShared本身不会主动出错但它是排查重点的检查点可以借助JFR或者Arthas看等方法观察等待队列中节点的waitStatus分布。如果CANCELLED节点过多说明有线程拿不到资源后取消排队这会影响唤醒的传播效率。第二类是“共享锁性能陡降”。在极端情况下如果经常出现等待队列很长、唤醒频繁在多个线程间交接的场景doReleaseShared的自旋循环会反复执行。正常情况下自旋次数很少但如果发现这段代码成为热点往往意味着锁竞争太激烈、临界区太小或者线程轮询策略不合理。这时候别急着优化AQS先优化业务侧的锁粒度。第三类是“读写锁读线程饥饿”。ReadWriteLock的读锁是共享模式恰好踩中doReleaseShared的传播机制。当写锁频繁进入队列时读锁的传播可能被写锁请求阻塞。很多人不理解为什么读锁不是一口气把所有读线程唤醒看了setHeadAndPropagate里对后继节点必须isShared的判断就明白了队列里一旦出现写锁独占节点传播就停了这是刻意的优先级设计。排查这类问题从doReleaseShared入手能少走很多弯路。最后再分享一个读这段源码的体会doReleaseShared本身不生产许可、不管理状态机它只是把“有资源释放了”这件事安全、不漏地传递给了队列里真正需要它的人。这种设计在并发世界里有一个朴素的道理重要的往往不是谁发了信号而是信号在传递过程中不丢失。理解了这一点再看AQS其他部分会觉得顺滑很多。