JAVA · Vol.II · DAY 16 · 并发编程
AQS 框架与 ReentrantLock:独占锁、公平锁、Condition
面试官:你知道 Java 里的锁底层是怎么实现的吗?
在 java.util.concurrent(JUC)包中,几乎所有同步工具背后都站着同一个"幕后大佬"——AQS(AbstractQueuedSynchronizer,抽象队列同步器)。这个由并发大师 Doug Lea 设计的抽象框架,是整个 JUC 锁体系的基石:
- ReentrantLock —— 可重入的独占锁,AQS 最经典的实现
- CountDownLatch —— 倒计数器,state 从 N 递减到 0 时释放所有等待线程
- Semaphore —— 信号量,state 表示可用许可数
- ReentrantReadWriteLock —— 读写锁,state 高 16 位存读锁计数、低 16 位存写锁计数
- ThreadPoolExecutor 的 Worker —— 内部也通过 AQS 实现不可重入锁
理解 AQS = 理解 JUC 的一半。核心思想极其精炼:
一个 volatile int state(同步状态) + 一条 FIFO 双向队列(CLH 变体) + 模板方法模式(子类重写 tryAcquire / tryRelease)
本文以 ReentrantLock 为主线,先用一个「餐厅排号」类比把整套机制串起来,再逐行拆解 lock()、unlock()、公平/非公平、Condition 全流程,中途专门用一站回答一个高频追问——竞争最终都收敛到 state 那一次 CAS,AQS 凭什么还更快,最后横向串联其他 AQS 工具和生产落地要点。
AQS 核心结构:state + CLH 队列
AQS 的全部魔力建立在两个核心成员之上。先看源码:
public abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer {
// 同步状态:ReentrantLock 中 0=空闲,>0=重入次数;Semaphore 中=许可数
private volatile int state;
// CLH 变体队列的头尾节点
private transient volatile Node head;
private transient volatile Node tail;
// 独占模式:子类重写
protected boolean tryAcquire(int arg) { throw new UnsupportedOperationException(); }
protected boolean tryRelease(int arg) { throw new UnsupportedOperationException(); }
// 共享模式:CountDownLatch / Semaphore 重写
protected int tryAcquireShared(int arg) { ... }
protected boolean tryReleaseShared(int arg) { ... }
}
Node 是 CLH 队列的节点单元,包含五个关键字段:
static final class Node {
static final int CANCELLED = 1; // 已取消(超时/中断)
static final int SIGNAL = -1; // 后继需要被 unpark
static final int CONDITION = -2; // 在 Condition 队列中
static final int PROPAGATE = -3; // 共享模式传播唤醒
volatile Node prev; // 前驱(取消时回溯找有效节点)
volatile Node next; // 后继(unpark 目标)
volatile Thread thread;
Node nextWaiter; // Condition 后继 / 独占or共享标识
}
state 的修改通过 compareAndSetState() CAS 操作保证原子性,但 volatile 保证了可见性:当一个线程 CAS 修改 state 后,其他线程通过普通读(getState())能立刻看到最新值,而不需要额外加锁。没有 volatile,线程可能读到 CPU 缓存中的旧值。这也呼应了 JMM 篇的结论:并发工具的正确性 = 原子性(CAS)+ 可见性(volatile),一个都不能少。
餐厅排号:把 AQS 一次讲透
在逐行读源码之前,先花三分钟用一个餐厅把整套机制映射一遍——后面每段源码你都能对上图:
| 餐厅场景 | AQS 对应 | 一句话解释 |
|---|---|---|
| 「今天还有没有空位」的牌子(0=满,N=还有 N 个) | volatile int state | 所有线程都看这块牌子决定自己能不能坐下 |
| 取号 → 排队 → 等叫号 | CLH 变体队列 | 抢不到位置的线程排进 FIFO 队列 |
| 顾客坐下休息(不是站着原地转圈) | LockSupport.park() | 等叫号时线程挂起,不烧 CPU |
| 服务员喊号、领位 | unpark() + acquireQueued | 空位出来后唤醒队首线程去抢 |
| 每家店的「座位规则」不同:单人桌一家一客 / 卡座可拼桌 | tryAcquire / tryAcquireShared | state 的语义由子类定义(独占 vs 共享) |
| 领位台(统一管排队、喊号、处理弃号) | AQS 父类框架 | 排队/挂起/唤醒逻辑写一次,所有店复用 |
| 「等位区」可以开好几个:等包间的、等卡座的分开等 | 多个 Condition 队列 | 同一把锁可以挂多条独立等待队列,精确唤醒 |
这个类比里最关键的洞察是:「座位规则」和「排队管理」被彻底分开了。一家新餐厅开张(新同步工具),只需要定义自己的座位规则(重写 4 个 tryXxx 方法),排队、喊号、处理弃号这些最繁琐最容易出错的活儿,全部交给领位台(AQS 父类)——这就是模板方法模式,第 14 站展开。
CLH 队列:从自旋到 park 的进化
原始 CLH(Craig, Landin, Hagersten)是一种自旋锁队列:每个节点只包含一个 locked 字段,线程通过自旋(while(node.prev.locked))检查前驱状态。自旋的优点是响应快(无需内核态切换),但在锁竞争激烈的场景下,大量线程持续空转,CPU 利用率极高但有效吞吐为零。
AQS 做了三个关键改造,从根本上解决了自旋锁的缺陷:
- 变单向为双向:增加
prev指针,用于取消节点时向前回溯跳过已取消的前驱 - 自旋改 park/unpark:不再忙等,调用
LockSupport.park()将线程挂起——注意线程状态进入的是 WAITING(等通知),而不是等锁的 BLOCKED;unpark()唤醒(恢复到 RUNNABLE)。park/unpark 底层依赖操作系统的 Mutex/Condition 或 Linux 的 futex - 增加 waitStatus:节点携带 SIGNAL、CANCELLED、CONDITION、PROPAGATE 等精细状态,精确控制唤醒时机
当线程被中断或超时取消时,需要从队列中"摘除"。如果只有 next,无法找到前驱修补链路。有了 prev,取消的节点沿 prev 向前找到第一个非取消前驱,把自己的 prev 指过去,同时更新前驱的 next,完成摘除。这就是 cancelAcquire() 的核心逻辑。
ReentrantLock.lock() 全流程拆解(NonfairSync)
ReentrantLock 默认是非公平锁(NonfairSync)。调用 lock() 时的完整流程:
final void lock() {
// ① 上来先 CAS 抢锁,不管队列有没有人 —— "插队"尝试
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1); // ② CAS 失败,走 AQS 标准流程
}
acquire(1) 是 AQS 的模板方法,依次调用子类 tryAcquire()、失败则入队 park:
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt(); // 等待中被中断过,补上中断标记
}
NonfairSync 的 tryAcquire() 委托给 Sync.nonfairTryAcquire():
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
// ③ state==0 锁空闲,CAS 抢占
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
} else if (current == getExclusiveOwnerThread()) {
// ④ 当前线程已持有 → 重入,state+1
int nextc = c + acquires;
setState(nextc); // 无需 CAS(只有自己能改)
return true;
}
return false; // ⑤ 被其他线程持有,获取失败
}
tryAcquire 失败后,AQS 执行 addWaiter() 入队,然后进入 acquireQueued() 自旋+park 循环:
final boolean acquireQueued(Node node, int arg) {
boolean failed = true;
try {
boolean interrupted = false;
for (;;) {
final Node p = node.predecessor();
// 前驱是 head 才有资格尝试获取锁
if (p == head && tryAcquire(arg)) {
setHead(node); // 成功,自己成为新 head
p.next = null; // 旧 head 断开,帮助 GC
failed = false;
return interrupted;
}
// 获取失败:判断是否应 park
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed) cancelAcquire(node);
}
}
检查前驱的 waitStatus。如果是 SIGNAL(-1),说明"我释放时会通知你",可以放心 park。如果前驱已取消(>0),沿 prev 跳过所有取消节点重新链接。如果前驱既不是 SIGNAL 也不是 CANCELLED,则 CAS 设为 SIGNAL,但不立即 park,再自旋一次尝试获取锁。
lock() → CAS(0→1) 成功则直接获得锁;失败 → acquire(1) → tryAcquire(CAS + 重入检查)→ 失败 → addWaiter 入队 → acquireQueued 自旋+park → 被 unpark 后重试 → 成功则将自身设为 head。
可重入性:同一把锁,自己人进来自如
先看一个没有可重入性会直接死锁的场景:
public void a() {
lock.lock();
try {
b(); // b() 内部还要 lock.lock() —— 同一线程抢自己持有的锁
} finally {
lock.unlock();
}
}
public void b() {
lock.lock(); // 若锁不可重入:当前线程永远等不到"释放",死锁
try {
// ...
} finally {
lock.unlock();
}
}
递归算法、持有锁的方法调用另一个也加锁的方法——Java 代码里这种嵌套极其常见。可重入性让「同一线程重复加锁」合法化,靠的就是 nonfairTryAcquire() 里那个分支:
加锁:owner == 当前线程 → state + 1(不抢 CAS,直接 setState)
解锁:state - 1;只有减到 0 才真正释放(清 owner + unpark 后继)
outerThread: lock.lock(); // state: 0 → 1(CAS 抢占,owner = outerThread)
outerThread: lock.lock(); // state: 1 → 2(重入检查通过,owner 还是自己)
outerThread: lock.unlock(); // state: 2 → 1 ★ 锁没真正释放,没人被唤醒
outerThread: lock.unlock(); // state: 1 → 0 ★ 真正释放,unparkSuccessor 唤醒队首
注意第二站源码里的细节:重入分支用的是 setState(nextc) 而不是 CAS——因为只有 owner 自己才会走这个分支改 state,不存在竞争,省掉一次 CAS。unlock() 时的 tryRelease 同样直接 setState,并先校验 Thread.currentThread() == getExclusiveOwnerThread(),不是持有者就抛 IllegalMonitorStateException(线程 A 锁了线程 B 的锁,直接拒绝)。
synchronized 也是可重入的(重入计数存在 monitor 里,JVM 管),行为一致。区别在解锁:synchronized 出代码块自动减计数,ReentrantLock 必须手动 unlock()——少调一次,锁就永远不释放(第 16 站的坑)。
公平锁 vs 非公平锁:一行代码的差异
通过构造函数选择模式,默认非公平:
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
// FairSync 的 tryAcquire —— 比 NonfairSync 多了一个检查
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
// ★ 关键差异:先检查队列中是否有人排队
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
} else if (current == getExclusiveOwnerThread()) {
setState(c + acquires); // 重入
return true;
}
return false;
}
// AQS 提供的判断方法
public final boolean hasQueuedPredecessors() {
Node h = head, t = tail, s;
// 队列非空 且 我不是第一个等待者 → 有人排在我前面
return h != t &&
((s = h.next) == null || s.thread != Thread.currentThread());
}
非公平锁的 lock() 第一步就是无条件 CAS——不管队列里有没有人,上来就抢。这就是"非公平"的含义:刚来的线程可以"插队"到已排队线程前面。
| 维度 | 非公平锁(NonfairSync) | 公平锁(FairSync) |
|---|---|---|
| lock() 第一步 | 无条件 CAS(0→1),直接抢锁 | 先 hasQueuedPredecessors(),没人排队才 CAS |
| 吞吐量 | 高(减少 park/unpark 上下文切换) | 较低(严格按序,即使锁刚释放也排队) |
| 饥饿风险 | 存在(某些线程可能长期抢不到) | 基本没有(FIFO 保证公平) |
| 适用场景 | 绝大多数场景(默认选择) | 资源分配需严格公平(订单处理、任务调度) |
核心原因是减少线程上下文切换。假设线程 A 刚释放锁,线程 B 正好来请求:
- 非公平:B 直接 CAS(0→1) 成功,立刻执行。省去 park → unpark → 调度 → 恢复的全部开销
- 公平:B 发现队列中有线程 C,必须入队。最终 C 被 unpark → 调度 → 获锁 → 释放 → 再 unpark B,多一次完整上下文切换
还要补一句常被忽略的:非公平的「插队」是尽力而为——B 只是多了一次 CAS 机会,抢不到照样排队,不会一直插到系统崩溃;而公平锁也并非「绝对无饥饿」,它保证的是请求顺序,唤醒后仍要竞争。面试时说清「非公平 ≠ 乱来,公平 ≠ 绝对」,区分度立刻拉满。
"公平和非公平的区别在于 tryAcquire 中是否调用 hasQueuedPredecessors()。非公平允许刚到的线程直接 CAS 抢锁,减少 park/unpark 开销,吞吐量更高;缺点是可能饥饿。公平严格按 FIFO,保证不饥饿但性能较低。绝大多数场景用非公平即可。"
lockInterruptibly / tryLock:synchronized 给不了的能力
lock() 一旦调用就是「死等」——等多久不知道,中断信号也照单不拒(只是最终补个标记)。生产里经常需要更灵活的控制,ReentrantLock 给出三个变体:
// ① 可中断:等待锁的过程中响应中断(内部 acquireInterruptibly,
// park 后检查中断状态并抛 InterruptedException)
try {
lock.lockInterruptibly();
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // 惯例:恢复中断标记,交给上层决策
return; // 本轮不抢了(比如任务已被取消)
}
try {
// ...临界区
} finally {
lock.unlock();
}
// ② 超时:最多等 N 秒,抢不到就走兜底
if (lock.tryLock(1, TimeUnit.SECONDS)) {
try {
// ...临界区
} finally {
lock.unlock();
}
} else {
log.warn("抢锁超时,走降级逻辑");
// 返回缓存 / 降级数据 / 交给重试队列
}
// ③ 非阻塞:抢到就干,抢不到立刻走人
if (lock.tryLock()) {
try {
// ...临界区
} finally {
lock.unlock();
}
}
| 方法 | 等不到锁时 | 响应中断 | 典型场景 |
|---|---|---|---|
lock() | 无限期等待 | 不响应(事后补标记) | 必须拿到锁才能干,且任务可无限等 |
lockInterruptibly() | 无限期等待,但可被中断 | 响应,抛 InterruptedException | 可取消的长任务(订单履约、批处理) |
tryLock(timeout, unit) | 超时放弃 | 响应 | 有兜底策略的抢锁(缓存刷新、分布式协调) |
tryLock() | 立刻放弃 | 不等待 | 乐观尝试:行就行,不行走别的路径 |
synchronized 的等待是「黑箱等待」:进去就要等,等不等到、等多久,业务代码完全无法干预。而「可中断 + 可超时」让锁等待变成业务流程的一部分——可以被取消、可以设上限、可以降级。凡是「锁等待可能很长」或「任务生命周期受外部控制」的场景(MQ 消费、定时任务、用户可取消的操作),都应该优先想到这三个变体。
unlock() 流程:释放锁与唤醒后继
unlock() 委托给 AQS 的 release() 模板方法:
public void unlock() { sync.release(1); }
// AQS.release() 模板方法
public final boolean release(int arg) {
if (tryRelease(arg)) {
Node h = head;
if (h != null && h.waitStatus != 0)
unparkSuccessor(h); // 唤醒后继
return true;
}
return false;
}
// ReentrantLock.Sync.tryRelease()
protected final boolean tryRelease(int releases) {
int c = getState() - releases;
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = (c == 0);
if (free) setExclusiveOwnerThread(null);
setState(c); // 无需 CAS:只有持有者会修改 state
return free;
}
unparkSuccessor() 从 tail 往前找第一个非取消节点并唤醒:
private void unparkSuccessor(Node node) {
if (node.waitStatus < 0)
compareAndSetWaitStatus(node, node.waitStatus, 0);
Node s = node.next;
if (s == null || s.waitStatus > 0) {
s = null;
for (Node t = tail; t != null && t != node; t = t.prev)
if (t.waitStatus <= 0) s = t;
}
if (s != null) LockSupport.unpark(s.thread);
}
入队的 enq() 分两步:① node.prev = tail(CAS),② oldTail.next = node。两步间有时间窗口——tail 已指向新节点,但旧 tail 的 next 还没指过来。从 head.next 往后遍历可能在中间断裂。而从 tail 沿 prev 往前找是安全的,因为 prev 在 CAS 之前就已赋值。
unlock() → release(1) → tryRelease:state-1 → 若 state==0 清除 owner → unparkSuccessor:从 tail 往前找有效节点 → LockSupport.unpark() → 被唤醒线程在 acquireQueued 循环中重新竞争锁。
acquire / release 全景:一张图串起前后五站
前面五站把 lock 和 unlock 拆开讲了,这一站把两条路径合成一张图。面试时能徒手画出这张图,AQS 就算过关了:
unpark 只是「你被叫到号了,去抢」——被唤醒的线程要回到 acquireQueued 循环里重新 tryAcquire。因为 unpark 和它实际被调度执行之间有延迟,这期间锁可能已被别人抢走(比如释放方 unpark 了队首,队首却还没跑起来,而另一个新来的线程非公平 CAS 抢成功了)。park/unpark 是「唤醒」不是「授予」,锁永远靠竞争获得——这是 AQS 和「信号量式直接分配」的本质区别。
追问:锁竞争最后不还是变成 CAS 竞争吗?
这句话里混着三个问题,拆开才好回答:① 事实——真正对 state 发 CAS 的有几个线程、每人发几次?② 成本——一次 CAS 争抢和一次锁争抢,钱分别花在哪?③ 取舍——为什么裁决用 CAS,而排队不用 CAS?
竞争是业务给定的约束(互斥资源只有一份),谁也消不掉,只能安排。
AQS 的安排是:把「N 个线程争一把锁」重组成「1 个线程赢 + N−1 个线程睡」——CAS 只回答「这一刻谁赢」这一个窄问题,输的人试一两次就退场去 park;
真正扛住那 N−1 份压力的是内核的等待队列,那里睡着不烧 CPU。
① 事实层:把 AQS 的争抢点一个一个数清楚
「所有线程都在抢 state」这一步推理就不成立。回到第 5 站那段循环,盯住一个此前被一笔带过的条件:
final Node p = node.predecessor();
if (p == head && tryAcquire(arg)) { // ★ 全队列只有队首这 1 个线程会发 CAS(state)
setHead(node); ...
}
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt()) // ★ 抢不到就去睡,不在这里自旋重试
interrupted = true;
p == head 这四个字符就是本题的一半答案:一条几千个节点的队列里,任何时刻只有队首那一个线程会去调 tryAcquire、才会对 state 发 CAS,因为它后面的人全停在 LockSupport.park() 里睡觉。Doug Lea 在类注释里把这件事写得比任何解读都清楚(JDK 8 与 JDK 17 措辞一致):
* A thread may try to acquire if it is first in the queue. But being
* first does not guarantee success; it only gives the right to contend.
→ 排到队首拿到的不是锁,只是「再抢一次」的资格。
* ...while acquires do not "spin" in the usual sense, they may perform
* multiple invocations of tryAcquire interspersed with other
* computations before blocking. This gives most of the benefits of spins
* when exclusive synchronization is only briefly held, without most of
* the liabilities when it isn't.
→ 获取不是通常意义上的自旋:几次尝试之间穿插的是「建节点、挂 tail、改状态」这些记账动作。
锁只被持有很短时间时,能吃到自旋的好处;持有时间长时,又不背上自旋的包袱。
* // JDK 17 重写版补了一句更直接的:
* Except in this case, AQS locks do not spin.
→ 除了队首那几次重试,AQS 的锁不自旋。
把整条获取路径上的原子操作全列出来,才知道「争抢」到底被摊成了几份:
| 争抢点 | 谁参与 | 每人几次 | 争抢窗口 |
|---|---|---|---|
读 state(getState()) | 全部 N 个线程 | 1~2 | 不是 CAS,普通 volatile 读,只共享缓存行 |
| ① CAS(state) 抢锁 | 插队的新线程 + 队首 1 个 | 1~2(非公平 lock() 先抢一次,tryAcquire 再一次) | 一条指令 |
| ② CAS(tail) 入队 | 所有获取失败的线程 | 1~2(addWaiter 快路径 + enq 重试) | 几条指令,一次排队只做这么一两次 |
| ③ CAS(前驱 waitStatus) | 每个等待者对自己的前驱 | 1 | 点对点,不存在全局热点变量(第 12 站) |
| ④ setHead 换哨兵 | 刚赢的那个线程 | 1 | 独占,几乎无竞争 |
| 重入 +1 / 解锁 −1 | 只有 owner | 0 次 CAS | 直接 setState(第 6、9 站) |
再补一个细节:JDK 17 把「允许多抢几次才去睡」写成了显式的指数退避,而且重试资格只发给队首:
byte spins = 0, postSpins = 0; // retries upon unpark of first thread
...
} else if (first && spins != 0) {
--spins; // 只有队首允许多抢几次;reduce unfairness on rewaits
Thread.onSpinWait();
} else if (node.status == 0) {
node.status = WAITING; // 登记「我要睡了」,再复查一次
} else {
spins = postSpins = (byte)((postSpins << 1) | 1); // 1,3,7,…,255 指数增长
LockSupport.park(this); // 这次是真睡了
}
沿赢家视角走一遍:抢到锁之后,重入用 setState、释放也用 setState,一条 CAS 都不发。所以稳态下每秒对 state 的 CAS 次数 ≈ 锁的交接次数 ×(1 + 插队人数),而交接次数被临界区时长天然限死——临界区 1ms,一秒最多交接 1000 次。队列里排 9 个人和排 999 个人,state 上的 CAS 频率一模一样。
对比 AtomicLong.getAndIncrement():1000 个线程各自在 do-while 里自旋到成功,一秒能往同一条缓存行上砸上亿次 CAS。同一个「只有一个变量的 CAS 争抢」,AQS 的频率由锁的持有周期决定,AtomicLong 的频率由请求量决定——这才是「竞争转移到 CAS」这句话只说对一半的地方。
② 成本层:争抢没消失,但换了计价方式
| 竞争失败的处理方式 | 单次代价 | 陷入内核 | 让出 CPU | 随竞争人数 |
|---|---|---|---|---|
| 失败后自旋重试(AtomicLong / Semaphore 释放侧) | 几十纳秒 | 否 | 否,占着核继续撞 | 线性放大 |
| 失败后入队 park(AQS) | 10~100 微秒 | 是(futex_wait / futex_wake) | 是 | 睡 10 个和睡 1000 个一样便宜 |
看着像矛盾:park 贵三个数量级,为什么反而更好?因为决定系统生死的是「有没有人白占 CPU」,不是「哪条指令更快」。自旋方案里输的人不会退出,它们占着核一直撞:1000 个线程抢 1 个资源,机器上有几个核就有几个核在做无用功,浪费的 CPU 随竞争线程数线性增长;AQS 方案里那 999 个线程睡在内核等待队列上,CPU 全部留给真正在跑临界区的线程。AQS 用「赢家的几十纳秒」+「输家的几十微秒延迟」,换掉了「整台机器一起空转」——这就是同一句「竞争转移到 CAS」背后被忽略的那半句:只有一小撮竞争转移到了 CAS,大头转移到了调度器那里排队,而排队是免费的。
失败 CAS 本身只是一条指令返回 false,贵的是 lock cmpxchg 要把那条缓存行从别的核抢回来(MESI 失效 + 重新拉取),多个核轮流抢同一行就是来回弹。所以「CAS 争抢」在硬件层的真实形态是缓存行迁移次数。诚实说,AQS 并没有优化这一点:JDK 8 和 17 里 head、tail、state 三个 volatile 字段紧邻声明在同一个对象上,没有 @Contended 填充,大概率落在同一条 64 字节缓存行里——入队改 tail 和抢锁改 state 会互相把对方的副本打失效。它敢这么写,是因为上面的限流效应已经把频率压下去了;而 LongAdder 面对的是真·高频竞争,所以老老实实给每个 Cell 填了 padding(CAS 篇第 14 站)。要不要为缓存行花钱,取决于你每秒撞多少次——这两个类的竞争强度根本不是一个量级。
③ 取舍层:为什么裁决用 CAS,排队却不用「一把真锁」
- 鸡生蛋问题:AQS 本身就是用来实现锁的框架。如果用
synchronized或内核 mutex 去保护 state,那「抢 AQS 的锁」就退化成「先进内核抢一把锁」,用户态排队当场失去意义。任何锁实现最终都必须落到一个不可再分的硬件原子操作上,lock cmpxchg就是这个终点(CAS 篇第 3 站)。这不是 AQS 的偏好——synchronized的轻量级锁第一步同样是 CAS 把 Mark Word 替换成指向栈上 Lock Record 的指针(synchronized 原理篇;写线程 ID 那是偏向锁的活儿,JDK 15+ 已移除),整个 Java 并发体系收敛在同一个底座上。 - 颗粒度刚好合适。AQS 类注释开宗明义:这个框架专为 "synchronizers that can rely on
intstate" 设计。一个 int、一条指令就能裁完,正是 CAS 的甜区。反过来「排队」做不到——入队要改三根指针,一条指令装不下,所以只能用 CAS(tail) + 失败重试的乐观入队,窗口只有几条指令,失败方重试也没有副作用。 - 把贵的资源留给对的事。裁决要的是「快」,等待要的是「不占 CPU」。让赢家用几十纳秒定输赢,让输家用几十微秒去睡,两套成本各归其位。反过来两种纯方案都输得很惨:全靠自旋(等待也靠抢)→ 高竞争时 CPU 打满、吞吐不涨;全靠阻塞(连裁决都要 park)→ 临界区只有 100ns 的场景会被上下文切换吃掉全部性能。JDK 8 那个
spinForTimeoutThreshold = 1000L就是把这条分界线钉死:剩余超时不足 1 微秒时宁可自旋,也别 park。
④ 边界层:AQS 什么时候真会被争抢拖垮
前面三段讲的是「设计如何限制争抢」,不是「争抢不存在」。四种情况要心里有数:
- 非公平锁 + 极短临界区 + 高并发 = 插队风暴。JDK 把这套默认策略叫 barging,也写作 greedy / renouncement / convoy-avoidance(避免护航效应),注释承认它 "not guaranteed to be fair or starvation-free"。队首线程醒来发现锁又被抢走,只能 rewait——这是第 7 站那句「非公平 ≠ 乱来」的另一半代价。解药就是公平锁的
hasQueuedPredecessors()和 JDK 17 那个最多 255 次的指数退避。 - 共享模式的释放侧没有闸门,才是 AQS 家族真正的自旋热点。获取侧的
doAcquireShared同样要p == head才试;但tryReleaseShared是每个释放者都要跑的for (;;) { … CAS(state) }(第 15 站源码里 CountDownLatch、Semaphore 都长这样,没有闸门)。1000 个线程各countDown()一次,就是 1000 次 CAS 自旋砸在同一个 state 上——这已经退化成 AtomicLong 的用法了。所以别拿 CountDownLatch / Semaphore 当高并发计数器,那是 LongAdder 的活。 - 取消风暴。
tryLock(timeout)、lockInterruptibly用得太密时,超时与中断会触发cancelAcquire反复 CAS 摘节点,JDK 17 注释写着每次清理要 "traverses the queue until a clean sweep"(一路扫到干净为止)。队列越长、超时越密集,排队管理的开销被放大得越厉害——超时值别拍脑袋设成贴着临界区的毫秒级。 - 反过来,「争抢变成可观测的资源」是 AQS 白送的红利。
hasQueuedThreads()(当前是否有人排队)、getQueueLength()、hasContended()(是否曾经阻塞过,实现就一句head != null,常数时间)都能直接接监控。JDK 注释甚至建议高吞吐路径上先预检查再决定要不要抢——反正已经有人在排队了,多发一次 CAS 大概率也抢不到。
jstack 里一片
WAITING (parking)、栈顶是 LockSupport.park,但 CPU 不高 → 锁被长时间持有,park 兜底正在起作用,优化方向是缩短临界区(把 IO、RPC 挪出锁外)。CPU 打满、吞吐不涨,热点栈在
compareAndSetState / lock cmpxchg → 竞争退化成了自旋,优化方向是分散热点(分段锁、LongAdder 思路)或降低抢的频率(一次批量拿多个许可)。"竞争不会消失,只能被重新安排。AQS 里对 state 的 CAS 被 p == head 这道闸门限死了:队列中只有队首那一个线程有资格再试,其余全在 park;而输的人试一两次也去排队睡觉,不自旋。赢家之后的重入和释放都走 setState,一条 CAS 都不发。所以对 state 的 CAS 频率约等于锁的交接频率,和排队人数无关——这是它和 AtomicLong 单点自旋最本质的区别。剩下的 N−1 份压力交给内核等待队列,睡着不占 CPU。
所以 AQS 的可扩展性不来自 CAS,来自那条把「抢」变成「等」的队列;CAS 在这里只是最便宜的那个仲裁器——因为 AQS 本身就是实现锁的框架,用锁去保护 state 会循环依赖,最终必须落到 lock cmpxchg 这个硬件原语上。
代价也讲得清:唤醒后仍要重新竞争、可能饥饿(所以要公平锁和指数退避),以及共享模式的释放侧没有闸门、是真自旋热点(所以高并发计数该用 LongAdder 而不是 Semaphore)。"
waitStatus 四状态:每个节点头上的「小纸条」
队列节点上的 waitStatus 是每个节点携带的状态标记,面试最爱考「SIGNAL 是谁的状态」。一张表说清:
| 状态 | 值 | 含义 | 谁在什么时候写它 |
|---|---|---|---|
0 | 0 | 初始值;tail 节点默认 0;head 哨兵成功后置 0 | 节点创建时 |
SIGNAL | -1 | 「我释放锁时会 unpark 你」——写在前驱节点上,给后继看的承诺 | 后继入队/获取失败时,CAS 到前驱上 |
CANCELLED | 1 | 节点已取消(被中断、超时、取消),必须被后续操作跳过 | cancelAcquire() |
CONDITION | -2 | 节点此刻在 Condition 条件队列里,不在 sync 队列 | await() 加入条件队列时 |
PROPAGATE | -3 | 共享模式专用:「这次释放的唤醒已经传播完毕」,防止最后一个等待者漏唤醒 | doReleaseShared() |
两个易错点:
- 负数 = 正常,正数 = 异常。判断代码里到处是
waitStatus < 0(正常节点)、waitStatus > 0(取消节点),CANCELLED 是唯一正数,用大小比较而不是等值比较来筛。 - SIGNAL 是前驱的承诺,不是自己的状态。「节点 A 的 waitStatus == SIGNAL」意思是「A 释放后会唤醒 A 的后继」。面试答反了就前功尽弃。
PROPAGATE 只在共享模式(Semaphore、ReadWriteLock 的读锁)里出现:共享释放可能要连续唤醒多个等待者,JDK 用「唤醒传播 + 最后一个节点置 PROPAGATE」的方式保证新入队的线程不会漏掉这次释放——独占锁(ReentrantLock)永远用不到它,知道「它是共享模式的补丁」即可,不必深究每个分支。
Condition 条件队列:两条队列的协作
newCondition() 创建的 ConditionObject 是 AQS 内部类,维护一条独立于 sync 队列的条件等待队列——AQS 最精巧的设计之一。
public final void await() throws InterruptedException {
Node node = addConditionWaiter(); // ① 加入条件队列
int savedState = fullyRelease(node); // ② 完全释放锁(保存重入次数)
while (!isOnSyncQueue(node)) {
LockSupport.park(this); // ③ park 阻塞
}
acquireQueued(node, savedState); // ④ 回到 sync 队列重新竞争锁
}
public final void signal() {
Node first = firstWaiter;
if (first != null) doSignal(first); // 唤醒条件队列第一个节点
}
private void doSignal(Node first) {
do {
if ((firstWaiter = first.nextWaiter) == null) lastWaiter = null;
first.nextWaiter = null;
} while (!transferForSignal(first) && // 转移到 sync 队列
(first = firstWaiter) != null);
}
两者都需要持锁调用、都释放锁并阻塞。关键差异:Object.wait() 只有一个 waitSet,Condition 可以创建多个实现精确唤醒(如 ArrayBlockingQueue 的 notFull/notEmpty);Condition 支持超时、不可中断等变体;底层机制不同——wait 基于 monitor,Condition 基于 AQS park/unpark。
用 ReentrantLock + 双 Condition 手写一个有界生产者-消费者,是面试白板题的高频变体,直接背下这个骨架:
class BoundedQueue<T> {
private final Deque<T> deque = new ArrayDeque<>();
private final int cap;
private final Lock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition(); // 生产者等
private final Condition notEmpty = lock.newCondition(); // 消费者等
public BoundedQueue(int cap) { this.cap = cap; }
public void put(T v) throws InterruptedException {
lock.lock();
try {
while (deque.size() == cap) notFull.await(); // ★ while 不是 if
deque.addLast(v);
notEmpty.signal(); // 精确唤醒一个消费者
} finally {
lock.unlock();
}
}
public T take() throws InterruptedException {
lock.lock();
try {
while (deque.isEmpty()) notEmpty.await();
T v = deque.removeFirst();
notFull.signal(); // 精确唤醒一个生产者
return v;
} finally {
lock.unlock();
}
}
}
两个必须答出来的细节:
- 为什么
while而不是if? 被唤醒 ≠ 条件成立:① 虚假唤醒(JVM 允许);② 多个消费者被唤醒后只有一个能拿到元素,其他必须重新检查。synchronized 版的经典「flag 变量 + 单一 waitSet」写法,正是用两个布尔标志模拟这种双队列——Condition 把它原生化了。 - 为什么 signal 而不是 signalAll? put 只新增一个元素,唤醒一个消费者就够了;signalAll 会把所有等待线程都拉起来重新抢锁,多出来的全部白跑一趟。性能敏感场景,能 signal 就别 signalAll。
除了 signal() 只唤醒一个节点,signalAll() 会将条件队列中所有节点转移到 sync 队列并逐一 unpark——条件变化影响所有等待者时(比如队列容量动态调整)用它。
| 维度 | Object.wait() | Condition.await() |
|---|---|---|
| 前置条件 | 持有 synchronized 监视器锁 | 持有 ReentrantLock |
| 等待队列数 | 一个(waitSet) | 可创建多个 Condition |
| 唤醒 | notify / notifyAll | signal / signalAll |
| 超时 | 支持(wait(long ms)) | 支持(await(long, TimeUnit)) |
| 不可中断 | 不支持 | 支持(awaitUninterruptibly()) |
| 底层机制 | JVM monitor | AQS park/unpark |
模板方法模式:哪些钉死,哪些留白
回到第 3 站的餐厅类比——「领位台」和「座位规则」的分工,在代码上就是模板方法模式(Template Method):
| 钉死在父类(AQS) | 留给子类(tryXxx 钩子) |
|---|---|
排队:addWaiter / enq(CAS 入队) | 独占:什么是「获取成功」→ tryAcquire(arg) |
挂起/唤醒:acquireQueued / unparkSuccessor | 独占:什么是「释放成功」→ tryRelease(arg) |
取消处理:cancelAcquire(跳过 CANCELLED 节点) | 共享:共享获取 → tryAcquireShared(arg) |
模板骨架:acquire / release / acquireShared / releaseShared | 共享:共享释放 → tryReleaseShared(arg) |
为什么这样切分?因为排队、挂起、唤醒是并发代码里最容易写错的部分(竞态、漏唤醒、死循环),Doug Lea 把它写成一次、调试到稳,所有同步工具复用;而「什么算拿到锁」每个工具的定义完全不同,必须留给子类。这正是开闭原则(Open-Closed Principle)的教科书级体现——对扩展开放(新工具),对修改关闭(队列逻辑不再动)。
推论:你以后自己写一个同步工具(比如限流器),不需要碰队列和 park,只需要继承 AQS,想清楚两件事——state 怎么定义(比如 state = 剩余令牌数)、tryAcquireShared 怎么写(state >= acquires 就 CAS 扣减)。Semaphore 就是这么来的(第 15 站)。
AQS 家族:CountDownLatch、Semaphore、ReadWriteLock
理解了 AQS 三板斧,其他工具迎刃而解——差异仅在于state 的语义和tryAcquire/tryRelease 的实现。
Sync(int count) { setState(count); } // state = 初始计数值
protected int tryAcquireShared(int arg) {
return (getState() == 0) ? 1 : -1; // state==0 成功,否则阻塞
}
protected boolean tryReleaseShared(int arg) {
for (;;) {
int c = getState();
if (c == 0) return false;
if (compareAndSetState(c, c - 1))
return c - 1 == 0; // 减到 0 才释放所有等待线程
}
}
protected int tryAcquireShared(int acquires) {
for (;;) {
int available = getState();
int remaining = available - acquires;
if (remaining < 0 || compareAndSetState(available, remaining))
return remaining; // <0 表示许可不够,获取失败
}
}
protected boolean tryReleaseShared(int releases) {
for (;;) {
int c = getState(), next = c + releases;
if (compareAndSetState(c, next)) return true;
}
}
static final int SHARED_SHIFT = 16;
static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1; // 0x0000FFFF
// 读锁计数 = state 高 16 位(无符号右移)
static int sharedCount(int c) { return c >>> SHARED_SHIFT; }
// 写锁计数 = state 低 16 位(位与掩码)
static int exclusiveCount(int c) { return c & EXCLUSIVE_MASK; }
在同一个 state 中同时记录读写锁持有数,只需一次 CAS 操作就能完成状态变更,避免维护两个独立变量的一致性问题和额外同步开销。代价是每种锁最多支持 65535 次重入。这和线程池的 ctl(高 3 位状态 + 低 29 位线程数)是同一个设计手法:一个 volatile int 打包多份状态,一次 CAS 原子更新——JUC 里到处都是这个套路。
| 工具 | state 语义 | 模式 | tryAcquire 逻辑 |
|---|---|---|---|
| ReentrantLock | 0=空闲,>0=重入次数 | 独占 | state==0 则 CAS;持有者则 state+1 |
| CountDownLatch | 倒计时计数 | 共享 | state==0 成功,否则阻塞 |
| Semaphore | 可用许可数 | 共享 | state >= acquires 则 CAS 减少 |
| ReadWriteLock | 高16位=读,低16位=写 | 独占+共享 | 写锁:无读无写则CAS;读锁:无写锁则CAS增加高位 |
除了上述四个经典工具,ThreadPoolExecutor 的 Worker 也内部继承了 AQS,实现了一个不可重入的独占锁(state 只有 0 和 1),用于保护工作线程的状态。FutureTask 的 Sync 同样基于 AQS,state 表示任务生命周期(NEW → COMPLETING → NORMAL/CANCELLED/EXCEPTIONAL)。AQS 是整个 JUC 包真正的"地基"。
生产落地:最大的坑是忘记 unlock
AQS 再精巧,也救不了业务代码里最常见的一手——异常路径漏掉 unlock:
lock.lock();
// 业务代码抛了异常(NPE / 超时 / DB 断了……)
doSomething(); // ← 这里炸了
lock.unlock(); // ← 永远执行不到!锁被永久占用
锁泄漏的表现非常隐蔽:服务不报错、不 OOM,只是某个功能慢慢变慢——所有后续请求都排在 CLH 队列里 WAITING,jstack 一抓全是一排 waiting to acquire lock。唯一规范写法:
lock.lock();
try {
// 临界区
} finally {
lock.unlock(); // 无论成功失败、抛什么异常,必走
}
synchronized 没有这个坑(出块自动释放),这是它最实际的优势之一。生产选型速查:
| 维度 | synchronized | ReentrantLock |
|---|---|---|
| 释放 | 自动(出块即释放,异常也安全) | 手动,必须 try-finally |
| 可中断 | ✗ | ✓ lockInterruptibly() |
| 超时 | ✗ | ✓ tryLock(timeout, unit) |
| 公平锁 | ✗(只能非公平) | ✓ new ReentrantLock(true) |
| 多条件队列 | ✗(单一 waitSet) | ✓ 多个 Condition |
| 性能 | JDK 6 后与 ReentrantLock 接近;无竞争时略优 | 竞争激烈的复杂逻辑下更灵活 |
| 代码量 | 少,不易出错 | 多,有漏 unlock 风险 |
默认 synchronized:简单临界区、锁持有时间极短、没有特殊需求——自动释放让你少一类 bug。出现以下任一需求再换 ReentrantLock:① 需要可中断/超时(长等待、可取消任务);② 需要公平性(严格 FIFO);③ 需要多条件精确唤醒(自定义生产者-消费者);④ 需要 tryLock 做乐观/降级逻辑。换过去之后,try-finally 是红线,Code Review 里看到没有 finally 的 unlock 直接打回。
这一篇你掌握了什么
AQS 的设计哲学归结为三个核心原则:
- 状态驱动:所有同步语义通过一个
volatile int state表达,CAS 保证原子修改 - 队列兜底:获取失败的线程统一进入 CLH 变体队列,park/unpark 管理生命周期,避免忙等
- 模板方法:AQS 定义 acquire/release 骨架,子类只需重写 tryAcquire/tryRelease 实现定制语义
核心知识点回顾
- 三大件:volatile state(CAS 原子修改)+ CLH 变体双向队列(park/unpark 不忙等)+ 模板方法(排队逻辑钉死,语义钩子留给子类)。
- state 语义因工具而异:ReentrantLock=重入次数、CountDownLatch=倒计时、Semaphore=许可数、ReadWriteLock=高16读+低16写。
- lock() 路径:CAS(0→1) 抢 → 失败 tryAcquire(含重入)→ 失败入队 → acquireQueued 自旋+park → unpark 后重新竞争。
- unlock() 路径:state-1,减到 0 才清 owner + unparkSuccessor(从 tail 往前找有效节点)。
- 可重入:owner==自己则 state+1,解锁逐步减,减到 0 才真释放。
- 公平 vs 非公平:差在 hasQueuedPredecessors();默认非公平吞吐更高,但有饥饿可能。
- 争抢不会消失,只会被重新安排:
p == head把 CAS(state) 的参与者限到「队首 1 人 + 插队者」,输的人试一两次就 park,重入/解锁 0 次 CAS——所以对 state 的 CAS 频率由锁的交接频率决定,与排队人数无关。可扩展性来自队列,不是来自 CAS。 - synchronized 给不了的:lockInterruptibly / tryLock 超时 / 多 Condition。
- waitStatus:SIGNAL 是前驱的承诺;CANCELLED 唯一正数;PROPAGATE 只管共享模式。
- 生产红线:unlock 必须在 finally;默认 synchronized,有进阶需求再换 ReentrantLock。
一句话总结
AQS = 「state 决定你能不能赢,队列决定你输的时候怎么等,模板方法决定『赢』对每个工具意味着什么」。
🎤 面试 30 秒总结
Q:请介绍 AQS 的原理以及 ReentrantLock 的实现。
"AQS 是 JUC 包的核心框架,内部维护 volatile int state 和 CLH 改造的双向 FIFO 队列。AQS 采用模板方法模式,子类重写 tryAcquire/tryRelease 实现不同同步语义。
ReentrantLock 基于 AQS 实现独占锁。state=0 空闲,>0 重入次数。lock() 先 CAS(0→1),失败则 tryAcquire(含重入检查),仍失败则入 CLH 队列 park。unlock() 时 state-1,减到 0 释放并 unparkSuccessor 唤醒后继。
公平锁在 tryAcquire 中多了 hasQueuedPredecessors() 检查,保证 FIFO 顺序;非公平锁允许直接 CAS 插队,减少上下文切换,吞吐量更高。绝大多数场景用非公平即可。
Condition 通过独立条件队列实现 wait/notify。await() 释放锁入条件队列 park,signal() 转移节点到 sync 队列并 unpark。支持多个 Condition 实现精确唤醒。"
Q:公平锁和非公平锁如何选择?
"绝大多数场景用非公平锁(默认),性能更好。只有需要严格保证不饥饿或业务要求 FIFO 顺序时才用公平锁。"
Q:AQS 靠 CAS 改 state,那锁竞争最后不还是变成 CAS 竞争吗?这样设计有什么优势?
"竞争消不掉,只能重新安排。AQS 里对 state 的 CAS 被 p == head 限死了:队列中只有队首那一个线程有资格再试,其余线程全在 park;失败的人试一两次也去排队睡觉,不自旋,而且赢家后续的重入和释放都走 setState,不再发 CAS。所以对 state 的 CAS 频率约等于锁的交接频率,跟排队人数无关,这是它和 AtomicLong 单点自旋最本质的差别。剩下的 N−1 份压力交给内核等待队列,睡着不占 CPU。
所以 AQS 的可扩展性不是来自 CAS,而是来自那条把「抢」变成「等」的队列;CAS 在这里只是最便宜的仲裁器——AQS 本身就是实现锁的框架,用锁保护 state 会循环依赖,最终只能落到 lock cmpxchg 这个硬件原语上。代价是唤醒后仍要重新竞争、可能饥饿,以及共享模式的释放侧没有闸门、是真正的自旋热点。"
Q:ReentrantLock 和 synchronized 怎么选?
"默认 synchronized——自动释放没有锁泄漏风险。需要可中断、超时、公平、多 Condition 时用 ReentrantLock,但 unlock 必须放 finally。"
Comments · 评论