
读AQS源码的时候shouldParkAfterFailedAcquire这个方法一般不会卡住你太久但它里面那个else分支却让很多人反复翻车——为什么前驱节点的waitStatus明明是 0不直接 park非要先 CAS 改成SIGNAL为什么还会出现PROPAGATE-3这种状态它凭什么跟 0 走同一个分支这两个问题如果答不上来说明你对 AQS 的唤醒契约理解还差最后一步。这篇文章就专门拆解shouldParkAfterFailedAcquire的完整逻辑重点把状态为 0 和PROPAGATE的场景彻底讲透。适合已经把acquireQueued源码读过一遍、但卡在状态判断逻辑上的同学也适合准备并发面试、想深入理解同步器底层设计的人。1. 先搞清楚它在整个锁获取路径中的位置shouldParkAfterFailedAcquire不是被外部直接调用的它是acquireQueued自旋循环里的一个“哨兵”。想理解它的行为得先回到acquireQueued这个主战场。1.1 acquireQueued 的自旋循环以独占锁为例线程获取锁失败后会被封装成Node放入同步队列尾部然后进入acquireQueued的for (;;)循环final boolean acquireQueued(final Node node, int arg) { boolean failed true; try { boolean interrupted false; for (;;) { final Node p node.predecessor(); if (p head tryAcquire(arg)) { setHead(node); p.next null; // help GC failed false; return interrupted; } if (shouldParkAfterFailedAcquire(p, node) parkAndCheckInterrupt()) interrupted true; } } finally { if (failed) cancelAcquire(node); } }整个循环就干两件事先尝试获取锁失败后调用shouldParkAfterFailedAcquire判断“能不能睡”。如果这个方法返回true就进入parkAndCheckInterrupt()线程真正阻塞。如果返回false循环立刻再来一轮重新尝试获取。注意这里有个时机问题shouldParkAfterFailedAcquire返回false后下一轮循环还是会先执行tryAcquire。这个设计不是多余的——它其实是在给当前线程一个“反悔窗口”在准备 park 之前如果前驱刚好释放了锁当前线程就能赶在 park 前抢到锁省掉一次阻塞唤醒的开销。1.2 一个容易被跳过的细节setHead 没有清零 waitStatus很多人读到这里会自动脑补setHead(node)之后新的头节点状态应该变成 0 吧实际上源码里并没有这一步private void setHead(Node node) { head node; node.thread null; node.prev null; }setHead只更新head引用、清掉线程和prev指针完全不动waitStatus。新头节点的waitStatus是从哪来的就保持什么值。这在独占锁场景下问题不大因为正常唤醒路径上前驱释放锁时会把SIGNAL改成 0新头节点通常就是 0。但在共享锁场景下新头节点的waitStatus完全可能是PROPAGATE。这个细节很关键。它解释了为什么后面的排队线程在调用shouldParkAfterFailedAcquire时会看到前驱节点的状态是 0 或PROPAGATE。很多人忽略了这个细节导致后面分析状态流转时怎么都想不通。2. waitStatus 的五种状态以及为什么 0 和 PROPAGATE 经常被搞混Node.waitStatus是一个volatile int它记录的是节点当前在队列中的等待状态。AQS 一共定义了五种取值先全部列出来看个全貌。2.1 五种状态速查表状态值常量名含义典型出现位置1CANCELLED节点线程已取消需被清理超时中断后cancelAcquire置位-1SIGNAL前驱释放锁时需要唤醒本节点排队线程 park 前一秒被置上-2CONDITION节点在条件队列中等待只在ConditionObject中出现-3PROPAGATE共享锁释放的传播标记doReleaseShared中置位0初始值无特殊状态等待被赋予语义新节点入队时以及各种中间态这里有两个状态在shouldParkAfterFailedAcquire里走的是同一个分支0 和PROPAGATE。它们的数值不同来源不同但在这个方法里处理方式完全一样。先把各自的语义拆开。2.2 状态 0 的语义无契约的中间态0 是Node初始化时的默认值。当enq把线程封装成节点塞进队列尾部时waitStatus就是 0。换句话说每个排队线程刚进队列的时候都是“裸奔”状态——没有任何信号约定。0 还会在释放锁时出现。unparkSuccessor里如果节点状态是负数会先 CAS 成 0再去唤醒后继private void unparkSuccessor(Node node) { int ws node.waitStatus; if (ws 0) compareAndSetWaitStatus(node, ws, 0); // 找到后继节点并 unpark }所以当一个节点从SIGNAL变成 0意味着“信号已经发出唤醒即将执行”。但它同时也在制造一个新的问题这个节点如果是新头节点它的后继在下一轮自旋中看到的还是 0而不是SIGNAL。这时候后继该不该 park直接 park 的风险太大了——万一这个唤醒信号不是发给自己的呢2.3 PROPAGATE 状态共享锁的传播接力棒PROPAGATE是五个状态里最少见、也最容易被忽略的一个。它只在共享锁释放路径上出现典型代码是doReleaseSharedprivate 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; unparkSuccessor(h); } else if (ws 0 !compareAndSetWaitStatus(h, 0, Node.PROPAGATE)) continue; } if (h head) break; } }注意else if这行当头节点的状态是 0 时doReleaseShared会尝试把它改成PROPAGATE。这是一个非常微妙的操作。它的作用是保证共享锁的释放信号在并发竞争下能够继续向后传播而不是丢在半路上。你可以把PROPAGATE理解成一个接力棒某次共享锁释放时如果发现头节点已经处于“无信号”的 0 状态就给它打上PROPAGATE标记告诉后续准备 park 的线程“这里刚刚有过一次释放你别急着睡再试一次看看能不能直接拿到锁。”3. shouldParkAfterFailedAcquire 源码逐行拆解把辅助信息都摆齐了现在回到主角本身。这个方法在 JDK 8 到 JDK 17 里几乎没有变化下面是完整实现private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) { int ws pred.waitStatus; if (ws Node.SIGNAL) return true; if (ws 0) { do { node.prev pred pred.prev; } while (pred.waitStatus 0); node.prev pred; } else { compareAndSetWaitStatus(pred, ws, Node.SIGNAL); } return false; }代码很短但逻辑密度很高。它接收当前节点的前驱pred和当前节点node通过前驱的状态决定当前线程是继续自旋、清理前驱、还是安全阻塞。整个方法分三个分支挨个看。3.1 分支一看到 SIGNAL直接 park如果pred.waitStatus Node.SIGNAL说明前驱已经承诺等它释放锁时会主动唤醒当前节点。这就是一个已经签订完成的唤醒契约当前线程不需要再做任何额外操作直接返回true让外层进入parkAndCheckInterrupt()。SIGNAL这个状态本身不会凭空出现。正常情况下它是通过下面要讲的第三个分支由当前线程主动 CAS 设置到前驱身上的。一旦设置成功前驱在release时就会看到这个标记并履行唤醒义务。注意SIGNAL 只表示“前驱释放时要唤醒我”并不表示前驱一定会成功释放。如果前驱中途被取消CANCELLED唤醒逻辑会在清理时重新选择有效前驱。所以即使在拿到 SIGNAL 之后当前线程的唤醒保障也不是绝对的只是 AQS 在竞争公平性上做了充分的补偿。3.2 分支二看到 CANCELLED清理前驱链如果pred.waitStatus 0也就是CANCELLED说明前驱节点已经放弃等待了。一个被取消的节点没有资格继续留在当前节点的前驱位置上所以这个分支做的是“跳过所有已取消节点”的动作if (ws 0) { do { node.prev pred pred.prev; } while (pred.waitStatus 0); node.prev pred; }这里是一个do-while循环从当前前驱开始沿着prev指针一直往前找直到找到一个状态不是CANCELLED的节点。然后直接把当前节点的prev指向这个有效节点。这里有个容易被忽视的点这个分支只修改了当前节点的prev指针并没有去动被取消节点的next指针。完整的断链清理动作发生在更后面的cancelAcquire和unparkSuccessor配合过程中。所以这里的操作更像是“当前节点主动绕开障碍”而不是“主动清理垃圾”。这个分支同样返回false让外层继续自旋。为什么要自旋而不是立刻 park因为被跳过的前驱可能已经释放锁了当前节点完全有机会在下一轮直接抢到锁。3.3 分支三看到 0 或 PROPAGATE用 CAS “预约”唤醒最后一个else分支就是本次讨论的核心else { compareAndSetWaitStatus(pred, ws, Node.SIGNAL); }这里的ws是方法开头记录的pred.waitStatus。进入这个分支时ws可能是 0也可能是PROPAGATE理论上还可能是CONDITION但正常同步队列里不会出现CONDITION节点所以实际有效情况就是 0 和PROPAGATE。这段代码做的事情非常明确尝试把前驱节点的状态从 0或PROPAGATE通过 CAS 改成SIGNAL。如果改成功了下一轮自旋时pred.waitStatus SIGNAL当前线程就能安心 park 了。注意这里根本没有检查 CAS 的返回值。失败说明前驱状态在这一瞬间被别的线程改掉了那也直接返回false让外层循环重新竞争一轮。为什么不原地重试因为 CAS 失败意味着状态正在变化与其死等不如立刻回到自旋入口重新判断。这个设计在后面专门讲。4. 深挖为什么状态为 0 或 PROPAGATE 都走同一个 CAS 分支现在回答标题里最核心的问题这两种状态到底什么时候出现它们之间有什么区别既然数值不同为什么 AQS 对它们一视同仁4.1 前驱为 0 的典型场景从入队到稳定排队前驱节点状态为 0 最常见的情况就是“刚入队还没来得及建立唤醒契约”。想象一个场景线程 A 持有锁线程 B 和线程 C 同时尝试获取都失败于是按顺序入队。入队时B 和 C 节点的waitStatus都是 0。线程 C 在acquireQueued第一轮自旋里发现自己的前驱是 BB 的状态是 0。于是 C 执行compareAndSetWaitStatus(B, 0, SIGNAL)把 B 置为SIGNAL。下一轮 C 再进来看到 B 已经是SIGNAL就安心 park 了。第二种常见场景是前驱刚被唤醒过。线程 B 原本是SIGNAL状态等待唤醒线程 A 释放锁时unparkSuccessor先把 B 的状态从SIGNALCAS 成 0再唤醒 B。如果 B 被唤醒后尝试获取锁又失败了比如被一个新来的线程抢先那么 B 再次进入等待之前它的后继 C 看到的 B 的状态就是 0。此时 C 会再次把 B 从 0 CAS 成SIGNAL重新建立契约。所以“前驱为 0”本质上说的是之前那个唤醒契约已经消耗掉了或者还没有建立起来。对于后继节点来说它必须主动把这个契约再补上否则将来前驱释放锁时没有义务唤醒自己。4.2 前驱为 PROPAGATE 的典型场景读锁释放传播现场还原PROPAGATE出现的场景就更具体了基本只发生在共享锁路径上。我用ReentrantReadWriteLock的读锁来还原一次完整过程。假设现在线程 A 持有读锁线程 B、线程 C 在队列里排队等待读锁。同步队列头节点head是某个已经释放的旧节点状态为SIGNAL。流程如下线程 A 释放读锁进入doReleaseShared发现head.waitStatus SIGNALCAS 成 0然后unparkSuccessor唤醒 B。线程 B 被唤醒在doAcquireShared的自旋循环中尝试获取读锁成功。进入setHeadAndPropagatesetHead(B)把 B 设为新头节点。此时 B 自己的waitStatus是多少是 0——因为 B 在排队期间它的状态一直没被改动过只在被唤醒时前驱的 SIGNAL 被消耗了但 B 自己的状态还是 0。setHeadAndPropagate发现 head 已更新且还有剩余的读锁可传播于是再次调用doReleaseShared。这一次doReleaseShared看到新头节点 B 的状态是 0于是执行compareAndSetWaitStatus(B, 0, PROPAGATE)把 B 标记成PROPAGATE。线程 C 在自旋中尝试获取读锁失败。调用shouldParkAfterFailedAcquire(pred B, node C)发现pred.waitStatus是PROPAGATE于是进入 else 分支CAS 把 B 从PROPAGATE改成SIGNAL。你看C 看到前驱是PROPAGATE本质上是因为共享释放的传播动作刚在头节点身上发生。和 0 一样对 C 来说它看到的都是一个尚未许诺唤醒的节点都需要自己主动补上SIGNAL。4.3 两种状态的本质共同点唤醒契约尚未确立从语义上看0 和PROPAGATE差别挺大0 是中性的“尚未发生任何事”PROPAGATE是“共享释放传播正在进行”。但从shouldParkAfterFailedAcquire的角度看它们完全等价都不是SIGNAL意味着“没有人为我押上唤醒承诺”都是负数或 0意味着“前驱没有取消还有继续合作的可能”都需要当前线程主动把前驱状态改成SIGNAL才能换取未来的唤醒保障。这就是为什么它们会合并在同一个else分支里。shouldParkAfterFailedAcquire只看面前有没有一纸契约有SIGNAL就放心睡契约撕毁了CANCELLED就绕道走没有契约或契约还在路上0 / PROPAGATE就主动把契约签好再睡。5. 从状态 0 到 PROPAGATE 再到 SIGNAL 的完整流转演练光看源码定义还不够我们直接推演整个状态流转过程把可能出现状态的路径全部走一遍。这样再回去看源码就会觉得每一条分支都顺理成章。5.1 独占锁场景最标准的状态流转以ReentrantLock为例线程 B 排队等待锁阶段等待状态说明1. 入队B.waitStatus 0enq初始化时默认值2. 第一轮竞争失败前驱被 CAS 设为 SIGNALB 在shouldParkAfterFailedAcquire里改前驱状态3. 第二轮竞争失败B 看到前驱 SIGNAL返回 trueB 正式 park4. 前驱释放锁前驱 SIGNAL → 0unparkSuccessor消耗信号并唤醒 B5. B 获取锁成功B 成为新 head状态不变通常为 0setHead(B)不清零但唤醒路径早已将 B 置为 06. 后继线程入队看到新 head 状态为 0后继 CAS 把 head 改成 SIGNAL重新建立契约这套流程里状态 0 承担了“契约已消耗等待重建”的中间角色。5.2 共享锁场景PROPAGATE 的完整生命周期以读锁为例重点观察PROPAGATE的产生和消耗阶段操作状态变化1. 读锁持有者释放doReleaseShared看到 headSIGNALhead: SIGNAL → 02. 唤醒后继 BunparkSuccessor(B)B 被唤醒准备竞争3. B 获取读锁成功setHeadAndPropagate将 B 设为 headB.waitStatus 保持 04. 继续传播释放信号doReleaseShared看到 head0head: 0 → PROPAGATE5. 后继 C 竞争失败shouldParkAfterFailedAcquire看到前驱PROPAGATE前驱: PROPAGATE → SIGNAL6. C 下一轮确认前驱SIGNALC 安心 park状态稳定注意第 4 步很关键head变成PROPAGATE并不意味着一定要唤醒某个线程它更像是一个“传播过程中留下的痕迹”。读到这个痕迹的后继线程会自己判断是否有机会获取锁如果失败就把PROPAGATE转成SIGNAL让传播信号变成具体某个线程的唤醒契约。5.3 CAS 失败的意义状态已经变了别盲目 park前面提到else分支里 CAS 失败也不管直接返回false。这个行为值得多说两句。CAS 失败意味着pred.waitStatus已经被其他线程改掉了。可能性主要有三种前驱释放了锁把SIGNAL改成了 0然后去唤醒后继。这种情况下当前线程不必 park应该马上回到自旋顶部尝试抢锁。前驱被取消了状态变成了CANCELLED。那当前线程需要绕开它下一轮会走ws 0的分支清理前驱链。其他线程抢先把它改成了SIGNAL。这相当于契约已签订下一轮就能直接 park。无论哪种情况原地疯狂重试 CAS 都没有意义。返回false让外层循环重新走一遍完整的“尝试获取→检查状态”流程才是最合理的选择。这其实就是 AQS 整体设计思想的缩影能自旋就绝不原地死等每一次状态变化都把主动权交还给竞争逻辑。6. 常见问题与排查技巧实录源码分析完了再讲几个我实际读源码和排查并发问题过程中沉淀下来的知识点。这些问题不是教科书里写的但几乎每个踩过 AQS 坑的人都会遇上。6.1 面试高频问题为什么 CAS 失败后不重试而是返回 false这个问题我在不少技术讨论里看到过很多人的第一反应是“CAS 失败应该继续 CAS 直到成功”。但放到shouldParkAfterFailedAcquire这个场景里原地重试是错的。CAS 失败说明pred.waitStatus发生了变化而每一次状态变化都对应着一个实质性的同步事件可能是锁被释放、可能是前驱被取消、也可能是唤醒信号已被其他线程抢先消费。这时候当前线程最应该做的是重新回到acquireQueued的 for 循环入口再尝试一次tryAcquire。就在这个间隙里锁可能已经被释放当前线程能直接抢到省掉一次 park/unpark 的上下文切换开销。如果原地死循环重试 CAS等于无视这些状态变化背后的同步机会本质上把自旋锁改成了忙等性能和逻辑都会出问题。6.2 排查实战线程一直 WAITING/PARKED 的最常见原因用jstack或者Arthas看线程转储时如果发现大量线程处于WAITING (PARKED)状态且都卡在LockSupport.park附近第一反应先别急着怀疑shouldParkAfterFailedAcquire写错了。最常见的几个原因按概率排序锁确实被某个线程长时间持有。检查获得锁的线程在做什么是不是卡在了 IO 或长时间计算上。等待队列前驱被取消但清理链不完整导致唤醒信号传不到最终节点。这种情况多发生在大量线程超时取消的场景。条件变量的await和signal使用不匹配导致线程在条件队列里永远等不到转移。如果怀疑是第二种可以用Arthas的watch或者直接看AbstractQueuedSynchronizer内部对象里的队列结构检查是否有大段连续CANCELLED状态的节点。6.3 自己动手验证把状态流转表变成实验读源码最容易出现的问题就是“感觉懂了一推演就错”。我的习惯是写一个小 demo模拟共享锁竞争然后对照状态流转表验证。demo 思路很简单开三个线程同时抢读锁让其中一个持有锁较长时间另外两个排队。然后通过watch命令观察Node.waitStatus的变化。比如用Arthaswatch java.util.concurrent.locks.AbstractQueuedSynchronizer shouldParkAfterFailedAcquire {params[0].waitStatus} -n 100如果环境允许更彻底的方式是在本地源码编译的 JDK 里给shouldParkAfterFailedAcquire和doReleaseShared临时加打印日志观察 0、PROPAGATE、SIGNAL三种状态的切换顺序。自己推演的流转表如果和日志完全对得上说明理解到位了。注意生产环境千万不要做这种改动。要玩源码就在自己的演示工程或者本地编译的 JDK 里玩线上观察状态用jstack和Arthas就够了。我个人读 AQS 源码最大的体会是不要试图一次性把每个方法都背下来先把“唤醒契约”这条主线抓住。0 和PROPAGATE本质都是“契约未建立”SIGNAL是“契约已建立”CANCELLED是“契约作废”。这条主线一旦通了shouldParkAfterFailedAcquire这三个分支的动机就全清楚了连带着doReleaseShared和setHeadAndPropagate里那些微妙的状态判断也不再神秘。AQS 的注释写得非常细强烈建议你打开源码把Node类每个状态前面的英文注释逐字读一遍再回来对照这篇文章的流转表收获会比我直接给你结论大得多。