在 Java 并发编程的世界里,ReentrantLock、Semaphore、CountDownLatch 等神器背后,都站着一个共同的幕后英雄——AQS(AbstractQueuedSynchronizer)。
而 AQS 的核心灵魂,是一个基于 CLH 队列思想改良而来的同步队列。
很多开发者听说过 CLH,但往往困惑于:为什么 AQS 不直接用标准的 CLH?它到底改了什么?
今天,我们就配合清晰的图示,剥开复杂的外壳,聊聊 AQS 中这个“魔改”版的 CLH 队列。
一、 什么是 CLH?(银行排队的比喻)
CLH(Craig, Landin, and Hagersten)原本是一种自旋锁算法。
想象一下银行办理业务:
传统锁(不公平):大家挤在门口,谁力气大谁先挤进去。后面的人可能永远挤不进去(线程饥饿)。
CLH 队列(公平):来了一个人,取个号,排成一条队。你只需要盯着你前面的那个人。前面的人办完业务走了(释放锁),你就知道轮到自己了。
📊 原始 CLH vs AQS 队列对比
💡 核心痛点:原始 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 的双向链表中,如果要移除一个节点,可以直接通过 prev 和 next 指针把前后连起来,操作非常灵活。
改造 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 尝试获取锁失败时:
封装节点:将 T2 包装成一个 Node,状态设为 0。
CAS 入队:通过 CAS 操作,安全地将 T2 节点追加到
tail后面。自旋检查:
T2 发现自己的前驱是 Head 吗?如果是,再试一次获取锁(万一刚才锁正好释放了呢?)。
如果还不是 Head,或者获取失败,T2 会检查前驱节点的状态。
设置 SIGNAL:T2 会将前驱节点的状态改为
SIGNAL。意思是:“老兄,你用完锁记得叫我一声”。阻塞 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) 释放锁时:
修改状态:将
state减 1(如果是重入锁)。检查 Head:查看 Head 节点的状态。如果 Head 的
waitStatus不是 0(说明后面有人在等),则执行唤醒。唤醒后继:
找到 Head 的下一个有效节点(跳过已取消的节点)。
调用
LockSupport.unpark(successor.thread)。
后继苏醒:被唤醒的 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 变体队列中,等待着被唤醒的那一刻。
评论区