侧边栏壁纸
博主头像
牧云

怀璧慎显,博识谨言。

  • 累计撰写 157 篇文章
  • 累计创建 14 个标签
  • 累计收到 8 条评论

目 录CONTENT

文章目录

深入理解 JUC 基石:AQS 中的“CLH 队列”变体

秋之牧云
2026-04-14 / 0 评论 / 0 点赞 / 2 阅读 / 0 字

在 Java 并发编程的世界里,ReentrantLockSemaphoreCountDownLatch 等神器背后,都站着一个共同的幕后英雄——AQS(AbstractQueuedSynchronizer)

而 AQS 的核心灵魂,是一个基于 CLH 队列思想改良而来的同步队列。

很多开发者听说过 CLH,但往往困惑于:为什么 AQS 不直接用标准的 CLH?它到底改了什么?

今天,我们就配合清晰的图示,剥开复杂的外壳,聊聊 AQS 中这个“魔改”版的 CLH 队列。


一、 什么是 CLH?(银行排队的比喻)

CLH(Craig, Landin, and Hagersten)原本是一种自旋锁算法。

想象一下银行办理业务:

  • 传统锁(不公平):大家挤在门口,谁力气大谁先挤进去。后面的人可能永远挤不进去(线程饥饿)。

  • CLH 队列(公平):来了一个人,取个号,排成一条队。你只需要盯着你前面的那个人。前面的人办完业务走了(释放锁),你就知道轮到自己了。

📊 原始 CLH vs AQS 队列对比

特性

原始 CLH

AQS 队列 (CLH 变体)

结构

单向链表

双向链表

等待方式

纯自旋 (Busy Spin)

自旋 + 阻塞 (Park/Unpark)

节点状态

boolean locked

int waitStatus (多状态)

主要缺点

CPU 占用高,不支持取消

实现复杂,但功能强大

💡 核心痛点:原始 CLH 如果前面的人很久才办完,后面的人一直死循环盯着(自旋),CPU 就烧干了。而且如果中间有人想插队或退出,单向链表很难处理。

于是,Doug Lea 大神在 AQS 中对 CLH 进行了三大改造


二、 AQS 对 CLH 的三大“魔改”

改造 1:从“单向”变成“双向”

  • 原始 CLH:单向链表。节点只知道 prev(前驱)。

  • AQS 队列双向链表。节点既有 prev,也有 next

🖼️ 结构图示:

原始 CLH (单向):
[Node A] --> [Node B] --> [Node C]
(只能往后看,很难往前找)

AQS 队列 (双向):
[Head] <==> [Node A] <==> [Node B] <==> [Node C] <== Tail
(前后互通,灵活增删)

为什么要改?
在原始 CLH 中,如果中间某个人突然说“我不办了,我要走”(线程中断或超时),单向链表很难快速把他剔除,因为后面的人找不到他。
而在 AQS 的双向链表中,如果要移除一个节点,可以直接通过 prevnext 指针把前后连起来,操作非常灵活。

改造 2:从“纯自旋”变成“自旋 + 阻塞”

  • 原始 CLH:一直 while(predecessor.locked) 空转。

  • AQS 队列:先自旋检查几次,如果还没轮到,就调用 LockSupport.park() 把自己挂起(阻塞),让出 CPU。直到前一个人显式地 unpark() 唤醒你。

🖼️ 状态流转图:

graph LR
    A[尝试获取锁] -->|失败| B(加入队列)
    B --> C{前驱是 Head?}
    C -->|是| D[再次尝试获取]
    D -->|成功| E[获得锁]
    D -->|失败| F[设置前驱状态为 SIGNAL]
    C -->|否| F
    F --> G[LockSupport.park 阻塞]
    G -->|被 unpark 唤醒| H[重新尝试获取]
    H -->|成功| E

