资讯动态

AQS源码解析:shouldParkAfterFailedAcquire中waitStatus的0与PROPAGATE

发布时间:2026/10/14 20:02:14 来源:尧图企业网站定制
读AQS源码很多人会卡在shouldParkAfterFailedAcquire这个方法上。名字长逻辑又绕着waitStatus转尤其是看到状态为0和PROPAGATE的时候总想问一句这俩状态到底从哪冒出来的直接返回true不行吗这篇文章专门围绕shouldParkAfterFailedAcquire做一次源码级拆解重点就是状态为0和PROPAGATE这两种情况。如果你已经知道AQS里有一个先进先出的同步队列但还没把节点状态流转完全串起来这篇正好补上这一段。1. 从acquireQueued的调用现场说起它为什么必须返回false重试1.1 acquireQueued里的for循环才是理解这段代码的正确容器shouldParkAfterFailedAcquire从来都不是单独运行的。在AQS的acquireQueued方法里它被放进一个for(;;)循环中每次tryAcquire失败后都会被调用。先看这个经典片段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; failed false; return interrupted; } if (shouldParkAfterFailedAcquire(p, node) parkAndCheckInterrupt()) interrupted true; } } finally { if (failed) cancelAcquire(node); } }注意第11行shouldParkAfterFailedAcquire(p, node)的返回值被直接当作是否需要park的判断条件。返回true线程就会进入parkAndCheckInterrupt()真正阻塞返回false则立刻进入for循环的下一轮重新执行tryAcquire。很多人刚看这段时会觉得奇怪既然方法名叫shouldParkAfterFailedAcquire为什么还会返回false其实这个false不是失败的意思而是告诉调用者现在还不能睡前驱节点还没准备好唤醒信号你再去抢一次锁或者再校准一次状态。这就像过马路前被拉了一下不是让你停在那儿而是让你再看一眼左右车流。1.2 想睡安稳觉必须先让前驱进入SIGNAL状态那么准备睡觉的条件到底是什么答案就藏在Node.waitStatus这个字段里。AQS同步队列里的每个节点都有waitStatus它有几个取值值常量含义1CANCELLED节点已取消需要从队列中清理-1SIGNAL当前节点释放锁后需要唤醒后继节点-2CONDITION节点在条件队列中等待-3PROPAGATE共享模式下需要继续传播释放信号0无默认初始状态没有特殊约定当一个线程想要park时它必须保证自己的前驱节点waitStatus是SIGNAL而不是0或PROPAGATE等其他状态。为什么因为SIGNAL代表一份契约前驱节点在释放锁或取消时会负责唤醒后继节点。如果前驱是CANCELLED它自身都放弃了指望它唤醒你显然不现实如果前驱是0说明这个契约还没有建立如果前驱是PROPAGATE说明前驱处于共享信号的传播路径上但也还没有给你一个确定的唤醒承诺。shouldParkAfterFailedAcquire要做的就是在park之前把前驱节点的状态校准到SIGNAL。把这件事想清楚后面看0和PROPAGATE的处理就顺了。2. 状态0不是什么都没有而是尚未进入戒备状态2.1 新建节点的waitStatus默认就是0但这只是还没人预约唤醒每个通过addWaiter新创建的节点waitStatus默认都是0。这个0并不代表这个节点不需要唤醒它只是代表还没有人替这个节点预约过唤醒信号。更准确地说一个节点的waitStatus通常不是由它自己设置的而是由它的后继节点设置的。这是一个非常容易搞反的点当前线程能否安全park取决于前驱节点有没有对自己这一侧挂出SIGNAL所以代码操作的是pred.waitStatus而不是node.waitStatus。假设有一个刚入队的节点A它现在是队列尾部waitStatus0。此时节点B入队排在A后面B发现自己的前驱A是0就会通过CAS把A的waitStatus从0改成SIGNAL。这个动作的意思是A我B要睡了等你释放锁的时候记得叫我。只有A处于SIGNAL状态B才能放心地去park。如果把A的waitStatus比作一个门铃状态0是门铃还没装SIGNAL是门铃已经装好。后继节点B的职责就是睡觉前帮前驱A把门铃装上然后按一下告诉A我在这等你叫醒。2.2 为什么用CAS把0改成SIGNAL而不是直接赋值看代码的时候有人会疑惑既然要把pred从0改成SIGNAL直接pred.waitStatus Node.SIGNAL不就行了为什么非要走compareAndSetWaitStatus(pred, ws, Node.SIGNAL)这里的关键是同步队列里存在并发竞争。最常见的竞争场景是当前节点的前驱恰好是head节点。head节点是整个队列的入口释放锁的线程会在doReleaseShared里实时修改head.waitStatus比如从SIGNAL改成0或者从0改成PROPAGATE。如果当前线程直接赋值可能会覆盖掉其他线程刚刚写入的状态导致信号丢失。CAS保证了原子性只有当pred.waitStatus仍然是我读到的那个值时我才把它改成SIGNAL如果在我读和改之间状态已经被别人改成PROPAGATE或SIGNALCAS就会失败。失败之后shouldParkAfterFailedAcquire返回false外层for循环再转一圈重新读取pred.waitStatus再做一轮判断。这个读状态-CAS改状态-可能失败-重试的路径就是AQS里很典型的自旋收敛过程。所以当ws 0时实际发生的是} else { compareAndSetWaitStatus(pred, ws, Node.SIGNAL); } return false;无论CAS成功还是失败方法都返回false。如果成功下一轮for循环会看到pred.waitStatus已经是SIGNAL然后真正park如果失败下一轮会看到更新的状态可能是SIGNAL也可能是PROPAGATE再走对应的分支。用一次额外的循环来确认状态比在CAS成功后直接返回true要稳妥得多。3. PROPAGATE这个特殊状态到底从哪来、为什么会在前驱节点上出现3.1 doReleaseShared里的0转PROPAGATE释放锁与抢锁竞态下的信号接力棒PROPAGATE这个名字听起来很抽象我第一次读的时候也琢磨了很久。它主要由doReleaseShared方法产生而doReleaseShared通常出现在共享模式的锁释放路径上比如Semaphore、CountDownLatch、读锁这类场景。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; unparkSuccessor(h); } else if (ws 0 !compareAndSetWaitStatus(h, 0, Node.PROPAGATE)) continue; } if (h head) break; } }解读一下第二种情况当head.waitStatus是0时说明某个后继线程已经被unparkSuccessor唤醒了或者正准备被唤醒head的SIGNAL被清除成了0。但head后面还有其他等待者释放信号还需要继续向下传播。如果此时doReleaseShared什么都不做直接退出就可能出现一个窗口期新进来的acquire线程看到head是0认为没有唤醒义务于是准备自行park但park后可能永远没人唤醒。为了解决这个窗口期的问题设计者让释放线程把head从0 CAS成PROPAGATE。PROPAGATE是一个负状态它向所有路过的人传递一条信息当前还有共享释放信号在传播中别轻易停下。这个状态会在后续的setHeadAndPropagate里被检查只要发现旧head或新head的waitStatus小于0就会再次调用doReleaseShared把唤醒信号继续传给后面的节点。正因为如此PROPAGATE几乎只在共享模式里出现。独占锁的release走的是unparkSuccessor路径不会主动把0改成PROPAGATE。但AQS的shouldParkAfterFailedAcquire是独占和共享共用的一段公共代码所以它必须能处理PROPAGATE这个状态不能因为它少见就忽略。3.2 当后继线程看到前驱是PROPAGATE为什么还要把它改成SIGNAL现在进入正题如果一个线程在shouldParkAfterFailedAcquire里读到的pred.waitStatus是PROPAGATE会发生什么直接看代码的分支逻辑PROPAGATE不是SIGNAL也不是大于0的CANCELLED所以它会落入最后的else分支也就是和状态0一样走compareAndSetWaitStatus(pred, ws, Node.SIGNAL)尝试把-3改成-1然后返回false。这里很多人会冒出疑问PROPAGATE明明也代表需要传播信号为什么还要被改回SIGNAL这不是把共享模式的传播链打断了吗其实不会。我推演一个典型的共享锁场景你就明白了线程A持有共享锁线程B排在head之后等待获取。A释放共享锁doReleaseShared发现head.waitStatus是SIGNAL把它CAS成0然后unparkSuccessor唤醒B。此时另一个线程C也释放了一把共享锁doReleaseShared发现head.waitStatus已经是0按逻辑会把它CAS成PROPAGATE。线程B被唤醒后抢锁失败比如权限被C抢走了进入shouldParkAfterFailedAcquire发现自己的前驱head.waitStatus是PROPAGATE。B进入else分支CAS把head从PROPAGATE改回SIGNAL然后返回false。B进入for循环下一轮再次尝试抢锁。如果还是失败再调用shouldParkAfterFailedAcquire此时看到head是SIGNAL就可以安心park了。在这个过程里PROPAGATE并没有被浪费。当B把head从PROPAGATE改成SIGNAL后如果B之后成功抢到锁setHeadAndPropagate会检查旧head的waitStatus是否小于0SIGNAL是-1小于0所以照样会触发doReleaseShared继续把信号往后传。如果B一直抢不到锁而需要park那么它需要的是一个明确的唤醒承诺SIGNAL正好提供这个承诺。一句话总结PROPAGATE关心的是信号不要断SIGNAL关心的是睡下去一定要有人叫醒。当你要从抢锁失败切换到可能要睡一会儿时优先把自己这个后继者的唤醒预约落定把它从-3改成-1是合理的。4. 状态为0和PROPAGATE的完整分支行为源码逐行走一遍4.1 三路分支速查表SIGNAL、CANCELLED、其他把整个方法拆开逻辑其实只有三件事前驱waitStatus含义shouldParkAfterFailedAcquire行为-1 (SIGNAL)前驱已承诺唤醒直接返回true允许当前线程park0 (CANCELLED)前驱已取消向前跳过所有取消节点返回false重试0且不是SIGNAL0、PROPAGATE等状态尚未校准或处于传播中CAS改成SIGNAL返回false重试0和PROPAGATE就落在最后一行。它们虽然来源不同但处理方法一致先尝试把前驱改成SIGNAL然后让外层循环再做一次确认。区别只是触发场景不同0更多发生在刚入队的新节点或刚被唤醒的head上PROPAGATE则发生在共享模式信号传播尚未结束的head上。4.2 一段被忽略的代码compareAndSetWaitStatus的结果为什么没有检查再看一遍完整源码这次别忘了最后那行return falseprivate 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); pred.next node; } else { compareAndSetWaitStatus(pred, ws, Node.SIGNAL); } return false; }细心的读者会发现else分支里的CAS结果被直接忽略了没有用if包一层。这看起来很不严谨但其实是故意的。因为无论CAS成功还是失败方法都需要返回false让acquireQueued的for循环转一圈。如果CAS成功下一轮for循环再进来时pred.waitStatus已经变成SIGNALshouldParkAfterFailedAcquire会直接返回true线程park。如果CAS失败说明pred.waitStatus在我读到之后已经被别人改动了可能是被另一个线程改成了0、SIGNAL或PROPAGATE。下一轮for循环会读取这个新值再走对应分支。所以CAS结果不重要重要的是这次没有直接睡再确认一次。这种先改状态确认后再睡的两步走设计正是AQS避免丢失唤醒信号的经典手法。很多并发bug都是因为以为自己写了唤醒状态其实写错了地方或者被覆盖了AQS用一轮额外的自旋把这种风险降到最低。另外注意当ws 0时代码会做一个链式清理do { node.prev pred pred.prev; } while (pred.waitStatus 0); pred.next node;这会把当前节点往前拨跳过所有已经取消的前驱直接挂到最后一个非取消节点后面。这是一个不常被提到的细节AQS在park前不仅要设置SIGNAL还要顺手整理队列防止大量CANCELLED节点积压在队首。5. 读这段源码最容易被绕进去的三个地方5.1 状态对象搞反要盯的是pred.waitStatus不是node.waitStatus这是初学者最容易犯的错。很多人以为我要睡所以要把自己的waitStatus改成SIGNAL然后盯着node.waitStatus看了半天怎么都不对。实际上当前线程能不能睡着由前驱节点决定而当前节点自己的waitStatus是留给它的后继节点去设置的。你可以这样记waitStatus的SIGNAL是一个向下游负责的状态。节点A的waitStatus如果是SIGNAL代表A愿意在释放时唤醒它的后继B。设置这个状态的人恰恰是B。所以shouldParkAfterFailedAcquire里的主宾关系是当前线程node看到前驱pred修改pred的状态。方向别搞反否则整个方法都会看不懂。5.2 别在独占锁里死等PROPAGATE共享模式才是它的主场如果你是在看ReentrantLock的加锁解锁路径大概率很难遇到前驱waitStatus为PROPAGATE的情况。这是因为PROPAGATE由doReleaseShared产生而doReleaseShared主要被releaseShared调用也就是Semaphore、CountDownLatch、读写锁的共享读锁这类场景。如果你发现自己调试一个独占锁程序时shouldParkAfterFailedAcquire里总读不到PROPAGATE这并不奇怪。不要把没有看到PROPAGATE当成异常。反过来如果在读锁或信号量的demo里频繁看到-3也别慌那是共享传播机制在工作。理解了它来自doReleaseShared的0转PROPAGATE就不会再觉得这个状态神出鬼没了。5.3 调试建议打印waitStatus观察0到SIGNAL再到0或PROPAGATE的循环我调试AQS时有一个很笨但很有效的方法在shouldParkAfterFailedAcquire入口打一份日志记录当前线程、pred.waitStatus以及返回值。不需要打完整项目只要写一个很小的自定义同步器或者直接用共享锁Demo起四五个线程反复acquire和release就能观察到状态流转System.out.printf(%s pred ws%d signal%s%n, Thread.currentThread().getName(), pred.waitStatus, shouldParkAfterFailedAcquire(pred, node));多跑几轮你会看到很多重复的模式0被改成-1-1被改成00被改成-3-3又被改成-1。这些状态并不神秘它们只是在不同的时机承担不同的任务。等你见过几次日志里ws0和ws-3的转换就能真正理解为什么shouldParkAfterFailedAcquire要同时处理这两种状态而不是只盯着SIGNAL。我的建议是调试时一定用常量名Node.SIGNAL、Node.PROPAGATE别直接看魔法数字。AQS里1、-1、-2、-3这几个值只有一字之差看错一次就够你纠结半天了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