AQS-同步队列

0 阅读8分钟

AQS 内部的等待队列,正式名称是 同步队列(Sync Queue),它是 AQS 实现线程排队等待的核心数据结构。下面从结构、节点、状态机、核心操作、以及两种模式下的行为差异五个维度进行深度剖析。


一、队列的宏观结构

1.1 双向链表 + 虚头节点

AQS 的同步队列是一个双向链表,由内部类 Nodeprevnext 指针连接而成。队列维护了两个原子引用:

private transient volatile Node head;
private transient volatile Node tail;

结构示意

      head (虚节点)                     tail
         ↓                              ↓
┌─────────────────┐    ┌─────────────────┐    ┌─────────────────┐
│      Node       │ ←→ │      Node       │ ←→ │      Node       │
│  thread = null  │    │  thread = 线程A │    │  thread = 线程B │
│ waitStatus = -1 │    │ waitStatus = -1 │    │ waitStatus = 0  │
└─────────────────┘    └─────────────────┘    └─────────────────┘

关键特征

  • head 是虚节点(Dummy Node):其 thread 字段永远为 null,仅作为队列的起始哨兵。它代表当前持有锁的线程最后一个释放锁的线程留下的占位符
  • tail 始终指向最后一个入队的节点。新来的线程总是挂在 tail 之后。
  • FIFO 顺序:节点从队尾入队,从队首(head.next)出队。

1.2 为什么是双向链表?

AQS 之所以设计为双向链表(原版 CLH 是单向),是因为两个核心需求:

  1. 支持从后向前遍历:在唤醒后继时,如果 next 指针因并发入队尚未连接或指向已取消节点,可以通过 prevtail 安全地向前查找有效节点。
  2. 支持节点取消:当节点因超时/中断需要从队列中移除时,需要同时修改前驱的 next 和后继的 prev,双向指针让这个操作更容易实现。

二、节点 Node 的内部结构

每个等待线程被封装成一个 Node 对象,其核心属性如下:

static final class Node {
    volatile int waitStatus;      // 节点状态(核心信号)
    volatile Node prev;           // 前驱指针
    volatile Node next;           // 后继指针
    volatile Thread thread;       // 被阻塞的线程
    Node nextWaiter;              // 指向条件队列中的下一个节点(同步队列中未使用)
}

2.1 waitStatus 状态枚举

waitStatus 是 AQS 队列的调度信号灯,它决定了节点在队列中的行为:

状态常量整数值含义与作用
00初始状态。节点刚创建并入队时的默认值。
SIGNAL-1唤醒责任标记。当前节点释放锁后,必须唤醒其后继节点。这是后继节点在阻塞前为前驱设置的状态。
CANCELLED1无效节点。节点因等待超时、被中断或发生异常而取消。此类节点会在后续遍历中被惰性删除
CONDITION-2节点在条件队列中等待(Condition.await),尚未转移到同步队列。
PROPAGATE-3传播标记。仅在共享模式下使用,表示释放锁时的唤醒操作需要向后传播

2.2 节点模式

节点在入队时通过 nextWaiter 字段标记其同步模式

模式nextWaiter典型场景
独占模式Node.EXCLUSIVE (null)ReentrantLock、写锁
共享模式Node.SHARED (一个空 Node 常量)SemaphoreCountDownLatch、读锁

三、核心操作:入队、出队、阻塞、唤醒

3.1 入队:addWaiterenq

当线程快速尝试获取锁失败后,会调用 addWaiter 将自己包装成节点并原子地挂到队尾。

private Node addWaiter(Node mode) {
    Node node = new Node(Thread.currentThread(), mode);
    Node pred = tail;
    if (pred != null) {
        node.prev = pred;
        if (compareAndSetTail(pred, node)) {  // CAS 竞争 tail
            pred.next = node;
            return node;
        }
    }
    enq(node);  // 快速失败或队列为空时,进入自旋入队
    return node;
}

enq 自旋入队的精妙设计

private Node enq(final Node node) {
    for (;;) {
        Node t = tail;
        if (t == null) {
            // 队列为空:必须先用 CAS 初始化一个虚头节点
            if (compareAndSetHead(new Node()))
                tail = head;
        } else {
            node.prev = t;
            if (compareAndSetTail(t, node)) {
                t.next = node;   // 注意:此步非原子,存在滞后
                return t;
            }
        }
    }
}

关键点t.next = node 是在 CAS 成功之后才执行的,这意味着存在一个极短的时间窗口tail 已经指向新节点,但旧 tailnext 指针尚未建立。因此,任何正向遍历(沿着 next 走)都可能在此窗口内看到断裂的链表,但反向遍历(沿着 prev 走)是绝对安全的。

3.2 出队:setHead

当线程成功获取锁后,它会将自己所在的节点设置为新的 head,并清空其中的线程引用,使之成为新的虚节点

private void setHead(Node node) {
    head = node;
    node.thread = null;   // 清空线程,成为虚节点
    node.prev = null;     // 断开前驱,帮助 GC
}

为什么不需要 CAS? 因为只有成功获取到锁的唯一直线程在操作,不存在并发竞争。

3.3 阻塞前的准备:shouldParkAfterFailedAcquire

线程在自旋中发现自己不是队首或抢锁失败时,不能直接 park,必须先确保前驱节点有唤醒自己的责任。这通过设置前驱的 waitStatusSIGNAL 实现。