为什么要改?
Java 应用通常运行在复杂的服务器上,线程可能非常多。如果所有等待锁的线程都在死循环自旋,CPU 占用率会瞬间飙升至 100%。
阻塞(Park) 是让线程进入操作系统层面的休眠状态,不消耗 CPU 资源,只有被唤醒时才重新参与调度。这是高性能的关键。

改造 3:引入“状态机” (waitStatus)

  • 原始 CLH:节点只有一个 boolean locked 状态。

  • AQS 队列:节点有一个 int waitStatus,包含多种状态:

  • 0:初始状态。

  • SIGNAL (-1)最重要! 表示“我的后继节点在等我,我释放锁时要叫醒它”。

  • CANCELLED (1):表示“我取消了,别管我”。

  • CONDITION / PROPAGATE:用于更复杂的场景。

🖼️ 节点内部结构:

static final class Node {
    volatile Node prev;      // 前驱
    volatile Node next;      // 后继
    volatile Thread thread;  // 当前线程
    volatile int waitStatus; // 状态信号灯
    
    // 状态常量
    static final int SIGNAL    = -1; 
    static final int CANCELLED =  1;
    // ...
}

为什么要改?
原始 CLH 只是简单的“真/假”。但 AQS 需要处理取消排队共享模式(多个线程同时获取锁,如读写锁)等复杂逻辑。waitStatus 就像一个多功能信号灯,协调着节点间的协作。


三、 AQS 队列长什么样?

AQS 维护了一个 FIFO(先进先出)的双向链表,还有一个特殊的 Head 节点(虚拟头节点)

🖼️ 队列全景图:

       Head (Dummy)                     Tail
      [thread=null] <--> [Thread-A] <--> [Thread-B] <--> [Thread-C]
      waitStatus=0       ws=SIGNAL      ws=0           ws=0
         ^                  ^              ^              ^
         |                  |              |              |
      占位符,不绑定     正在持有锁      等待被唤醒      刚入队,
      任何线程         (或即将获取)     (阻塞中)        自旋中
  • Head 节点:它是一个“占位符”,不绑定任何线程。

  • 真正持有锁的线程:通常是 head.next 指向的节点(如果存在)。

  • Tail 节点:队列中最后加入的节点。

💡 为什么要有虚拟 Head?
简化代码逻辑。如果没有虚拟头,每次第一个线程入队都要特殊处理“头节点为空”的情况。有了虚拟头,入队和出队的逻辑可以统一。


四、 核心流程图解:加锁与解锁

让我们看看线程是如何在这个队列中进进出出的。

1. 加锁流程(入队与等待)

当线程 T2 尝试获取锁失败时:

  1. 封装节点:将 T2 包装成一个 Node,状态设为 0。

  2. CAS 入队:通过 CAS 操作,安全地将 T2 节点追加到 tail 后面。

  3. 自旋检查

  • T2 发现自己的前驱是 Head 吗?如果是,再试一次获取锁(万一刚才锁正好释放了呢?)。

  • 如果还不是 Head,或者获取失败,T2 会检查前驱节点的状态。

  1. 设置 SIGNAL:T2 会将前驱节点的状态改为 SIGNAL。意思是:“老兄,你用完锁记得叫我一声”。

  2. 阻塞 Park:调用 LockSupport.park(),T2 进入休眠,不再消耗 CPU。

🖼️ 入队动作分解:

步骤 1: T2 创建节点,prev 指向 Tail (Thread-A)
[Head] <==> [Thread-A] <== Tail      [Thread-B (new)]

步骤 2: CAS 更新 Tail 指向 Thread-B
[Head] <==> [Thread-A] <==> [Thread-B] <== Tail

步骤 3: Thread-A 的 next 指向 Thread-B (最终完成双向链接)
[Head] <==> [Thread-A] <==> [Thread-B] <== Tail

步骤 4: Thread-B 发现前驱不是 Head,且 tryAcquire 失败
        它将 Thread-A 的 waitStatus 改为 SIGNAL
        然后 Thread-B 调用 park() 睡觉... 💤

2. 解锁流程(唤醒后继)

