☰
AQS共享锁唤醒传播机制:setHeadAndPropagate与PROPAGATE深度解析
2026/10/16 5:28:55 网站建设 项目流程

做并发 Bug 排查,绕不开 AQS 这套基础设施。前几天我调一个读写锁导致的读线程集体阻塞问题时,把 JDK8 里setHeadAndPropagate的源码翻来覆去看了好几遍,最后发现真正让我卡住的不是锁的公平性,而是共享锁唤醒的传播逻辑。这篇文章就顺着setHeadAndPropagate往下拆,讲清楚它和doReleaseShared、PROPAGATE状态之间怎么配合,也把我排查过程中的一些判断方法一起放进来。适合正在精读 AQS、或者被共享锁并发问题折磨过的同学。

1. 纵观全局:setHeadAndPropagate 在 AQS 锁流程里的位置

1.1 一条链路回顾:AQS 的等待队列是怎么工作的

AQS 的核心套路可以压缩成一句话:拿不到资源就排队,拿到资源就尝试叫醒后面的人。共享锁的入口是acquireShared:

public final void acquireShared(int arg) { if (tryAcquireShared(arg) < 0) doAcquireShared(arg); }

tryAcquireShared返回负数表示失败,当前线程需要进入等待队列;返回 0 或正数表示成功。这里的“正数”在不同实现里含义不完全一样,可能是剩余资源数,也可能只是一个“成功”标记,但 AQS 只关心正负。

一旦失败,就会进入doAcquireShared把当前线程包装成共享节点挂到队尾,然后自旋检查自己的前驱是否 head。如果前驱是 head,再试一次tryAcquireShared;成功后就调用setHeadAndPropagate把自己设成新的 head,同时尝试唤醒后面的共享节点。这个“唤醒后面的共享节点”就是传播。

1.2 独占比共享多出来的那一件事:传播

看 AQS 源码时,独占锁acquireQueued里拿到锁之后只做一件事:setHead(node)把自己变成 head,然后返回。共享锁多了一个setHeadAndPropagate,区别就在AndPropagate这三个单词上。

独占锁场景下,一个锁同时只能被一个线程持有,释放一次最多只能唤醒一个后继,唤醒多了反而是错的。共享锁不一样,比如ReentrantReadWriteLock的读锁可以多个线程同时持有,Semaphore可以多个 permit 同时存在,CountDownLatch到 0 后所有等待线程都应该被放行。所以当一个共享节点成功获取资源后,它不能只接管 head 就完事,还得看看自己后面是不是还有共享节点需要继续唤醒。

我习惯把setHeadAndPropagate理解成“接力棒交接点”:当前线程拿到资源后,把 head 移到自己身上,再把接力棒传给下一个共享节点;下一个拿到了,再传下去。这就是为什么一次releaseShared不一定是只唤醒一个线程,而是可能沿队列串成一串唤醒。JDK8 里这个方法看起来只有十几行,但每一行都踩过并发竞态的坑。

2. 源码逐行拆解:JDK8 版本 setHeadAndPropagate 的每一行在干什么

2.1 方法签名与调用点:谁在什么条件下调用它

先看 JDK8 中doAcquireShared的关键片段:

