AQS 内部的等待队列,正式名称是 同步队列(Sync Queue),它是 AQS 实现线程排队等待的核心数据结构。下面从结构、节点、状态机、核心操作、以及两种模式下的行为差异五个维度进行深度剖析。
一、队列的宏观结构
1.1 双向链表 + 虚头节点
AQS 的同步队列是一个双向链表,由内部类 Node 的 prev 和 next 指针连接而成。队列维护了两个原子引用:
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 是单向),是因为两个核心需求:
- 支持从后向前遍历:在唤醒后继时,如果
next指针因并发入队尚未连接或指向已取消节点,可以通过prev从tail安全地向前查找有效节点。 - 支持节点取消:当节点因超时/中断需要从队列中移除时,需要同时修改前驱的
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 队列的调度信号灯,它决定了节点在队列中的行为:
| 状态常量 | 整数值 | 含义与作用 |
|---|---|---|
0 | 0 | 初始状态。节点刚创建并入队时的默认值。 |
SIGNAL | -1 | 唤醒责任标记。当前节点释放锁后,必须唤醒其后继节点。这是后继节点在阻塞前为前驱设置的状态。 |
CANCELLED | 1 | 无效节点。节点因等待超时、被中断或发生异常而取消。此类节点会在后续遍历中被惰性删除。 |
CONDITION | -2 | 节点在条件队列中等待(Condition.await),尚未转移到同步队列。 |
PROPAGATE | -3 | 传播标记。仅在共享模式下使用,表示释放锁时的唤醒操作需要向后传播。 |
2.2 节点模式
节点在入队时通过 nextWaiter 字段标记其同步模式:
| 模式 | nextWaiter 值 | 典型场景 |
|---|---|---|
| 独占模式 | Node.EXCLUSIVE (null) | ReentrantLock、写锁 |
| 共享模式 | Node.SHARED (一个空 Node 常量) | Semaphore、CountDownLatch、读锁 |
三、核心操作:入队、出队、阻塞、唤醒
3.1 入队:addWaiter 与 enq
当线程快速尝试获取锁失败后,会调用 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 已经指向新节点,但旧 tail 的 next 指针尚未建立。因此,任何正向遍历(沿着 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,必须先确保前驱节点有唤醒自己的责任。这通过设置前驱的 waitStatus 为 SIGNAL 实现。
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 视角)
- 调用
tryAcquire快速尝试。 - 失败后
addWaiter(Node.EXCLUSIVE)入队。 - 进入
acquireQueued自旋:- 每次循环检查前驱是否为
head。 - 若是,再次
tryAcquire,成功则setHead出队返回。 - 若否,调用
shouldParkAfterFailedAcquire设置前驱SIGNAL并park阻塞。
- 每次循环检查前驱是否为
4.2 释放锁流程(release 视角)
- 调用
tryRelease修改state。 - 若
state == 0(完全释放),获取当前head。 - 若
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 支持线程在等待过程中响应中断或超时。当一个节点决定取消等待时,它不会立刻从队列中物理移除(因为并发环境下直接移除指针极易导致断链),而是:
- 将自己的
waitStatus设置为CANCELLED。 - 如果自己是
tail,尝试 CAS 将tail指向前驱。 - 唤醒自己的后继,让后继在自旋中帮忙清理自己(跳过
CANCELLED节点)。
这种标记-清理的惰性删除策略,既保证了并发安全,又避免了复杂的锁同步。
七、总结:同步队列的核心设计理念
| 设计要素 | 具体实现 | 解决的问题 |
|---|---|---|
| 双向链表 | prev + next | 支持安全的反向遍历和节点取消 |
| 虚头节点 | head.thread == null | 简化出队逻辑,作为唤醒的"接力棒" |
SIGNAL 状态 | 前驱释放时必须唤醒后继 | 解决 park/unpark 的信号丢失问题 |
CANCELLED 惰性删除 | 标记后由后继清理 | 无锁化处理超时/中断取消 |
PROPAGATE 传播 | 共享模式专用 | 实现批量唤醒的链式反应 |
| 反向遍历唤醒 | unparkSuccessor 从 tail 找 | 规避入队时 next 指针滞后的并发窗口 |
AQS 的同步队列是一个为阻塞调度而生的精密数据结构,它用极简的 volatile 字段和 CAS 操作,构建出了支撑整个 JUC 并发包的高性能线程排队引擎。