📌 人工智能开发:基于Spring AI的智能对话系统设计:Java全栈实现RAG与工具调用
第12题:AQS 中节点的入队时机有哪些?
📚 回答:
- 核心考点: AQS 的入队时机是理解其线程调度机制的关键。大厂面试不会只问"有哪三种入队时机",而是深入考察 每种入队的 CAS 原子性保障(enq 的尾插法自旋)、条件队列与同步队列的转移细节(transferForSignal 的 CAS 状态变更)、以及入队过程中的并发安全问题(tail 指针的 ABA 风险、CANCELLED 节点的清理时机)。面试官真正想判断的是:你是否能从源码层面理解 AQS 队列的动态演化过程。
1. 同步队列(Sync Queue)的入队时机
-
1.1 时机一:独占锁获取失败(acquire 路径) 当线程调用 acquire(int arg) 获取独占锁时,如果 tryAcquire 返回 false,线程会被封装为 Node 并入队:
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}addWaiter 的入队逻辑:
private Node addWaiter(Node mode) {
Node node = new Node(Thread.currentThread(), mode);
Node pred = tail;
// 快速路径:tail 已初始化,直接 CAS 入队
if (pred != null) {
node.prev = pred;
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
// 慢速路径:tail 未初始化或 CAS 失败,自旋入队
enq(node);
return node;
}关键细节:
- 先设置 prev,再 CAS tail:确保即使 next 指针未链接,也能从 prev 回溯;
- 快速路径 vs 慢速路径:大部分情况下 tail 已初始化,直接 CAS;只有队列未初始化或高并发竞争时才走 enq。
-
1.2 时机二:共享锁获取失败(acquireShared 路径)
public final void acquireShared(int arg) {
if (tryAcquireShared(arg) < 0)
doAcquireShared(arg);
}doAcquireShared 同样调用 addWaiter(Node.SHARED),但模式标记为 SHARED(nextWaiter 指向 SHARED 哨兵节点)。
-
1.3 时机三:可中断/超时获取失败(acquireInterruptibly/tryAcquireNanos)
public final void acquireInterruptibly(int arg) throws InterruptedException {
if (Thread.interrupted()) throw new InterruptedException();
if (!tryAcquire(arg))
doAcquireInterruptibly(arg); // 入队 + 可中断阻塞
}与普通 acquire 的区别:阻塞期间响应中断,直接抛出 InterruptedException。
-
1.4 enq 方法——自旋初始化 + 尾插法
private Node enq(final Node node) {
for (;;) {
Node t = tail;
if (t == null) { // 队列未初始化
if (compareAndSetHead(new Node())) // CAS 设置哨兵 head
tail = head;
} else {
node.prev = t;
if (compareAndSetTail(t, node)) { // CAS 尾插
t.next = node;
return t;
}
}
}
}关键设计:
- 哨兵 head:初始 head 是一个空 Node(thread=null),不绑定任何线程,只作为队列起点;
- 自旋保证原子性:compareAndSetTail 失败则重试,直到成功;
- ABA 安全:虽然 tail 可能 ABA(被其他线程修改又改回),但 prev 指针保证了链表的完整性。
2. 条件队列(Condition Queue)的入队时机
-
2.1 时机四:调用 Condition.await() 当线程持有锁并调用 await() 时,会释放锁并加入条件队列:
public final void await() throws InterruptedException {
if (Thread.interrupted()) throw new InterruptedException();
Node node = addConditionWaiter(); // 加入条件队列
int savedState = fullyRelease(node); // 完全释放锁
// … 阻塞等待
}addConditionWaiter 的入队逻辑:
private Node addConditionWaiter() {
Node t = lastWaiter;
// 清理已取消的尾节点
if (t != null && t.waitStatus != Node.CONDITION) {
unlinkCancelledWaiters();
t = lastWaiter;
}
Node node = new Node(Thread.currentThread(), Node.CONDITION);
if (t == null)
firstWaiter = node;
else
t.nextWaiter = node;
lastWaiter = node;
return node;
}关键细节:
- 无需 CAS:条件队列的入队在持有锁的情况下进行(await 调用前必须持有锁),因此是单线程操作,无需 CAS;
- 清理 CANCELLED 节点:入队前检查并清理尾部的 CANCELLED 节点,避免链表污染。
-
2.2 时机五:调用 awaitUninterruptibly/awaitNanos/awaitUntil 这些变体方法同样会调用 addConditionWaiter,但阻塞期间的行为不同:
方法中断响应超时支持 await() 立即抛出异常 无 awaitUninterruptibly() 忽略中断 无 awaitNanos(long) 中断 + 超时返回 纳秒级超时 awaitUntil(Date) 中断 + 超时返回 绝对时间超时
3. 条件队列 → 同步队列的转移时机
-
3.1 时机六:调用 Condition.signal()
public final void signal() {
if (!isHeldExclusively()) throw new IllegalMonitorStateException();
Node first = firstWaiter;
if (first != null) doSignal(first);
}transferForSignal 的转移逻辑:
final boolean transferForSignal(Node node) {
// 步骤1:将 CONDITION 状态改为 0(CAS 保证原子性)
if (!compareAndSetWaitStatus(node, Node.CONDITION, 0))
return false; // 节点已取消// 步骤2:入队到同步队列尾部
Node p = enq(node);
int ws = p.waitStatus;// 步骤3:如果前驱已取消或设置 SIGNAL 失败,直接唤醒
if (ws > 0 || !compareAndSetWaitStatus(p, ws, Node.SIGNAL))
LockSupport.unpark(node.thread);
return true;
}关键细节:
- CAS 状态变更:CONDITION( -2) → 0 是原子操作,确保节点在转移过程中不会被其他线程操作;
- 转移后不一定立即唤醒:如果前驱正常且设置 SIGNAL 成功,节点等待前驱释放时唤醒;只有前驱异常时才立即 unpark。
-
3.2 时机七:调用 Condition.signalAll() doSignalAll 遍历整个条件队列,将所有节点逐个转移到同步队列:
private void doSignalAll(Node first) {
lastWaiter = firstWaiter = null;
do {
Node next = first.nextWaiter;
first.nextWaiter = null;
transferForSignal(first);
first = next;
} while (first != null);
} -
3.3 时机八:中断导致的被动转移 线程在条件队列中等待时被中断,会触发 transferAfterCancelledWait:
private int checkInterruptWhileWaiting(Node node) {
return Thread.interrupted() ?
(transferAfterCancelledWait(node) ? THROW_IE : REINTERRUPT) :
0;
}中断后节点会被强制转移到同步队列,但此时可能尚未被 signal,属于"提前唤醒"。
4. 特殊入队时机——CANCELLED 节点的清理
-
4.1 时机九:获取锁超时/中断后标记为 CANCELLED 当线程在 acquireQueued 中因中断或超时而取消时:
private void cancelAcquire(Node node) {
if (node == null) return;
node.thread = null;// 跳过前驱的 CANCELLED 节点
Node pred = node.prev;
while (pred.waitStatus > 0)
node.prev = pred = pred.prev;Node predNext = pred.next;
node.waitStatus = Node.CANCELLED;// 如果当前节点是 tail,直接移除
if (node == tail && compareAndSetTail(node, pred)) {
compareAndSetNext(pred, predNext, null);
} else {
// 否则,让前驱负责清理(在 shouldParkAfterFailedAcquire 中)
if (pred != head &&
pred.waitStatus == Node.SIGNAL ||
compareAndSetWaitStatus(pred, 0, Node.SIGNAL) &&
pred.thread != null) {
Node next = node.next;
if (next != null && next.waitStatus <= 0)
compareAndSetNext(pred, predNext, next);
} else {
unparkSuccessor(node); // 唤醒后继,让它自己处理
}
}
}清理策略:
- 尾节点直接移除:CAS 设置 tail = pred;
- 中间节点延迟清理:不立即从链表中移除,而是依赖后继线程的 shouldParkAfterFailedAcquire 跳过 CANCELLED 节点。
5. 入队时机的完整分类与对比
| 独占锁获取失败 | 同步队列 | tryAcquire 返回 false | compareAndSetTail | RUNNABLE → 自旋/阻塞 |
| 共享锁获取失败 | 同步队列 | tryAcquireShared < 0 | compareAndSetTail | RUNNABLE → 自旋/阻塞 |
| 可中断获取失败 | 同步队列 | tryAcquire 返回 false | compareAndSetTail | RUNNABLE → 可中断阻塞 |
| 超时获取失败 | 同步队列 | tryAcquire 返回 false | compareAndSetTail | RUNNABLE → 限时阻塞 |
| await() | 条件队列 | 调用 await() 且持有锁 | 无需 CAS(单线程) | RUNNABLE → 释放锁+阻塞 |
| signal() | 同步队列 | 调用 signal() 且持有锁 | compareAndSetWaitStatus + enq | CONDITION → 等待锁 |
| signalAll() | 同步队列 | 调用 signalAll() | 多次 transferForSignal | CONDITION → 等待锁 |
| 中断被动转移 | 同步队列 | 条件队列中线程被中断 | transferAfterCancelledWait | CONDITION → 等待锁 |
| 超时取消 | 同步队列(CANCELLED) | tryAcquireNanos 超时 | cancelAcquire | 阻塞 → CANCELLED |
6. 入队过程中的并发安全问题
-
6.1 tail 的 ABA 问题
时间线:
T1: 线程 A 读取 tail = NodeX
T2: 线程 B CAS tail = NodeY
T3: 线程 C CAS tail = NodeX(NodeX 被重新入队)
T4: 线程 A CAS tail = NodeZ(基于旧的 NodeX,但 NodeX 已非原节点)解决方案:node.prev = pred 先设置,即使 tail ABA,也能通过 prev 指针构建完整链表。
-
6.2 next 指针的可见性 next 指针是普通变量(非 volatile),依赖 tail 的 happens-before 保证可见性:
// enq 中的顺序:
node.prev = t; // 普通写
compareAndSetTail(t, node); // volatile 写( happens-before 屏障)
t.next = node; // 普通写,对后续读 tail 的线程可见 -
6.3 条件队列的线程安全 条件队列的入队/出队不需要 CAS,因为:
- await() 要求当前线程持有锁,入队是单线程操作;
- signal() 要求当前线程持有锁,出队也是单线程操作。 这是 AQS 设计的精妙之处——用锁保护条件队列,用 CAS 保护同步队列。
7. 生产环境避坑指南
-
7.1 避免在 tryAcquire 中触发其他入队 tryAcquire 是回调方法,如果内部调用其他会触发 AQS 入队的方法(如另一个 lock.acquire()),会导致嵌套入队死锁。
-
7.2 注意 signal 前必须持有锁 signal() 内部调用 isHeldExclusively() 检查,未持有锁会抛出 IllegalMonitorStateException。这是常见 Bug,尤其在异步回调中 signal。
-
7.3 条件队列的内存泄漏 如果 signal 被遗漏(如异常分支未执行),条件队列中的节点会永久等待。应使用 signalAll 或确保所有路径都有 signal。
-
7.4 监控队列长度
ReentrantLock lock = new ReentrantLock();
// 入队后检查队列长度
if (lock.getQueueLength() > 100) {
logger.warn("AQS queue too long: {}", lock.getQueueLength());
}
8. 面试官追问与高分回答模板
-
追问 1:“AQS 中节点的入队时机有哪些?”
- 低分回答:“竞争失败入同步队列,await 入条件队列,signal 转移队列。”(遗漏了多种变体)
- 高分回答:
"AQS 的入队时机可分为三大类九种情况:
- 同步队列入队(4 种):独占锁 acquire 失败、共享锁 acquireShared 失败、可中断 acquireInterruptibly 失败、限时 tryAcquireNanos 失败;
- 条件队列入队(2 种):await()、awaitUninterruptibly/awaitNanos/awaitUntil;
- 队列间转移(3 种):signal() 转移头节点、signalAll() 转移全部节点、中断导致的被动转移。
此外,超时/中断会导致节点被标记为 CANCELLED,这也是一种特殊的"状态变更"。
关键区别:同步队列入队需要 CAS(多线程竞争),条件队列入队无需 CAS(单线程持有锁),转移时需要 CAS 变更 waitStatus 并调用 enq。"
追问 2:“同步队列的入队为什么需要 CAS?条件队列为什么不需要?”
- 高分回答:
"同步队列管理的是竞争锁失败的线程,这些线程来自多个 CPU 核心,同时尝试入队,必须用 CAS 保证尾插法的原子性(compareAndSetTail)。
条件队列管理的是调用 await() 的线程,而 await() 的调用前提是当前线程必须持有锁。既然持有锁,同一时刻只有一个线程能操作条件队列,因此是单线程操作,无需 CAS。
这是 AQS 设计的精妙之处:用锁保护条件队列(简化实现),用 CAS 保护同步队列(支持高并发)。"
追问 3:“enq 方法为什么要用哨兵 head?直接让第一个线程作为 head 不行吗?”
- 高分回答:
"哨兵 head(thread=null 的空节点)有两个核心作用:
- 简化边界处理:acquireQueued 中判断 p == head 时尝试获取锁,如果 head 绑定真实线程,需要额外处理线程已释放但节点未出队的情况;
- 统一释放逻辑:release 时唤醒 head.next,如果 head 是真实线程且已退出,需要特殊处理。哨兵 head 保证队列始终有头节点,释放逻辑统一。
另外,哨兵 head 的 waitStatus 可以承载 SIGNAL 状态,提示后继节点"我释放时会唤醒你"。"
追问 4:“signal 后节点一定立即被唤醒吗?”
- 高分回答:
"不一定。transferForSignal 将节点从条件队列转移到同步队列后,有两种情况:
- 正常路径:前驱节点 waitStatus 正常,CAS 设置为 SIGNAL。此时节点进入同步队列等待,直到前驱释放锁时唤醒它;
- 快速路径:前驱已取消(ws > 0)或设置 SIGNAL 失败,直接 LockSupport.unpark(node.thread) 唤醒。
大部分情况下走正常路径,因为 signal 调用时通常前驱正常。但极端并发下可能走快速路径。"
追问 5:“CANCELLED 节点为什么不立即从链表中移除?”
- 高分回答:
"CANCELLED 节点采用延迟清理策略,原因有三:
- 避免竞争:cancelAcquire 时可能持有锁的线程正在遍历链表(如 unparkSuccessor),立即移除需要复杂同步;
- 简化实现:依赖后继线程的 shouldParkAfterFailedAcquire 跳过 CANCELLED 节点,将清理责任分散到多个线程;
- 尾节点快速移除:如果 CANCELLED 节点是 tail,可以直接 CAS 移除(compareAndSetTail),因为 tail 只有一个竞争者。
这种设计是’空间换时间’,用少量内存占用换取更简单的并发控制。"
追问 6:“如果 tryAcquire 内部又调用了另一个 AQS 的 acquire,会发生什么?”
- 高分回答:
"这会导致嵌套入队死锁。例如线程 A 在 Lock1 的 tryAcquire 中调用 Lock2.acquire(),如果 Lock2 也获取失败,线程 A 会入 Lock2 的同步队列并阻塞。但此时线程 A 可能持有 Lock1 的部分状态(如已修改了 Lock1 的 state),导致 Lock1 无法被其他线程释放,形成死锁。
最佳实践:tryAcquire 必须是’纯函数’,只操作当前 AQS 的 state,严禁调用其他阻塞方法、IO 或嵌套锁。"
9. 方案选型速查表
| 普通获取独占锁 | addWaiter(EXCLUSIVE) | 同步队列 | ✅ | 先 prev 再 CAS tail |
| 普通获取共享锁 | addWaiter(SHARED) | 同步队列 | ✅ | nextWaiter 指向 SHARED 哨兵 |
| 可中断获取锁 | addWaiter(EXCLUSIVE) | 同步队列 | ✅ | 阻塞期间响应中断 |
| 限时获取锁 | addWaiter(EXCLUSIVE) | 同步队列 | ✅ | 超时后标记 CANCELLED |
| 条件等待 | addConditionWaiter() | 条件队列 | ❌ | 必须持有锁,清理 CANCELLED 尾节点 |
| 条件唤醒单个 | transferForSignal() | 同步队列 | ✅ | CONDITION→0 CAS + enq |
| 条件唤醒全部 | doSignalAll() | 同步队列 | ✅ | 遍历转移所有节点 |
| 中断被动唤醒 | transferAfterCancelledWait() | 同步队列 | ✅ | 可能提前唤醒,需处理中断模式 |
💡 面试官想要的满分总结:
AQS 的入队时机是理解其线程调度机制的关键。核心认知有三层:
同步队列入队(4 种变体):所有竞争锁失败的路径最终都走 addWaiter → enq,用 CAS 尾插法保证多线程安全。enq 的哨兵 head 设计和先 prev 后 tail 的顺序,是应对 ABA 和并发断裂的关键。
条件队列入队(2 种变体):await 系列方法在持有锁的前提下入队,单线程操作无需 CAS,但需清理 CANCELLED 尾节点防止链表污染。
队列间转移(3 种变体):signal/signalAll 通过 CAS 将节点从条件队列(CONDITION 状态)转移到同步队列(0 状态),转移后不一定立即唤醒,而是等待前驱释放。中断导致的被动转移是边界情况,需处理 THROW_IE/REINTERRUPT 两种中断模式。
工程实践中,避免在 tryAcquire 中嵌套锁,监控同步队列长度,确保所有 await 都有对应的 signal。AQS 的入队设计体现了"用锁简化单线程路径,用 CAS 支持高并发路径"的精妙平衡。
觉得对您有帮助,麻烦点点关注啦,您的关注是我创作的最大动力~ 🎯