private void doAcquireShared(int arg) { final Node node = addWaiter(Node.SHARED); boolean failed = true; try { boolean interrupted = false; for (;;) { final Node p = node.predecessor(); if (p == head) { int r = tryAcquireShared(arg); if (r >= 0) { setHeadAndPropagate(node, r); p.next = null; // help GC if (interrupted) selfInterrupt(); failed = false; return; } } if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt()) interrupted = true; } } finally { if (failed) cancelAcquire(node); } }

调用条件很关键:当前节点的前驱必须是 head,并且当前线程已经成功拿到了共享资源。也就是说,setHeadAndPropagate不是随便一个线程都能调的,它发生在“从队首后继变成新 head”的瞬间。这个时机决定了它能看到很多其他线程看不到的中间状态。

另外注意,调用点里p.next = null是在setHeadAndPropagate返回之后才执行的。也就是说方法执行时,旧 head 的next还指向当前节点,但这不影响判断,因为当前节点已经被setHead设为 head 了。

2.2 局部变量 h:旧 head 与新 head 的两次记录

再看 JDK8 方法体:

private void setHeadAndPropagate(Node node, int propagate) { Node h = head; // Record old head for check below 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(); } }

这里最容易被忽略的是局部变量h被用了两次。第一次赋值发生在setHead(node)之前,拿到的是旧 head;第二次赋值(h = head)发生在setHead(node)之后,拿到的是新 head,也就是传入的node。

为什么要把旧 head 记下来?因为setHead会修改全局的head字段,如果只从head读一次,就分不清看到的到底是旧 head 还是新 head 的状态。代码需要同时检查“旧 head 上有没有未完成的唤醒任务”和“新 head 上有没有刚被并发线程标记的传播需求”。

setHead本身很简单:

private void setHead(Node node) { head = node; node.thread = null; node.prev = null; }

注意这里没有 CAS。能走到这一步的线程已经确认自己是 head 的后继,并且已经成功获取资源,head 的交接是“队列里的唯一候选者”完成的,不是多个线程竞争同一个 head,所以普通赋值就够了。

2.3 第一层 if:四个条件分别捕获什么问题

整个方法的决策都在这一个if里。

先看第一个条件propagate > 0。它是最直观的:调用者传入的r大于 0,说明这次获取之后系统里可能还有剩余资源。最典型的情况是Semaphore的tryAcquireShared返回剩余 permit 数;ReentrantReadWriteLock的读锁tryAcquireShared大多数时候返回 1,也是正数。只要有余量,就应该继续唤醒后面的共享节点。

第二个条件h == null是防御性的。AQS 队列一旦初始化,head 就不会是 null,所以正常路径下这个条件不会触发。但保留它没有坏处,和后面的(h = head) == null形成对称,也让代码在极端状态下更稳。

第三个条件h.waitStatus < 0检查的是旧 head 的状态。旧 head 的waitStatus为负,说明这个节点上还挂着“需要唤醒后继”或者“需要传播”的标记。最常见的负值是SIGNAL(-1)和PROPAGATE(-3)。一个刚刚把当前线程唤醒的旧 head,它的waitStatus通常已经被unparkSuccessor从SIGNALCAS 成 0 了,但如果另一个线程正在并发 release,就可能在我们读取时看到还没清掉的负值,或者又被重新标记成了PROPAGATE。

第四个条件(h = head) == null || h.waitStatus < 0是第二次机会。setHead(node)之后,新 head 是当前节点。如果它的waitStatus也是负的,说明有另一个线程在并发释放资源,并且已经通过doReleaseShared把这个新 head 标记为需要传播。这种情况下,即使propagate == 0、旧 head 状态也是 0,我们仍然不能停下来,必须再走一次doReleaseShared。

2.4 第二个 if:为什么只有共享后继才值得唤醒

第一层 if 通过后,代码取出当前节点的后继:

Node s = node.next; if (s == null || s.isShared()) doReleaseShared();

node.next == null表示当前节点可能是队尾,但并发环境下这个判断不可靠:刚检查完,后面可能就插进来一个新节点。所以即使next == null,也值得调用doReleaseShared,让它在循环里重新确认 head 和 tail 的关系。

如果后继是共享节点,说明唤醒它可以继续传播,调用doReleaseShared是合理的。如果后继是独占节点,比如读写锁队列里读节点后面跟着写节点,就不调用。这不是源码疏忽,而是对公平性和队列语义的尊重:读锁释放后不应该越过排队的写线程去唤醒后面的读线程,否则写线程可能被无限插队。等独占节点拿到锁并释放时,它自然会按独占锁的唤醒逻辑继续。

所以这个方法实际上非常保守:宁可多走一次doReleaseShared造成一次无害的 spurious wakeup,也不放过任何一个可能丢失唤醒的窗口。

3. 真正理解 propagate:doReleaseShared 与 setHeadAndPropagate 的配合

3.1 doReleaseShared 的循环与 CAS 释放

传播不是靠setHeadAndPropagate一个人完成的,真正的唤醒动作在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; } }

这个方法可能由两个地方调用:共享锁释放时调用一次,setHeadAndPropagate里再调用一次。它的循环体逻辑是:看当前 head 是不是队尾,如果不是,说明后面还有等待者;如果 head 状态是SIGNAL,说明有人承诺要唤醒后继,于是 CAS 成 0 并真正 unpark 后继;如果 head 状态是 0,说明没有待唤醒标记,但队列里还有人,那就 CAS 成PROPAGATE,给后面的人留一个“这里可能还需要传播”的记号。

releaseShared本身是这样的:

public final boolean releaseShared(int arg) { if (tryReleaseShared(arg)) { doReleaseShared(); return true; } return false; }

这里没有直接唤醒所有排队线程,只负责“根据当前 head 的状态,最多 unpark 一个后继”。后续的唤醒由拿到资源的线程通过setHeadAndPropagate接力完成。这也是 AQS 共享锁唤醒看起来像“链式反应”的原因。

3.2 为什么需要 PROPAGATE:一个并发时序还原

propagate > 0这个参数看起来已经能表达“继续传播”了,为什么还要引入PROPAGATE状态?

考虑一个读写锁场景:读锁 A 正在被持有,队列里有等待读线程 B 和 C。A 释放读锁时调用doReleaseShared,看到 head 状态是SIGNAL,CAS 成 0,然后 unpark B。此时 B 被唤醒,去执行tryAcquireShared,成功返回 1,进入setHeadAndPropagate(node, 1)。

如果一切正常,propagate > 0会触发后续传播。问题在于,B 读到propagate的那一瞬间,它的返回值不一定能反映并发释放的情况。假设 C 前面已经没有多余资源,B 这次获取刚好是最后一个可用读锁,tryAcquireShared返回的就不是正数,而是 0 或失败信号。这时如果旧 head 的状态已经被刚才的 CAS 清成 0,setHeadAndPropagate的四个条件可能全部不满足,C 就没人唤醒了。

JDK8 的解法就是PROPAGATE:当doReleaseShared发现 head 状态是 0 但队列里还有等待者时,不直接退出,而是把 head 状态 CAS 成PROPAGATE。这样一来,后续拿到资源的线程在setHeadAndPropagate里读取新 head 或旧 head 时,会看到负数状态,于是重新调用doReleaseShared,把该传的接力棒传下去。

这个状态本质上是给“下一次 head 交接”留了一个记号:我现在不 unpark,但你拿到 head 后别忘了检查。

3.3 为什么 doReleaseShared 要反复确认 h == head

doReleaseShared循环末尾有一句if (h == head) break;,看起来很简单,其实是并发正确性的关键。

如果循环开始时记下的h在 CAS 和 unpark 期间没有变化,说明队列 head 没有交接,当前 release 线程可以退出,把后续传播交给已经或即将醒来的线程。但如果 head 变化了,说明有另一个线程已经抢在前面完成了 head 交接,此时不能直接退出,否则可能漏掉新 head 上刚挂上的唤醒需求。要继续循环,对新 head 再做一次同样的判断。

这也是我在排查共享锁问题时最喜欢看的一行:它决定了doReleaseShared到底是一个“一次性动作”还是“循环接力”。很多对 AQS 传播的误解都来自把它当成简单的“unpark 一个后继就结束”。

3.4 release 与 acquire 的互补关系

需要特别强调:doReleaseShared和setHeadAndPropagate不是两条独立的代码路径,而是同一条传播链路上的两个环节。

释放线程负责在队首“点火”,不管当前拿锁的是谁,只要 head 状态允许,就 unpark 一个后继并可能留下PROPAGATE标记。获取线程负责在成为新 head 后“接力”,根据旧 head、新 head、propagate 参数,决定是否再调用一次doReleaseShared。

换句话说,release 只负责唤醒当前队首的等待者,而“让被唤醒者再去唤醒更后面的人”这件事,是靠setHeadAndPropagate完成的。如果没有这个方法,共享锁的公平队列会在一连串并发读请求下退化成“每次只放一个进去”,性能会很差。

4. 特殊边界:PROPAGATE 状态与负数判断的细节

4.1 waitStatus 的五种状态与负数语义

AQS 节点的waitStatus不是随便几个数字,它有一套固定语义。我把最常用的整理成一张表:

状态值含义常见场景
0初始状态节点刚入队
1 (CANCELLED)节点已取消等待超时或被中断
-1 (SIGNAL)后继需要被唤醒前驱承诺 unpark 后继
-2 (CONDITION)节点在条件队列中Condition 等待
-3 (PROPAGATE)共享释放需要继续传播doReleaseShared标记

注意到没有:除了CANCELLED是 1,其他有“待办事项”的状态全是负数。所以代码里大量使用< 0判断,本质上是判断“这个节点上是否还有未完成的唤醒义务”。

setHeadAndPropagate里检查h.waitStatus < 0,并不是只关心PROPAGATE。旧 head 上如果是SIGNAL,说明后面还有一个等待者需要唤醒;如果是PROPAGATE,说明传播需求还没完成;哪怕是理论上的CONDITION,也说明这个节点不能简单当作“无事发生”。负数状态是一个统一的“还有事”信号。

4.2 为什么用小于零而不是等于某个具体值

可能有人问:想判断是否需要传播,直接写h.waitStatus == Node.PROPAGATE不行吗?

不行。setHeadAndPropagate的触发条件不只有PROPAGATE。如果旧 head 还是SIGNAL,当前线程也必须继续调用doReleaseShared,因为SIGNAL表示后继等待者还没被唤醒。如果只判断PROPAGATE,这种场景就漏了。

那为什么不写h.waitStatus != 0?因为CANCELLED是 1,不等于 0,但一个已经取消的节点没有唤醒义务,看到它不该触发传播。用< 0刚好把CANCELLED和 0 排除掉,只留下所有负数的“待办状态”。

更深一层,waitStatus的 CAS 修改是原子的,但读取未必和修改发生在同一时刻。一个节点可能刚刚还是 0,下一秒被并发线程 CAS 成PROPAGATE;也可能刚才是SIGNAL,被unparkSuccessorCAS 成 0。用< 0这种宽泛判断,本质上是在竞态下做保守决策:只要有可能还需要干活,就去干一次;多干一次顶多造成一次 spurious wakeup,少干一次可能就是永久阻塞。

4.3 PROPAGATE 存在的必要性

PROPAGATE是 JDK8 里特别值得品的一个状态。它不会被用来直接 unpark 线程,而是作为“唤醒义务的传递标记”存在。

想象一个场景:线程 A 正在释放读锁,线程 B 已经醒过来并准备成为新 head。A 的doReleaseShared发现 head 状态是 0,队列里还有等待者,就把 head 状态改成PROPAGATE。这个动作本身不唤醒任何人,但它保证了 B 在setHeadAndPropagate里读到旧 head 或新 head 的waitStatus < 0,从而再调用一次doReleaseShared。

为什么不能靠propagate > 0完成?因为 B 的tryAcquireShared返回值只能代表“这一刻还有多少资源”,而资源释放可能是多线程并发的。在 B 读返回值之前,A 可能已经释放了资源;在 B 读完返回值之后,C 可能又释放了资源。propagate是一个瞬时快照,不能代替持久的状态标记。PROPAGATE把“这里可能还有传播需求”这个信息留在 head 上,比瞬时返回值可靠得多。

4.4 h == head 的退出条件为什么不能省

doReleaseShared里那句if (h == head) break;也值得单独说。

如果这里不判断 head 是否变化,而是无条件退出,就可能出现这种情况:线程 A 释放资源后,线程 B 立刻获取资源并成为新 head,同时线程 C 在后面等待。A 的doReleaseShared在 B 交接 head 之前已经看过旧 head,旧 head 状态是 0,于是退出;但 B 成为新 head 后,新 head 上可能刚被另一个释放线程标记成PROPAGATE。如果 A 直接退出,这个标记就没人消费了。

反过来,如果无条件循环,性能会很差,而且可能造成无限 unpark。AQS 的选择是“每次循环结束前比较一次 head”,head 没变说明没有新交接,可以安全退出;head 变了就继续检查新 head。这个退出条件本质上是在传播的及时性和开销之间做平衡。

5. 实战复盘:排查一次读线程集体阻塞问题

5.1 现象:读线程全部 WAITING,写锁却没有持锁

我之前排查过一个类似问题:某服务用ReentrantReadWriteLock保护本地缓存,读请求走读锁,缓存刷新走写锁。某天告警显示大量读请求线程卡死,线程 dump 里可以看到它们全部停在LockSupport.park上,等待对象是同一个ReentrantReadWriteLock$NonfairSync。

第一反应是“AQS 是不是丢唤醒”,因为读锁允许并发,正常情况下一个读锁释放后,所有排队的读线程应该被陆续唤醒,而不是集体趴着。但仔细看线程 dump,写锁并没有被持有,state 看起来也不像有写锁占用的样子。这个现象很像传播断了。

5.2 排查路径:把 setHeadAndPropagate 的假设画出来

我开始从源码推演,重点就是setHeadAndPropagate和doReleaseShared的配合。我先把队列状态画在纸上:假设 head 是 H,后继是 R1,再后面是 R2。读锁释放时,doReleaseShared把 H 的waitStatus从SIGNALCAS 成 0,unpark R1。R1 醒来后执行setHeadAndPropagate(R1, r)。这时如果r <= 0,旧 head H 的waitStatus又是 0,新 head R1 的waitStatus也不是负数,R2 就不会被唤醒。

JDK8 靠PROPAGATE堵住了这个洞,所以理论上正常的releaseShared不会导致永久阻塞。既然源码推演没发现问题,那就要怀疑业务代码真的少了一次释放。

排查时我有一个小技巧:不要只看等待线程,要看持有线程。把线程 dump 里所有和这个锁相关的线程找出来,重点看谁还停在 try/finally 之间,或者谁在异常路径上没有执行 unlock。那次最后定位到的是一个缓存刷新任务,在某个异常分支里直接 return 了,没有执行写锁的 finally unlock。读线程全部阻塞不是 AQS 传播失效,而是写锁一直被同一个线程的异常路径占着没放。

5.3 可复现的最小实验与验证思路

如果怀疑传播逻辑本身有问题,我建议写一个最小压力测试验证,而不是靠线上线程 dump 猜。用java.util.concurrent.Semaphore就能复现类似结构:一个 semaphore,若干线程同时 acquire,另一个线程反复 release。核心思路是让并发 release 和 acquire 的时机尽可能交错,然后统计是否有线程永远等不到唤醒。

这种测试不一定能 100% 复现丢失唤醒,因为竞态窗口很小,但可以用来验证“去掉setHeadAndPropagate的传播后,系统是否出现明显饥饿”。真正有用的排查顺序是:先确认 state 和锁的计数对不对,再确认是否所有锁都在 finally 里释放,最后才怀疑 JDK 自身的传播逻辑。

我自己现在读 AQS 源码的习惯是:不孤立地看一个方法,而是把acquireShared、doAcquireShared、releaseShared、doReleaseShared、setHeadAndPropagate放到同一条时间轴上看。setHeadAndPropagate是接力点,不是终点;它负责判断“接班人”是否需要继续跑下一棒。理解这一点之后,大部分共享锁的并发问题都能迅速定位到是业务代码的释放路径问题,而不是 AQS 本身的问题。

在 JDK8 里,setHeadAndPropagate这段源码虽然短,但它把“并发环境下的保守决策”表现得非常典型:宁可多唤醒一次,也不少唤醒一次。读锁、信号量、倒计时门闩这些共享同步器,最终都依赖这个小小的接力逻辑撑起高并发下的公平与吞吐。

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

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

立即咨询