当持有锁的线程 T1 (即 Head.next) 释放锁时:

  1. 修改状态:将 state 减 1(如果是重入锁)。

  2. 检查 Head:查看 Head 节点的状态。如果 Head 的 waitStatus 不是 0(说明后面有人在等),则执行唤醒。

  3. 唤醒后继

  • 找到 Head 的下一个有效节点(跳过已取消的节点)。

  • 调用 LockSupport.unpark(successor.thread)

  1. 后继苏醒:被唤醒的 T2 从 park() 方法返回,再次进入循环,尝试获取锁。如果成功,T2 成为新的 Head。

🖼️ 出队与唤行动作分解:

初始状态:
[Head(ws=0)] <==> [Thread-A(ws=SIGNAL)] <==> [Thread-B(ws=0)]

步骤 1: Thread-A 释放锁,调用 release()
        发现 head.ws != 0,准备唤醒后继

步骤 2: unparkSuccessor(head)
        找到 head.next 即 Thread-B
        调用 LockSupport.unpark(Thread-B) 🔔

步骤 3: Thread-B 被唤醒,从 park() 返回
        再次循环,发现 predecessor == head
        尝试 tryAcquire() -> 成功! ✅

步骤 4: Thread-B 设置为新的 Head (setHead)
        原 Head 和 Thread-A 断开引用,等待 GC

最终状态:
[Head(Thread-B)] <==> [null...] 
(Thread-B 现在持有锁)

五、 常见疑问解答

Q1: 既然有阻塞,为什么还叫“自旋锁”变体?

AQS 并不是纯粹的自旋锁。它在极短的时间窗口内会自旋(检查前驱状态),一旦确定需要等待较长时间,就会立即阻塞。这种**“先自旋后阻塞”**的策略,既避免了上下文切换的开销(如果只是短暂等待),又避免了长期自旋的 CPU 浪费。

Q2: 为什么解锁时要从 Tail 向前找后继?

unparkSuccessor 方法中,如果 head.next 为空或已取消,代码会从 tail 向前遍历寻找最后一个有效节点。
这是因为在并发入队时,node.prev 的赋值是原子的(CAS),但 pred.next = node 这一步可能还没执行。所以,向后遍历(next)可能断链,但向前遍历(prev)一定是完整的。这是一个经典的并发技巧。

🖼️ 为什么 Prev 靠谱,Next 不一定?

并发入队瞬间:
Thread-B 执行了: node.prev = pred (成功)
Thread-B 执行 CAS tail: 成功
Thread-B 还没执行: pred.next = node (失败或延迟)

此时:
[Thread-A] ==> null  (next 断了!)
   ^
   | (prev 是好的)
[Thread-B]

所以从 Tail 往回走 (prev) 一定能找到头,从头往后走 (next) 可能会迷路。

Q3: AQS 队列保证绝对公平吗?

是的,对于非公平锁的实现类(如默认 ReentrantLock),AQS 队列本身是 FIFO 的。
但是,“非公平锁”之所以叫非公平,是因为新来的线程在入队之前,会先尝试抢一次锁(tryAcquire)。如果抢到了,它就不用排队了;如果没抢到,才乖乖进队列排队。
一旦进入队列,就是严格的 FIFO 顺序唤醒。


六、 总结

AQS 中的 CLH 变体队列,是 Java 并发包的心脏

  • 它保留了 CLH FIFO 公平性的优点。

  • 它通过双向链表解决了节点取消的问题。

  • 它通过 park/unpark 机制解决了 CPU 浪费的问题。

  • 它通过 waitStatus 状态机实现了复杂的同步语义。

理解了这个队列,你就理解了 ReentrantLock 如何工作,CountDownLatch 如何计数,甚至 ThreadPoolExecutor 如何管理任务。下次看到 LockSupport.park(),你就会知道,线程正安静地躺在这个精妙的 CLH 变体队列中,等待着被唤醒的那一刻。

0

评论区