欢迎光临
我们一直在努力

【大白话说Java面试题 第112题】【并发篇】第12题:AQS 中节点的入队时机有哪些?

📌 人工智能开发:基于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. 入队时机的完整分类与对比
入队时机目标队列触发条件CAS 操作线程状态变化
独占锁获取失败 同步队列 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. 方案选型速查表
场景入队方法队列类型是否 CAS注意事项
普通获取独占锁 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 支持高并发路径"的精妙平衡。


觉得对您有帮助,麻烦点点关注啦,您的关注是我创作的最大动力~ 🎯

赞(0)
未经允许不得转载:171主机测评 » 【大白话说Java面试题 第112题】【并发篇】第12题:AQS 中节点的入队时机有哪些?
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址