java

关注公众号 jb51net

关闭
首页 > 软件编程 > java > Java AQS的propagate机制

Java AQS的propagate机制深入剖析

作者:wuminyu

AQS是Java并发包的核心框架,通过维护共享状态和CLH变体队列实现锁和同步器,这篇文章主要介绍了Java AQS的propagate机制的相关资料,文中介绍的非常详细,需要的朋友可以参考下

前言

本文旨在记录近期研读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 节点中。

二、 经典的 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)。

  1. T1 线程(ThreadA):调用 acquireShared 被阻塞,进入等待队列。
  2. T2 线程(外部线程):调用 releaseShared() 释放了一个许可,此时系统可用许可变为 1。
  1. ThreadA 被唤醒:从阻塞中恢复,开始执行 tryAcquireShared()。
  1. T3 线程(另一个外部线程并发介入):在 ThreadA 尚未执行 setHeadAndPropagate 之前,T3 并发调用了 releaseShared() 释放了一个许可。
  1. **ThreadA 推进到 setHeadAndPropagate(NodeA, 0)**:

灾难性后果:明明 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

2. 重现前述挂起漏洞:在 JDK 8下的完美闭环

再次将前述导致死锁的极端高并发场景带入 OpenJDK 8的逻辑中进行推演:

  1. Head -> ThreadA -> ThreadB。T2 线程释放资源,将旧 Head 从 SIGNAL 改为 0,唤醒 ThreadA。
  2. ThreadA 抢到最后资源,propagate = 0。
  3. 【并发交织点】:外部线程 T3 调用 releaseShared(),进入 doReleaseShared()。此时 ThreadA 还未替换 head。T3 发现 head.waitStatus == 0,于是果断执行:
compareAndSetWaitStatus(h, 0, Node.PROPAGATE)

CAS 成功,旧 head 的状态被强行烙印为 Node.PROPAGATE(-3)。
4. ThreadA 接着执行 setHeadAndPropagate(NodeA, 0):

  1. 在 doReleaseShared() 内部,ThreadA 面对的是新的 head(NodeA)。因为后面还排着 ThreadB,NodeA 的状态在早些时候已被 ThreadB 改为了 SIGNAL。
  2. ThreadA 成功匹配到 ws == Node.SIGNAL,通过 CAS 重置为 0,并调用 unparkSuccessor(NodeA),成功唤醒 ThreadB。

通过引入 PROPAGATE,AQS 利用旧头节点的“状态残留”,成功地将并发调用 releaseShared 的通知转化为一种“历史标记”。这个标记允许新被唤醒的线程即便在 propagate == 0 的情况下,也能洞察到在它处理状态切换的间隙里曾经有资源被释放过,从而必须将唤醒动作无限延展下去。

五、 系统工程师视角下的设计总结

从系统工程和底层并发设计的角度来看,AQS 的 PROPAGATE 机制展示了无锁(Lock-Free)设计中应对多角色(生产线程/消费线程)在时间轴上交错重叠的高超技巧。

总结 

到此这篇关于Java AQS的propagate机制的文章就介绍到这了,更多相关Java AQS的propagate机制内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

您可能感兴趣的文章:
阅读全文