AQS与StampedLock
开篇:锁的底层框架是什么?
ReentrantLock、CountDownLatch、Semaphore、CyclicBarrier......JUC 里这么多同步工具,你有没有想过它们的底层是怎么实现的?
答案是几乎都建立在同一个基石之上:AbstractQueuedSynchronizer(抽象队列同步器,简称 AQS)。Doug Lea 设计了这套框架,让同步器的开发者只需要关心"什么时候能拿到资源、什么时候释放资源"这两个问题,排队、阻塞、唤醒这些脏活累活全由 AQS 包办。
可以说,理解了 AQS,就理解了 JUC 的半壁江山。
一、AQS 核心设计
1.1 两个核心要素
AQS 的内部结构可以用一句话概括:一个 state 变量 + 一个 FIFO 双向队列。
┌───────────────────────────────────────────┐
│ AQS │
│ │
│ volatile int state ← CAS 修改 │
│ │
│ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │ head │──▶│ Node │──▶│ Node │──▶ ... │
│ │(虚拟)│◀──│ t1 │◀──│ t2 │◀── ... │
│ └──────┘ └──────┘ └──────┘ │
│ CLH 变体双向队列 │
└───────────────────────────────────────────┘state 变量:一个 volatile int,表示同步状态。不同的同步器赋予它不同的含义:
| 同步器 | state 的含义 |
|---|---|
| ReentrantLock | 0 = 未锁定,>=1 = 锁定(值代表重入次数) |
| CountDownLatch | 剩余计数 |
| Semaphore | 可用许可数 |
| ReentrantReadWriteLock | 高 16 位 = 读锁重入数,低 16 位 = 写锁重入数 |
对 state 的操作通过 getState()、setState()、compareAndSetState() 三个方法完成,其中 CAS 保证了并发安全。
CLH 变体双向队列:当线程获取资源失败时,会被封装成一个 Node 节点,通过 CAS 插入到队列尾部,然后通过 LockSupport.park() 挂起。当资源被释放时,队列头部的节点会通过 LockSupport.unpark() 被唤醒,重新尝试获取资源。
Node 的关键属性:
static final class Node {
volatile int waitStatus; // 节点状态:SIGNAL(-1), CANCELLED(1) 等
volatile Node prev; // 前驱
volatile Node next; // 后继
volatile Thread thread; // 节点代表的线程
Node nextWaiter; // 条件队列的下一个节点
}1.2 为什么是双向链表?
这是一个高频面试题。用双向而不是单向链表,主要出于以下考虑:
- 高效中断支持:线程被中断时需要从队列中移除节点,双向链表可以 O(1) 找到前驱和后继,直接修改指针完成删除。
- 高效挂起判断:线程决定是否挂起前,需要检查前驱节点的状态(
shouldParkAfterFailedAcquire),直接通过node.prev获取,不需要遍历。 - 反向遍历:AQS 的很多查询方法(如
getQueueLength()、isQueued())从队尾开始向前遍历,可以减少对头部的竞争(头部是热点,经常被修改)。
1.3 独占模式 vs 共享模式
AQS 支持两种资源获取模式:
独占模式:一次只有一个线程能获取资源。典型实现:ReentrantLock。子类需要实现 tryAcquire() 和 tryRelease()。
共享模式:多个线程可以同时获取资源。典型实现:Semaphore、CountDownLatch。子类需要实现 tryAcquireShared() 和 tryReleaseShared()。
打个不太文雅但好记的比方:独占模式像女厕所的隔间,一次只能进一个人,门一关别人只能排队。共享模式像男厕所的小便池,只要还有空位就能一起用。
1.4 公平锁 vs 非公平锁
两者的区别仅在于获取资源时是否"检查队列":
- 公平锁:先看队列里有没有人排队,有就乖乖去排队。
- 非公平锁:不管队列里有没有人,上来就先 CAS 抢一把。抢到了就用,抢不到再去排队。
非公平锁的优势在于减少线程切换开销:刚释放锁的线程可能还在 CPU 上运行,新来的线程直接获取锁就能立刻执行,不需要唤醒排队线程(唤醒涉及用户态/内核态切换,代价不小)。所以 ReentrantLock 默认是非公平的。
二、基于 AQS 的工具类
2.1 ReentrantLock 源码要点
ReentrantLock 内部有一个 Sync 类继承 AQS,同时有 FairSync 和 NonfairSync 两个子类。
加锁流程(非公平):
1. CAS 尝试将 state 从 0 改为 1
2. 成功 → 设置 exclusiveOwnerThread 为当前线程,加锁完毕
3. 失败 → 进入 acquire(1)
3.1 再次 tryAcquire:
- state == 0?再 CAS 抢一次
- state != 0 但持有者是自己?重入,state + 1
3.2 都没拿到 → 封装为 Node 插入队列尾部
3.3 检查是否是 head.next,如果是就再试一次
3.4 还是没拿到 → LockSupport.park() 挂起释放锁流程:
1. state - 1
2. 如果 state == 0 → 真正释放,清除 exclusiveOwnerThread
3. 唤醒队列中 head 的后继节点2.2 CountDownLatch
生活类比:火箭发射倒计时。10、9、8......每完成一项检查就减 1,减到 0 火箭点火升空。
CountDownLatch latch = new CountDownLatch(3);
// 三个任务并行执行
executor.execute(() -> { doTaskA(); latch.countDown(); });
executor.execute(() -> { doTaskB(); latch.countDown(); });
executor.execute(() -> { doTaskC(); latch.countDown(); });
// 主线程等待所有任务完成
latch.await();
System.out.println("所有任务完成,继续!");原理:构造时将 state 设为计数值。countDown() 对 state 做 CAS 减 1,减到 0 时唤醒所有在 await() 中挂起的线程。await() 检查 state 是否为 0,不为 0 就以共享模式加入队列并挂起。
CountDownLatch 是一次性的,计数归零后无法重置。
2.3 Semaphore
生活类比:停车场。停车场有 10 个车位,每进一辆车就少一个车位(acquire),出一辆就多一个(release)。车位满了就得在外面等。
Semaphore semaphore = new Semaphore(10);
// 一家三口占 3 个车位
semaphore.acquire(3);
try {
// 使用资源
} finally {
semaphore.release(3);
}原理:构造时将 state 设为许可数。acquire() 对 state 做 CAS 减法,够减就成功,不够就排队。release() 对 state 做 CAS 加法,并唤醒排队线程。
Semaphore 也分公平和非公平版本,区别和 ReentrantLock 一样:公平版先检查队列,非公平版直接抢。
2.4 CyclicBarrier
生活类比:旅行团集合。导游说"人到齐了再出发",每到一个人就 count - 1,减到 0 所有人一起出发。而且下一站还能再集合一次(可重用)。
CyclicBarrier barrier = new CyclicBarrier(3, () -> {
System.out.println("所有人到齐,发护照!");
});
new Thread(() -> { prepare(); barrier.await(); go(); }).start();
new Thread(() -> { prepare(); barrier.await(); go(); }).start();
new Thread(() -> { prepare(); barrier.await(); go(); }).start();和 CountDownLatch 的区别:
| 特性 | CountDownLatch | CyclicBarrier |
|---|---|---|
| 计数方向 | 倒数到 0 | 倒数到 0 |
| 可重用 | 不可以 | 可以(自动重置) |
| 等待方 | 一个或多个线程等待其他线程完成 | 所有线程互相等待 |
| 到达 0 的动作 | 唤醒等待线程 | 先执行回调,再唤醒所有线程 |
| 底层实现 | AQS 共享模式 | ReentrantLock + Condition |
三、StampedLock:乐观读的锁
3.1 为什么需要 StampedLock?
ReentrantReadWriteLock 已经做了读写分离,但它有一个问题:写锁饥饿。当读锁一直被持有时,写线程可能长时间拿不到锁。而且读锁本身也有开销(要修改 state 的高 16 位)。
StampedLock(JDK 8 引入)在此基础上增加了乐观读的能力:读操作时不加锁,只拿一个"邮戳"(stamp),读完后验证邮戳是否失效。如果期间没有写操作,直接用;如果有写操作,再升级为悲观读锁。
3.2 三种模式
StampedLock lock = new StampedLock();
// 1. 写锁(独占)
long stamp = lock.writeLock();
try {
// 修改数据
} finally {
lock.unlockWrite(stamp);
}
// 2. 悲观读锁(共享)
long stamp = lock.readLock();
try {
// 读取数据
} finally {
lock.unlockRead(stamp);
}
// 3. 乐观读(无锁)
long stamp = lock.tryOptimisticRead();
// 读取数据到本地变量
int localX = x;
int localY = y;
if (!lock.validate(stamp)) {
// 验证失败,说明期间有写操作,升级为悲观读锁
stamp = lock.readLock();
try {
localX = x;
localY = y;
} finally {
lock.unlockRead(stamp);
}
}
// 使用 localX, localY乐观读适合读多写极少的场景。在大部分情况下 validate() 会返回 true(没有写操作),读操作就完全没有锁的开销。
3.3 使用注意事项
- StampedLock 不可重入。如果在持有写锁时再次请求写锁,会死锁。
- StampedLock 不支持 Condition。
- 不要在乐观读中执行有副作用的操作。因为 validate 可能失败,意味着读到的数据可能不一致。
四、AQS 的同步队列与条件队列
AQS 内部维护了两种队列:
同步队列:就是前面说的 CLH 变体双向队列,管理等待获取锁的线程。所有基于 AQS 的同步器共享这一个队列。
条件队列:通过 Condition 接口实现(ConditionObject 是 AQS 的内部类),允许线程在某个条件不满足时释放锁并等待,条件满足时被唤醒。每个 Condition 对象有自己独立的条件队列。
ReentrantLock lock = new ReentrantLock();
Condition notEmpty = lock.newCondition();
// 消费者
lock.lock();
try {
while (queue.isEmpty()) {
notEmpty.await(); // 释放锁,进入条件队列等待
}
consume(queue.poll());
} finally {
lock.unlock();
}
// 生产者
lock.lock();
try {
queue.add(item);
notEmpty.signal(); // 唤醒条件队列中的一个线程
} finally {
lock.unlock();
}await() 的关键流程:将当前线程从同步队列移到条件队列,释放锁,挂起。signal() 的关键流程:将条件队列头节点移回同步队列尾部,让它有机会重新竞争锁。
五、常见面试题精选
Q1:如何理解 AQS?
AQS 是 JUC 中构建锁和同步器的基础框架。核心是一个 volatile int state 和一个 FIFO 双向队列。线程获取资源时通过 CAS 修改 state,成功就继续执行,失败就封装为 Node 加入队列并通过 LockSupport.park() 挂起。资源释放时通过 LockSupport.unpark() 唤醒队头节点。ReentrantLock、Semaphore、CountDownLatch 等都是基于 AQS 实现的。
Q2:AQS 为什么用双向链表?
三个原因:1)高效删除节点(中断时需要移除节点,双向链表 O(1) 找到前驱);2)挂起前需要检查前驱节点状态(shouldParkAfterFailedAcquire 直接 node.prev);3)很多查询方法从尾部向前遍历,避免与头部的高频修改产生竞争。
Q3:CountDownLatch、CyclicBarrier、Semaphore 区别?
CountDownLatch 是一次性计数器,一个或多个线程等待其他线程完成;CyclicBarrier 是可重用屏障,所有线程互相等待到齐后一起出发;Semaphore 是信号量,控制同时访问资源的线程数。前者基于 AQS 共享模式,CyclicBarrier 基于 ReentrantLock + Condition,Semaphore 基于 AQS 共享模式。
Q4:AQS 是如何实现线程的等待和唤醒的?
获取锁失败的线程被封装为 Node 加入同步队列,然后通过 LockSupport.park() 挂起。释放锁时通过 LockSupport.unpark() 唤醒队列中 head 的后继节点,让它重新尝试获取锁。条件等待使用 Condition.await() 将线程移入条件队列并释放锁,signal() 将线程从条件队列移回同步队列。
小结
| 组件 | 核心思想 | 底层 |
|---|---|---|
| AQS | state + CLH 队列 | CAS + park/unpark |
| ReentrantLock | 独占锁,可重入 | AQS 独占模式 |
| CountDownLatch | 倒计时,一次性 | AQS 共享模式 |
| Semaphore | 信号量,限流 | AQS 共享模式 |
| CyclicBarrier | 屏障,可重用 | ReentrantLock + Condition |
| StampedLock | 乐观读 + 悲观读写 | 独立实现(非 AQS) |
一句话总结 AQS:用 CAS 改状态,用队列管线程,用 park/unpark 控阻塞。掌握了这三件套,JUC 的大部分同步器对你来说就都是纸老虎了。
附录:AQS 源码关键流程详解
A.1 acquire 方法(独占获取)
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}三步走:
tryAcquire(arg):子类实现,尝试获取资源。成功就直接返回。addWaiter(Node.EXCLUSIVE):获取失败,把当前线程封装为独占模式的 Node,CAS 插入队列尾部。acquireQueued(node, arg):在队列中自旋 + park 等待,直到获取到资源。
A.2 addWaiter 入队
private Node addWaiter(Node mode) {
Node node = new Node(Thread.currentThread(), mode);
Node pred = tail;
if (pred != null) {
node.prev = pred;
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
enq(node); // 快速路径失败,进入完整入队(含初始化)
return node;
}先尝试快速 CAS 追加到尾部。如果队列尚未初始化或 CAS 失败,进入 enq() 做自旋入队。
A.3 acquireQueued 自旋等待
final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
for (;;) {
final Node p = node.predecessor();
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null;
failed = false;
return false; // 获取成功
}
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
// 被中断唤醒
throw new InterruptedException();
}
} finally {
if (failed) cancelAcquire(node);
}
}关键逻辑:如果前驱是 head,就再 tryAcquire 一次(因为 head 可能刚释放锁)。否则检查前驱状态,确认自己可以安全挂起后,调用 LockSupport.park() 进入等待。
A.4 shouldParkAfterFailedAcquire
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
int ws = pred.waitStatus;
if (ws == Node.SIGNAL) return true; // 前驱已经是 SIGNAL,可以安全挂起
if (ws > 0) {
// 前驱被取消了,跳过它
do { node.prev = pred = pred.prev; } while (pred.waitStatus > 0);
pred.next = node;
} else {
// 将前驱设为 SIGNAL
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
return false;
}这个方法的核心作用:确保前驱节点的 waitStatus 是 SIGNAL(-1),这样前驱释放锁时才会唤醒后继节点。同时顺便清理已取消的节点。
A.5 release 释放(独占)
public final boolean release(int arg) {
if (tryRelease(arg)) {
Node h = head;
if (h != null && h.waitStatus != 0)
unparkSuccessor(h); // 唤醒 head 的后继节点
return true;
}
return false;
}tryRelease 由子类实现(如 ReentrantLock 的 Sync),成功后唤醒队列中的下一个等待节点。
A.6 Condition 的 await 与 signal
await() 的关键步骤:
- 将当前线程封装为 Node,加入条件队列尾部。
- 调用
fullyRelease()完全释放锁(包括重入),保存释放前的 state。 - 循环检查:如果节点不在同步队列中,就
LockSupport.park()挂起。 - 被唤醒后,调用
acquireQueued()重新竞争锁,拿到后恢复之前的 state。
signal() 的关键步骤:
- 取条件队列的头节点。
- 将该节点从条件队列移到同步队列尾部(
transferForSignal)。 - 如果前驱节点状态异常或 CAS 设置 SIGNAL 失败,直接 unpark 唤醒。
A.7 公平锁与非公平锁的代码差异
两者唯一的区别在 tryAcquire 中:
// 非公平锁
if (c == 0) {
if (compareAndSetState(0, acquires)) { ... } // 直接抢
}
// 公平锁
if (c == 0) {
if (!hasQueuedPredecessors() && // 先检查队列
compareAndSetState(0, acquires)) { ... }
}hasQueuedPredecessors() 检查同步队列中是否有比当前线程等待更久的节点,有就不抢,乖乖排队。