private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
    int ws = pred.waitStatus;
    if (ws == Node.SIGNAL)
        return true;   // 前驱已是 SIGNAL,可以安心阻塞
    if (ws > 0) {
        // 前驱是 CANCELLED,向前跳过所有取消节点
        do {
            node.prev = pred = pred.prev;
        } while (pred.waitStatus > 0);
        pred.next = node;
    } else {
        // 前驱是 0 或 PROPAGATE,CAS 将其设为 SIGNAL
        compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
    }
    return false;
}

为什么需要 SIGNAL 这是解决 "信号丢失" 问题的核心。如果线程不设置 SIGNAL 就直接 park,可能出现:刚检查完锁状态、准备 park 的前一瞬间,前驱释放了锁并发出了 unpark。此时 unpark 信号因为线程还没 park 而丢失,导致线程永久休眠。SIGNAL 状态配合二次检查(在 park 前再次尝试获取锁)完美规避了这个问题。

3.4 唤醒后继:unparkSuccessor

当线程释放锁后,会调用此方法唤醒队列中第一个有效的等待节点。

private void unparkSuccessor(Node node) {
    int ws = node.waitStatus;
    if (ws < 0)
        compareAndSetWaitStatus(node, ws, 0);  // 清除 SIGNAL 标记

    Node s = node.next;
    // 如果 next 为 null 或已取消,必须从 tail 向前找
    if (s == null || s.waitStatus > 0) {
        s = null;
        for (Node t = tail; t != null && t != node; t = t.prev)
            if (t.waitStatus <= 0)
                s = t;
    }
    if (s != null)
        LockSupport.unpark(s.thread);
}

为什么必须从 tail 向前找? 正因为入队时 next 指针的滞后性,node.next 可能尚未连接好。但 prev 指针是在 CAS 之前就设置好的,从 tail 沿着 prev 反向遍历一定可以找到最靠前的有效节点。


四、独占模式下的队列行为

4.1 获取锁流程(acquire 视角)

  1. 调用 tryAcquire 快速尝试。
  2. 失败后 addWaiter(Node.EXCLUSIVE) 入队。
  3. 进入 acquireQueued 自旋:
    • 每次循环检查前驱是否为 head
    • 若是,再次 tryAcquire,成功则 setHead 出队返回。
    • 若否,调用 shouldParkAfterFailedAcquire 设置前驱 SIGNALpark 阻塞。

4.2 释放锁流程(release 视角)

  1. 调用 tryRelease 修改 state
  2. state == 0(完全释放),获取当前 head
  3. head.waitStatus != 0(通常为 SIGNAL),调用 unparkSuccessor(head) 唤醒后继。

独占模式特点:一次释放只唤醒一个后继节点,被唤醒的节点成为新的 head


五、共享模式下的队列行为

共享模式与独占模式在队列操作上的核心区别在于传播唤醒

5.1 获取共享资源(acquireShared 视角)

流程与独占类似,但当获取成功时调用的不是 setHead,而是 setHeadAndPropagate

private void setHeadAndPropagate(Node node, int propagate) {
    Node h = head;
    setHead(node);  // 设为新 head
    // propagate 是 tryAcquireShared 的返回值(剩余资源数)
    if (propagate > 0 || h == null || h.waitStatus < 0) {
        Node s = node.next;
        // 如果后继也是共享节点,继续唤醒
        if (s == null || s.isShared())
            doReleaseShared();
    }
}

关键:如果 propagate > 0(表示获取后还有剩余资源),当前节点会主动调用 doReleaseShared 去唤醒下一个共享节点。那个节点被唤醒、获取成功后,又会重复此过程,形成链式唤醒。这就是 CountDownLatch 倒计数归零时能一次性唤醒所有等待线程的原因。

5.2 释放共享资源(releaseShared 视角)

public final boolean releaseShared(int arg) {
    if (tryReleaseShared(arg)) {
        doReleaseShared();  // 核心:传播唤醒
        return true;
    }
    return false;
}

doReleaseShared 是一个自旋循环,它会检查 head 的状态:

  • head.waitStatus == SIGNAL,则 CAS 改为 0 并唤醒后继。
  • head.waitStatus == 0,则 CAS 改为 PROPAGATE(确保传播标记不丢失)。
  • 循环直到 head 不再变化。

六、节点取消与惰性删除

AQS 支持线程在等待过程中响应中断或超时。当一个节点决定取消等待时,它不会立刻从队列中物理移除(因为并发环境下直接移除指针极易导致断链),而是:

  1. 将自己的 waitStatus 设置为 CANCELLED
  2. 如果自己是 tail,尝试 CAS 将 tail 指向前驱。
  3. 唤醒自己的后继,让后继在自旋中帮忙清理自己(跳过 CANCELLED 节点)。

这种标记-清理的惰性删除策略,既保证了并发安全,又避免了复杂的锁同步。


七、总结:同步队列的核心设计理念

设计要素具体实现解决的问题
双向链表prev + next支持安全的反向遍历和节点取消
虚头节点head.thread == null简化出队逻辑,作为唤醒的"接力棒"
SIGNAL 状态前驱释放时必须唤醒后继解决 park/unpark 的信号丢失问题
CANCELLED 惰性删除标记后由后继清理无锁化处理超时/中断取消
PROPAGATE 传播共享模式专用实现批量唤醒的链式反应
反向遍历唤醒unparkSuccessortail规避入队时 next 指针滞后的并发窗口

AQS 的同步队列是一个为阻塞调度而生的精密数据结构,它用极简的 volatile 字段和 CAS 操作,构建出了支撑整个 JUC 并发包的高性能线程排队引擎。