主题
AQS 源码走读:一个 int + 一个队列撑起 JUC
本文是 Java 并发系统学习系列的 L2 核心篇。前置:volatile 可见性、有序性与使用边界。 学完可以配合面试题食用:AQS 原理与 ReentrantLock 实现、CountDownLatch/CyclicBarrier/Semaphore、LockSupport park/unpark
为什么 JUC 需要一个"骨架"类
假设你要自己写一个互斥锁,最少要处理几件事?抢锁(CAS 改状态)、抢不到就排队(线程安全地把线程挂进队列)、阻塞自己(LockSupport.park)、锁释放后唤醒后继(unpark)、中途取消还不能弄坏队列。写完 Mutex 再写一个倒计时门闩,你会发现排队、阻塞、唤醒、取消这套代码几乎要原样抄一遍。
Doug Lea 的做法是把这套和"锁语义无关、但每个同步器都要"的机制抽成一个抽象类,就是 AbstractQueuedSynchronizer(AQS)。子类只需要回答一个问题:state 在什么条件下算获取成功、怎么改。排队、阻塞、唤醒、取消、公平性兜底,AQS 全包了。
回头看 JUC 的类图会很感慨:ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock、线程池的 Worker、甚至 JDK 9 后 CompletableFuture 的等待链,底下都是同一个 AQS。一个 volatile int + 一条 CLH 变体队列,撑起了半个 JUC。
骨架:state + CLH 变体队列 + 两种模式
AQS 的全部家当就三样:
volatile int state:同步状态的唯一权威载体。锁场景里 0 是没人持有、1 是持有、>1 是重入层数;Semaphore 里是剩余许可数;CountDownLatch 里是未完成的计数。对 state 的修改都走 CAS 或在持锁线程内进行,配合 volatile 保证可见性——上一篇 volatile 讲的 happens-before 在这里落到实战。- CLH 变体双向队列:等待线程排成 FIFO 双向链表,每个节点(Node)持有一个线程引用和一个 waitStatus。说它是"变体",因为原始 CLH 是自旋等前驱的隐式单向队列,AQS 改成了显式双向链表 + park 阻塞 + 前驱唤醒。
- exclusive / shared 两种模式:独占模式同一时刻只有一个线程能持有(Mutex、ReentrantLock),共享模式允许多个线程同时通过(Semaphore、CountDownLatch、读锁)。两种模式各有一套 tryAcquire/tryRelease 与 tryAcquireShared/tryReleaseShared 钩子,队列唤醒逻辑也分叉(共享模式拿到后会传播唤醒后继)。
结构长这样:
mermaid
flowchart LR
subgraph AQS
S["state (volatile int)"]
H["head (哨兵)"] <--> N1["Node: 线程A ws=SIGNAL"] <--> N2["Node: 线程B ws=SIGNAL"] <--> T["tail"]
end
H -. 唤醒 .-> N1注意 head 是个哨兵节点,不持有线程;真正等锁的是 head.next 往后。waitStatus 里最关键的是 SIGNAL(-1),含义是"我释放时会负责唤醒我的后继"——这个约定是队列协作的核心,后面流程里反复出现。
acquire/release 主流程走读
以独占模式 acquire(1) 为例,JDK 源码骨架可以浓缩成下面这段伪代码(按 JDK 8 逐行注释,别怕,总共就三层):
java
// 第一层:acquire 的入口,模板方法
public final void acquire(int arg) {
if (!tryAcquire(arg) && // ① 先试一次获取(子类实现)
acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) // ② 失败则入队并自旋+阻塞
selfInterrupt(); // ④ 补上中断位( park 期间被中断过)
}
// 第二层:入队
private Node addWaiter(Node mode) {
Node node = new Node(Thread.currentThread(), mode);
Node pred = tail;
if (pred != null && node.prev = pred, CAS tail(node)) { // 快路径:直接接到尾
pred.next = node;
return node;
}
enq(node); // 慢路径:tail 还没初始化或 CAS 失败,自旋重试(含建哨兵)
return node;
}
// 第三层:队列里的核心循环——"坐着不动"
final boolean acquireQueued(Node node, int arg) {
for (;;) {
Node p = node.predecessor();
if (p == head && tryAcquire(arg)) { // 前驱是 head:有资格再抢一次
setHead(node); // 自己晋升为新 head(哨兵)
p.next = null;
return false;
}
if (shouldParkAfterFailedAcquire(p, node) && // 把前驱 waitStatus 改成 SIGNAL
parkAndCheckInterrupt()) // park 自己,醒来检查中断
return true; // ③ 等待中被中断
}
}三件事值得盯着看:
为什么要 shouldParkAfterFailedAcquire 先把前驱改成 SIGNAL 再 park? 防丢唤醒。释放锁的线程只会唤醒 waitStatus 是 SIGNAL 的节点的后继;如果不先立好这个标志就 park,释放方会以为"没人要我唤醒",park 的线程就永远醒不来。这是典型的"先约定、后睡觉"模式。
为什么抢锁条件是 p == head? 队列保证 FIFO 顺序,但 head 可能正在释放锁。只有前驱是 head 的节点才有资格尝试 CAS,其余节点连试都不试,直接 park——这就是队列对公平性的保证来源(对入队后的线程而言)。
入队 CAS 失败怎么办? enq 里 for(;😉 自旋重试,直到 CAS 成功。多线程并发入队时总有输家,输了就再试一次,这是无锁算法的标准写法。
release 是 acquire 的镜像,短得多:
java
public final boolean release(int arg) {
if (tryRelease(arg) && // 子类实现:state 减到 0 才返回 true
headStatus == SIGNAL) {
unparkSuccessor(head); // 唤醒 head.next(若无效则从尾向前找第一个有效节点)
}
return true;
}一个细节:unparkSuccessor 里如果 head.next 为 null 或已取消,源码是从 tail 向前遍历找第一个有效节点——因为节点入队时先设 prev 再 CAS tail,next 指针的设置可能滞后,从后往前才能保证不漏。
ReentrantLock:公平与非公平只差 tryAcquire
AQS 定了骨架,ReentrantLock 只需要实现 tryAcquire/tryRelease。JDK 源码里公平和非公平是两个内部类,差异集中在一个判断:
java
// 非公平版(NonfairSync)
final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
// 上来直接 CAS state:0 -> acquires,抢到就是我的
}
// 公平版(FairSync)
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;
}
}
// 重入逻辑两版相同:c != 0 且 owner 是自己,state+1
}非公平锁吞吐更高的原因就在这一处:刚释放的锁被新来的线程直接抢走,正在排队的线程继续 park,省了一次线程切换。代价是队列中的线程可能长时间抢不到(饥饿),但队列本身仍在推进,所以实践中 new ReentrantLock() 默认非公平。
还要提一句 acquireQueued 里那个 p == head && tryAcquire 的"再抢一次":即使公平锁,线程被唤醒后也要重新走 tryAcquire(重新查 hasQueuedPredecessors),不是醒了就必然拿到。AQS 唤醒的语义是"给你一次竞争资格",不是"给你锁"。
一个骨架养活四个类:各自的复用姿势
看懂骨架后,其他同步器只是换了 state 的语义:
- ReentrantLock:state 0/1/N 表无主/持有/重入层数;独占模式;释放必须持锁线程本人(tryRelease 里校验 owner)。
- CountDownLatch:state = 剩余计数;共享模式获取(await 是 tryAcquireShared,要求 state==0 才放行);countDown 就是 tryReleaseShared 把 state 减 1,减到 0 时唤醒队列里所有等 shared 的节点并传播唤醒(setHeadAndPropagate——一个通过就把后面连续的 shared 节点全放行)。
- Semaphore:state = 许可数;acquire 是"减",release 是"加";同一份代码支持公平/非公平两版 tryAcquireShared。
- ReentrantReadWriteLock:最秀的一个——一个 int 拆两半用:高 16 位存读锁持有数,低 16 位存写锁重入数。读锁用共享模式(多个读者可同时通过),写锁用独占模式,一把 AQS 同时服务两种语义。
这张对照表值得记(同样建议自己默写一遍):
- 换语义不换机制:四个类没写一行排队/阻塞/唤醒代码,全是 state 解释不同
- 共享 vs 独占的判据:允许多个线程同时"通过"就用 shared(await/acquire/readLock),互斥就用 exclusive
- 传播唤醒只在 shared 出现:一个节点通过后如果还有余量(state 允许),继续唤醒后继
动手实操:30 行手写一个 Mutex
理解 AQS 最快的路是自己当一次子类。下面这个 Mutex 完整可运行,30 行内实现不可重入互斥锁:
java
import java.util.concurrent.locks.AbstractQueuedSynchronizer;
public class Mutex {
private final Sync sync = new Sync();
// 全部逻辑在 tryAcquire/tryRelease 里:state 0=空闲, 1=持有
private static class Sync extends AbstractQueuedSynchronizer {
@Override
protected boolean tryAcquire(int acquires) {
if (compareAndSetState(0, 1)) { // CAS 抢锁,非公平
setExclusiveOwnerThread(Thread.currentThread());
return true;
}
return false; // 抢不到,AQS 会安排排队+park
}
@Override
protected boolean tryRelease(int releases) {
if (getState() == 0) throw new IllegalMonitorStateException();
setExclusiveOwnerThread(null);
setState(0); // 已在锁内,直接写,不需要 CAS
return true; // 返回 true -> AQS 唤醒后继
}
}
public void lock() { sync.acquire(1); }
public void unlock() { sync.release(1); }
public boolean tryLock() { return sync.tryAcquire(1); }
public static void main(String[] args) throws InterruptedException {
Mutex m = new Mutex();
Runnable task = () -> {
m.lock();
try {
System.out.println(Thread.currentThread().getName() + " in");
Thread.sleep(100);
} catch (InterruptedException ignored) {
} finally {
m.unlock();
}
};
for (int i = 0; i < 3; i++) new Thread(task, "T" + i).start();
// 输出必然串行:T0/T1/T2 依次 in,间隔约 100ms——排队与唤醒 AQS 全包了
}
}两个细节呼应前文:tryRelease 里用 setState(0) 而不是 CAS,因为执行到这里必然持有锁,不存在竞争;tryAcquire 只有 CAS 一条路,所以这个锁是非公平的——想改公平,先查 hasQueuedPredecessors()。
拿它和 ReentrantLock 对比着玩:把 tryAcquire 改成允许重入(state 判断 owner),就得到了一个简版 ReentrantLock;把 tryAcquire 改成读 tryAcquireShared 且 state 初始为 N,就得到了简版 Semaphore。同步器的个性全在 tryXxx 的几行里,这就是骨架的意义。
常见误区与小结
- 误区一:AQS 队列是原始 CLH。 是变体:原始 CLH 自旋等前驱、隐式单向;AQS 是显式双向链表 + park 阻塞 + 前驱释放时唤醒后继,还处理取消与超时。
- 误区二:被唤醒就能拿到锁。 unpark 只给竞争资格,醒来后重新走 tryAcquire,公平锁还要重查队列,非公平锁可能被新来的插队。
- 误区三:park 之前忘了立 SIGNAL。 shouldParkAfterFailedAcquire 把前驱 waitStatus 置为 SIGNAL(-1) 是"释放方会唤醒我"的约定,跳过它 park 就可能永远没人叫醒。
- 误区四:state 只能表示锁。 state 就是个 volatile int,语义完全由 tryAcquire/tryRelease 定义——重入数、许可数、倒计时、甚至读写锁的高低 16 位拆分。
小结:AQS 用模板方法把"排队阻塞唤醒"做成公共骨架,把"什么算获取成功"留给子类。读源码的顺序建议先 acquire 三层(入口 -> addWaiter -> acquireQueued)再 release,配合手写 Mutex 吃透。它在学习路径上是 JMM/volatile 之后的实战检验——你终于看到了 volatile + CAS + 队列怎么组装成生产级同步器。下一篇进入 并发容器全景,看 ConcurrentHashMap、CopyOnWriteArrayList 这些日常更常打交道的工具。
参考
- OpenJDK 源码
java.util.concurrent.locks.AbstractQueuedSynchronizer(JDK 8 与 JDK 17+ 各读一遍,JDK 17 有小幅重构但主流程一致) - Doug Lea 的 AQS 说明文档
AbstractQueuedSynchronizer类头 javadoc(设计动机与 CLH 变体的第一手解释) - 《Java 并发编程的艺术》第 5 章(AQS 章节的中文化梳理,适合对照源码阅读)