Java AQS的propagate机制深入剖析
作者:wuminyu
前言
本文旨在记录近期研读Java源码的学习心得与疑难问题。由于个人理解水平有限,文中内容难免存在疏漏,恳请读者不吝指正。
AQS的propagate机制剖析
在基基于 Java 并发包(java.util.concurrent)的低延迟、高并发系统设计中,AbstractQueuedSynchronizer(AQS)是整个 JUC 体系的核心基石。AQS 内部维护着一个基于自旋与阻塞机制的双向变体 CLH 队列(同步队列),用以管理争用资源的线程。
在 AQS 中,存在两种资源共享范式:独占模式(Exclusive)与共享模式(Shared)。而 Node.PROPAGATE(状态值 -3)是专为共享模式引入的一个高度复杂的控制信号。它的引入是为了修复早期 JDK 中由于并发释放资源而引发的双向队列线程永久挂起漏洞(Bug ID: JDK-6801020)。
以下将从底层系统工程的角度,结合 OpenJDK 8源码对 PROPAGATE 的信号传播原理、无锁状态机演转以及解决的隐蔽死锁问题进行深度剖析。
一、 AQS 队列模型的双向链表与共享模式基石
AQS 内部的同步队列是一个双向链表,head 节点是一个虚拟节点(Dummy Node),不存储实际的等待线程,仅作为队列的头部标识,而实际等待的线程则封装在后续的 Node 节点中。
- 独占模式:同一时刻只允许一个线程持有同步状态(如
ReentrantLock)。当持有者释放状态时,只需单纯地通过unparkSuccessor(head)唤醒其直接后继节点(head.next)。 - 共享模式:允许多个线程同时获取同步状态(如
CountDownLatch、Semaphore)。这意味着,当一个节点被成功唤醒并传播(Propagate)获取到共享资源后,它不仅要激活自己,还需要级联地(Cascading)通知后续的共享模式节点,让它们尝试获取资源,从而在多核 CPU 上实现并发吞吐。
二、 经典的 JDK-6801020 挂起漏洞:为什么需要 PROPAGATE
在早期 JDK 版本(JDK 6 及以前)中,并没有 PROPAGATE 状态。当时的共享模式释放与唤醒逻辑极其精简。为了理解 PROPAGATE 的不可或缺性,我们先复现一下旧版本 AQS 在高并发环境下导致线程永久挂起的“吞信号”边界场景。
旧版 AQS 的核心逻辑伪代码
// 旧版 setHeadAndPropagate
private void setHeadAndPropagate(Node node, int propagate) {
setHead(node);
if (propagate > 0) { // 只有当剩余资源明确大于 0 时,才唤醒后继
Node s = node.next;
if (s == null || s.isShared())
doReleaseShared();
}
}
// 旧版 doReleaseShared (简化版)
private void doReleaseShared() {
Node h = head;
if (h != null && h.waitStatus == Node.SIGNAL) {
compareAndSetWaitStatus(h, Node.SIGNAL, 0);
unparkSuccessor(h);
}
}
导致挂起的并发流水线时序图
假设当前 Semaphore 的可用许可(Permits)为 0,队列结构为:Head -> ThreadA(Shared) -> ThreadB(Shared)。此时 Head.waitStatus == Node.SIGNAL(-1)。
- T1 线程(ThreadA):调用
acquireShared被阻塞,进入等待队列。 - T2 线程(外部线程):调用
releaseShared()释放了一个许可,此时系统可用许可变为 1。
- T2 进入
doReleaseShared(),识别到Head.waitStatus == SIGNAL。 - T2 通过 CAS 将
Head.waitStatus改为 0,并调用unparkSuccessor(Head)唤醒了 ThreadA。
- ThreadA 被唤醒:从阻塞中恢复,开始执行
tryAcquireShared()。
- 由于 T2 释放了 1 个许可,ThreadA 成功将其消费,此时
Semaphore的许可再次变为 0。 tryAcquireShared()返回值propagate = 0(表示成功获取资源,但当前已无剩余资源)。
- T3 线程(另一个外部线程并发介入):在 ThreadA 尚未执行
setHeadAndPropagate之前,T3 并发调用了releaseShared()释放了一个许可。
- T3 进入
doReleaseShared(),读取当时的head(注意,此时 ThreadA 还没来得及更新head,旧的head依然有效)。 - 此时旧
head的waitStatus已经在第 2 步中被 T2 改为了 0。 - T3 检查条件:
if (h.waitStatus == Node.SIGNAL)失败;else if (h.waitStatus == 0)… 在旧版代码中,这里什么都不会做,直接跳出循环。T3 释放信号的动作完成,但没有触发任何唤醒!
- **ThreadA 推进到
setHeadAndPropagate(NodeA, 0)**:
- 执行
setHead(NodeA),将head引用指向当前的NodeA。 - 判断评估:
if (propagate > 0)。由于第 3 步返回的propagate是 0,该条件直接被判定为false。 - ThreadA 退出该方法,没有唤醒它的后继节点
ThreadB。
灾难性后果:明明 T3 在第 4 步释放了一个可用的许可,但由于并发时间差,T3 看到的 head.waitStatus 是 0 导致其未发出唤醒信号;而 ThreadA 看到的 propagate 是 0 也未发出唤醒信号。这导致锁释放的信号被无情吞没,队列中的 ThreadB 将在资源充足的情况下被永久挂起(Hang 死)。
三、 OpenJDK 8源码级深度剖析与详尽注释
为了彻底解决上述由于“旧头节点的 waitStatus == 0”与“新头节点的 propagate == 0”在并发交织时引发的信号丢失,OpenJDK 8引入了 Node.PROPAGATE 状态,并对 doReleaseShared 和 setHeadAndPropagate 进行了重构。
以下是 OpenJDK 8中对应的核心源码与系统工程级详尽注释:
1. Node 节点状态定义
static final class Node {
/** 标记节点正在共享模式下等待 */
static final Node SHARED = new Node();
/** 标记节点正在独占模式下等待 */
static final Node EXCLUSIVE = null;
/** 由于后继节点对应的线程被挂起,当前节点在释放或取消时必须主动唤醒其后继节点 */
static final int SIGNAL = -1;
/** 线程由于超时或中断,被取消在此同步队列中的等待 */
static final int CANCELLED = 1;
/** 线程在 Condition 条件队列中等待(本文不展开) */
static final int CONDITION = -2;
/**
* 【核心引入】
* 共享模式下的释放动作(releaseShared)必须无条件向后传播。
* 该状态专门设置给头节点(Head),确保即使 propagate == 0,后续节点也能感知到并发释放信号。
*/
static final int PROPAGATE = -3;
/** 状态位,取值为上述常数或 0(初始初始化状态) */
volatile int waitStatus;
/** 前驱节点 */
volatile Node prev;
/** 后继节点 */
volatile Node next;
/** 封装的线程句柄 */
volatile Thread thread;
final boolean isShared() {
return nextWaiter == SHARED;
}
// ... 其他细项略
}
2.doReleaseShared()源码详尽注释
该方法是共享模式下释放资源的流水线核心。它运行在一个死循环中,其本质是一个无锁自旋多路转接器,核心任务是将释放信号安全、下沉地传递给队列。
private void doReleaseShared() {
/*
* 死循环保证在多线程并发释放资源或多线程并发抢占 head 变更时,
* 释放与唤醒信号不丢失。
*/
for (;;) {
Node h = head;
// 检查队列是否初始化,且队列中是否存在等待获取资源的真实线程节点
if (h != null && h != tail) {
int ws = h.waitStatus;
// 场景 A:如果头节点状态为 SIGNAL,说明后继节点需要被唤醒
if (ws == Node.SIGNAL) {
/*
* 必须通过 CAS 将状态由 SIGNAL 重置为 0。
* 这一步是为了防止多线程同时执行 unparkSuccessor 导致重复唤醒的系统开销。
* 如果 CAS 失败,说明其他并发线程抢先一步重置了状态并执行了唤醒,当前线程应自旋重试。
*/
if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
continue; // CAS 失败,重新读取最新的 head 状态
// CAS 成功者,唯一负责唤醒旧 head 的直接后继节点
unparkSuccessor(h);
}
/*
* 场景 B:如果 ws == 0,说明后继节点已经被唤醒,或者正处于唤醒的临界状态(如前述漏洞第4步)。
* 此时,为了防止信号丢失,必须将当前头节点状态通过 CAS 更改为 PROPAGATE(-3)。
*
* 作用:打上“传播”烙印。当正在唤醒的线程执行到 setHeadAndPropagate 时,
* 即便它发现剩余资源 propagate == 0,但只要感知到旧头节点变成了 PROPAGATE,
* 就会判定“在其获取资源期间,有外部线程释放了资源”,从而继续向下传播唤醒。
*/
else if (ws == 0 &&
!compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
continue; // 若此时 ws 被后继节点改为了 SIGNAL,CAS 失败,自旋重新检查
}
/*
* 【关键环回屏障】
* 如果在上述操作过程中,head 的指向没有发生改变(即没有线程成功获取锁并更新 head),
* 说明当前的释放/传播动作已经完整落盘到当前 head 节点上,可以安全退出循环。
*
* 如果 head 变了(例如被唤醒的线程已经成功获取锁并执行了 setHead),
* 当前线程必须继续循环,在新 head 上尝试应用上述释放逻辑,实现连续传播。
*/
if (h == head)
break;
}
}
3.setHeadAndPropagate()源码详尽注释
当一个共享模式节点(如 ThreadA)被唤醒并成功在 tryAcquireShared 中拿到资源后,会调用此方法。它的设计体现了防御性编程的极致。
private void setHeadAndPropagate(Node node, int propagate) {
// 1. 记录当前的旧 head,用于后续的传播条件评估
Node h = head;
// 2. 将当前获取到资源的节点提升为新的虚拟头节点(Head)
setHead(node);
/*
* 3. 极其复杂的条件链路评估:决定是否需要继续唤醒后继共享节点。
*
* 满足以下任意一条,即触发向下传播(执行 doReleaseShared):
* a) propagate > 0 : 明确告知还有剩余共享资源可用。
* b) h == null : 防御性极端边界检查。
* c) h.waitStatus < 0 : 旧 head 的状态小于 0。
* 说明旧 head 的状态可能是 SIGNAL(-1) 或 被外部并发释放线程强行改成的 PROPAGATE(-3)。
* 这就是解决漏洞的关键!即便 propagate == 0,只要旧 head 表现出 PROPAGATE,就必须继续唤醒!
* d) 再检查一遍新 head:(h = head) == null || h.waitStatus < 0
* 因为在执行 setHead(node) 之后,可能又有全新的外部线程调用了 releaseShared(),
* 将新 head 的状态改为了 SIGNAL 或 PROPAGATE。为了绝对不漏掉信号,必须对新 head 也进行透视。
*/
if (propagate > 0 || h == null || h.waitStatus < 0 ||
(h = head) == null || h.waitStatus < 0) {
Node s = node.next;
/*
* 如果当前节点的下一个节点是共享模式(Shared),或者下一个节点未知(s == null),
* 则直接调用 doReleaseShared(),开启新一轮的扩散唤醒流。
*/
if (s == null || s.isShared())
doReleaseShared();
}
}
四、 PROPAGATE 状态机的无锁流转与控制屏障分析
为了从底层透彻理解这一机制,我们可以将整个过程抽象为一个依赖于 volatile 读写和 CAS 操作的状态机转换图。
1. 状态流转轨迹
在共享模式下,头节点的 waitStatus 主要在以下四个状态间流转:
(初始初始化)
0
/ \
(有后继) (并发释放资源)
/ \
SIGNAL(-1) PROPAGATE(-3)
\ /
(执行唤醒重置)
0
0 -> Node.SIGNAL (-1):当后继节点尝试获取资源失败准备挂起时,后继节点会通过shouldParkAfterFailedAcquire强行将前驱(当前head)的状态由0修改为SIGNAL,表示“你释放时记着叫醒我”。Node.SIGNAL -> 0:当触发doReleaseShared时,执行线程为了独占唤醒权,会通过 CAS 将SIGNAL还原为0,并执行unparkSuccessor。0 -> Node.PROPAGATE (-3):当外部并发线程释放资源执行doReleaseShared,却发现当前head.waitStatus == 0(表明当前尚无需要唤醒的后继节点,或者后继节点正处于被唤醒的半初始化状态),此时通过 CAS 将其升级为PROPAGATE,封存并锁定这一释放信号。
2. 重现前述挂起漏洞:在 JDK 8下的完美闭环
再次将前述导致死锁的极端高并发场景带入 OpenJDK 8的逻辑中进行推演:
Head -> ThreadA -> ThreadB。T2 线程释放资源,将旧Head从SIGNAL改为0,唤醒ThreadA。ThreadA抢到最后资源,propagate = 0。- 【并发交织点】:外部线程 T3 调用
releaseShared(),进入doReleaseShared()。此时ThreadA还未替换head。T3 发现head.waitStatus == 0,于是果断执行:
compareAndSetWaitStatus(h, 0, Node.PROPAGATE)
CAS 成功,旧 head 的状态被强行烙印为 Node.PROPAGATE(-3)。
4. ThreadA 接着执行 setHeadAndPropagate(NodeA, 0):
Node h = head;此时h指向旧head,其waitStatus已经被 T3 改为了-3。- 执行
setHead(NodeA),将新head改为NodeA。 - 进行条件判断:
if (propagate > 0 || h == null || h.waitStatus < 0 ...) - 虽然
propagate > 0为false,但 **h.waitStatus < 0(-3 < 0)为true**! - 条件满足,
ThreadA越过屏障,成功调用doReleaseShared()。
- 在
doReleaseShared()内部,ThreadA面对的是新的head(NodeA)。因为后面还排着ThreadB,NodeA的状态在早些时候已被ThreadB改为了SIGNAL。 ThreadA成功匹配到ws == Node.SIGNAL,通过 CAS 重置为 0,并调用unparkSuccessor(NodeA),成功唤醒ThreadB。
通过引入 PROPAGATE,AQS 利用旧头节点的“状态残留”,成功地将并发调用 releaseShared 的通知转化为一种“历史标记”。这个标记允许新被唤醒的线程即便在 propagate == 0 的情况下,也能洞察到在它处理状态切换的间隙里曾经有资源被释放过,从而必须将唤醒动作无限延展下去。
五、 系统工程师视角下的设计总结
从系统工程和底层并发设计的角度来看,AQS 的 PROPAGATE 机制展示了无锁(Lock-Free)设计中应对多角色(生产线程/消费线程)在时间轴上交错重叠的高超技巧。
- 双重状态感知(旧头与新头):
setHeadAndPropagate连续对旧head和新head进行waitStatus < 0的两次扫描,本质上是在流水线架构中建立了两道双保险的快照检查机制,消除了由于head指针原子切换与waitStatus原子切换不同步而带来的时序盲区。 - 最小化 CAS 冲突:在
doReleaseShared中,通过优先判断SIGNAL并将其 CAS 为0,分离了“真正的唤醒者”与“信号的传播者”。多个并发释放资源的线程如果遇到ws == 0,只会安全地将状态推进至PROPAGATE,而不会触发大量的、无意义的底层操作系统的unpark线程上下文切换开销。 - 内存可见性保障:AQS 内部的所有节点指针(
head,tail,next,prev)及状态位(waitStatus,state)均被声明为volatile。配合 CAS 操作带来的底层 CPU 内存屏障(Memory Barrier,如 x86 架构下的lock前缀指令),确保了PROPAGATE信号一旦在某个核心上被写入,其他核心在执行setHeadAndPropagate读取时能够立即可见,完美支撑了共享模式下的高吞吐并发传播。
总结
到此这篇关于Java AQS的propagate机制的文章就介绍到这了,更多相关Java AQS的propagate机制内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!
