【大白话说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 并入队:

    java 复制代码
    public final void acquire(int arg) {
        if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
    }

    addWaiter 的入队逻辑:

    java 复制代码
    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 路径)

    java 复制代码
    public final void acquireShared(int arg) {
        if (tryAcquireShared(arg) < 0)
            doAcquireShared(arg);
    }

    doAcquireShared 同样调用 addWaiter(Node.SHARED),但模式标记为 SHAREDnextWaiter 指向 SHARED 哨兵节点)。

  • 1.3 时机三:可中断/超时获取失败(acquireInterruptibly/tryAcquireNanos

    java 复制代码
    public final void acquireInterruptibly(int arg) throws InterruptedException {
        if (Thread.interrupted()) throw new InterruptedException();
        if (!tryAcquire(arg))
            doAcquireInterruptibly(arg);  // 入队 + 可中断阻塞
    }

    与普通 acquire 的区别:阻塞期间响应中断,直接抛出 InterruptedException

  • 1.4 enq 方法------自旋初始化 + 尾插法

    java 复制代码
    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() 时,会释放锁并加入条件队列:

    java 复制代码
    public final void await() throws InterruptedException {
        if (Thread.interrupted()) throw new InterruptedException();
        Node node = addConditionWaiter();  // 加入条件队列
        int savedState = fullyRelease(node);  // 完全释放锁
        // ... 阻塞等待
    }

    addConditionWaiter 的入队逻辑:

    java 复制代码
    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()

    java 复制代码
    public final void signal() {
        if (!isHeldExclusively()) throw new IllegalMonitorStateException();
        Node first = firstWaiter;
        if (first != null) doSignal(first);
    }

    transferForSignal 的转移逻辑:

    java 复制代码
    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 遍历整个条件队列,将所有节点逐个转移到同步队列:

    java 复制代码
    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

    java 复制代码
    private int checkInterruptWhileWaiting(Node node) {
        return Thread.interrupted() ?
            (transferAfterCancelledWait(node) ? THROW_IE : REINTERRUPT) :
            0;
    }

    中断后节点会被强制转移到同步队列,但此时可能尚未被 signal,属于"提前唤醒"。

4. 特殊入队时机------CANCELLED 节点的清理
  • 4.1 时机九:获取锁超时/中断后标记为 CANCELLED

    当线程在 acquireQueued 中因中断或超时而取消时:

    java 复制代码
    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 保证可见性:

    java 复制代码
    // 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 监控队列长度

    java 复制代码
    ReentrantLock lock = new ReentrantLock();
    // 入队后检查队列长度
    if (lock.getQueueLength() > 100) {
        logger.warn("AQS queue too long: {}", lock.getQueueLength());
    }
8. 面试官追问与高分回答模板
  • 追问 1:"AQS 中节点的入队时机有哪些?"

    • 低分回答:"竞争失败入同步队列,await 入条件队列,signal 转移队列。"(遗漏了多种变体)
    • 高分回答 : "AQS 的入队时机可分为三大类九种情况:
      1. 同步队列入队 (4 种):独占锁 acquire 失败、共享锁 acquireShared 失败、可中断 acquireInterruptibly 失败、限时 tryAcquireNanos 失败;
      2. 条件队列入队 (2 种):await()awaitUninterruptibly/awaitNanos/awaitUntil
      3. 队列间转移 (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 的空节点)有两个核心作用:
      1. 简化边界处理acquireQueued 中判断 p == head 时尝试获取锁,如果 head 绑定真实线程,需要额外处理线程已释放但节点未出队的情况;
      2. 统一释放逻辑release 时唤醒 head.next,如果 head 是真实线程且已退出,需要特殊处理。哨兵 head 保证队列始终有头节点,释放逻辑统一。

      另外,哨兵 head 的 waitStatus 可以承载 SIGNAL 状态,提示后继节点"我释放时会唤醒你"。"

  • 追问 4:"signal 后节点一定立即被唤醒吗?"

    • 高分回答 : "不一定。transferForSignal 将节点从条件队列转移到同步队列后,有两种情况:
      1. 正常路径 :前驱节点 waitStatus 正常,CAS 设置为 SIGNAL。此时节点进入同步队列等待,直到前驱释放锁时唤醒它;
      2. 快速路径 :前驱已取消(ws > 0)或设置 SIGNAL 失败,直接 LockSupport.unpark(node.thread) 唤醒。

      大部分情况下走正常路径,因为 signal 调用时通常前驱正常。但极端并发下可能走快速路径。"

  • 追问 5:"CANCELLED 节点为什么不立即从链表中移除?"

    • 高分回答 : "CANCELLED 节点采用延迟清理策略,原因有三:
      1. 避免竞争cancelAcquire 时可能持有锁的线程正在遍历链表(如 unparkSuccessor),立即移除需要复杂同步;
      2. 简化实现 :依赖后继线程的 shouldParkAfterFailedAcquire 跳过 CANCELLED 节点,将清理责任分散到多个线程;
      3. 尾节点快速移除 :如果 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 种变体):所有竞争锁失败的路径最终都走 addWaiterenq,用 CAS 尾插法保证多线程安全。enq 的哨兵 head 设计和先 prevtail 的顺序,是应对 ABA 和并发断裂的关键。

条件队列入队 (2 种变体):await 系列方法在持有锁的前提下入队,单线程操作无需 CAS,但需清理 CANCELLED 尾节点防止链表污染。

队列间转移 (3 种变体):signal/signalAll 通过 CAS 将节点从条件队列(CONDITION 状态)转移到同步队列(0 状态),转移后不一定立即唤醒,而是等待前驱释放。中断导致的被动转移是边界情况,需处理 THROW_IE/REINTERRUPT 两种中断模式。

工程实践中,避免在 tryAcquire 中嵌套锁,监控同步队列长度,确保所有 await 都有对应的 signal。AQS 的入队设计体现了"用锁简化单线程路径,用 CAS 支持高并发路径"的精妙平衡。


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

相关推荐
摇滚侠1 小时前
SpringMVC 入门到实战 处理静态资源的过程 64
java·后端·spring·maven·intellij-idea
影寂ldy1 小时前
C# 泛型委托
java·算法·c#
摇滚侠1 小时前
MyBatis 入门到项目实战 MyBatis 核心配置文件 15-19
java·tomcat·mybatis
IT WorryFree1 小时前
Zabbix 7.4 API 可同步全量参数清单(同步第三方系统专用)
java·开发语言·zabbix
RoboWizard1 小时前
一块硬盘上架前要闯多少关?
java·服务器·数据库
码云骑士1 小时前
06-Python装饰器从入门到源码(上)-闭包与自由变量
开发语言·python
半夜燃烧的香烟2 小时前
docker 安装minio nginx,配置nginx根据文根路由minio展示图片
java·nginx·docker
吴阿福|一人公司2 小时前
深度解析 Python 类变量修改的命名空间隔离
java·服务器·数据结构
zzz_23682 小时前
【Java基础】链表的七十二变——从LRU缓存到手写浏览器前进后退
java·链表·缓存