AQS 这三个字母,在 Java 并发领域的分量,相当于地基之于大楼。AbstractQueuedSynchronizer(抽象队列同步器)是 java.util.concurrent 包的基石,ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock,甚至线程池里的 Worker 和 FutureTask,底层都离不开这套机制。
我第一次被逼着去啃它,是线上压测事故:接口偶发超时,线程栈 dump 出来一大片线程停在 LockSupport.park。当时我能说出来"锁竞争导致阻塞",却解释不清它们怎么排队、谁负责唤醒、为什么后到的线程有时反而先进去。后来把 AQS 源码一行行过完,才真正理解 JUC 这套并发骨架的设计思路。下面我就从问题出发,把 AQS 的骨架一点点拆开,再对照 ReentrantLock、Semaphore 和 CountDownLatch 看它怎么落地。不管你是刚开始读并发源码,还是准备面试想系统过一遍,这篇文章都值得你花十分钟慢慢看。
1. 为什么说AQS是JUC的"骨架"级设计
1.1 那些天天在用的并发类,底层都指向同一个类
很多人学过 synchronized,也知道 ReentrantLock,但没意识到它们背后是两套完全不同的实现。synchronized 是 JVM 字节码层面直接支持的监视器锁,而 ReentrantLock 以及 juc 包下一大堆工具,都是基于同一个普通的 Java 类写出来的——就是 AbstractQueuedSynchronizer。
我列几个你大概率用过的类:
- ReentrantLock:独占锁,state 表示重入次数;
- ReentrantReadWriteLock:读写锁,一个 int 拆成高 16 位和低 16 位,分别记录读锁和写锁状态;
- Semaphore:信号量,state 表示剩余许可数;
- CountDownLatch:倒计数门闩,state 表示还剩多少个计数没完成;
- ThreadPoolExecutor:里面的 Worker 继承了 AQS,用独占变量标记线程是否空闲;
- FutureTask:任务执行状态 NEW/RUNNING/DONE 等,也是借 AQS 的 state 来管理的。
换句话说,你每次 new 一个锁、每次提交一个线程池任务,背后都可能有一份 AQS 的实例在默默工作。这就是我把它叫"骨架"的原因:它不是一个具体的锁,而是所有锁和同步器的公共底座。
1.2 所有同步器都要回答的三个问题
往下看源码之前,先想清楚一个问题:任何一个同步器,本质上都在回答三件事。
第一,当前能不能通过?也就是"资源可不可用"。比如锁有没有被占用、信号量还有没有许可、计数器有没有到零。第二,不能通过的话怎么办?是死等、自旋、还是排到队列里休眠?第三,将来资源可用时,怎么通知等待的人?是全部唤醒,还是只叫醒一个人?
这三个问题,AQS 给出了统一的答案:用一个 volatile int state 表示资源可用性,用一条 FIFO 的等待队列组织拿不到资源的线程,用 LockSupport 的 park/unpark 实现精确唤醒。具体到每一种同步器,只需要告诉 AQS"什么样算获取成功、什么样算释放成功"。这个设计,就是 AQS 全部的精髓。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. AQS两大支柱:一个state变量和一张双向等待队列
2.1 state:一个volatile int撑起的全部同步语义
AQS 内部最核心的字段就一个:
java复制private volatile int state;
对外的三个 protected 方法也围绕它展开:
java复制protected final int getState() { return state; }
protected final void setState(int newState) { state = newState; }
protected final boolean compareAndSetState(int expect, int update) {
return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}
别小看这一个 int。在不同子类手里,它代表完全不同的含义:
| 同步器 | state 的含义 |
|---|---|
| ReentrantLock | 0 表示无锁,N 表示当前线程重入了 N 次 |
| Semaphore | 剩余可用的许可数量 |
| CountDownLatch | 还差多少个计数才能放行 |
| ReentrantReadWriteLock | 高 16 位是读锁计数,低 16 位是写锁重入次数 |
为什么一定要 volatile?因为多个线程要同时看到最新的 state,这是锁可见性的基础。为什么又需要 CAS?因为修改 state 必须是原子的,不能两个线程同时把 0 改成 1 还都认为自己成功了。volatile 管可见性,CAS 管原子性,两者结合,一个 int 才能在无锁算法里安全地飞来飞去。
2.2 CLH变体队列:排队节点到底存了什么
光有 state 还不够。如果锁被占着,后来的线程必须有个地方待着,不能全在 CPU 上自旋空转。AQS 维护了一条等待队列,它是 CLH 锁队列的一个变体:从原来的单向自旋队列,改成了双向、基于阻塞的队列。
队列的每个节点是内部类 Node,关键字段如下:
java复制volatile int waitStatus;
volatile Node prev;
volatile Node next;
volatile Thread thread;
Node nextWaiter;
prev 和 next 让队列能双向遍历,thread 保存等待的线程,nextWaiter 在独占/共享模式里有特殊作用(独占模式下指向 EXCLUSIVE 空值,Condition 场景下指向下一个条件节点)。
waitStatus 是理解 AQS 的关键,它只有几个取值:
| 值 | 含义 | 说明 |
|---|---|---|
| 1 | CANCELLED | 节点对应的线程已取消等待,节点作废 |
| -1 | SIGNAL | 当前节点的后继需要被唤醒 |
| -2 | CONDITION | 节点在条件队列中,等 condition.signal() |
| -3 | PROPAGATE | 共享模式下,唤醒需要向后传播 |
| 0 | 初始值 | 无特别状态 |
这里有个很多初学者忽略的点:为什么用双向链表?一个原因是取消等待时要能随意摘除节点,single-linked 摘除中间节点很麻烦;另一个原因是 unparkSuccessor 从 tail 往前找可用节点时需要 prev 指针。后面讲唤醒时会再碰到这个设计。
另外注意,队头 head 节点是个哨兵(dummy node),它的 thread 字段是 null。也就是说,真正的等待线程从 head.next 才开始。这个设计让队列操作少了很多边界判断,也方便一个节点获取锁后直接把 head 挪到自己身上。
2.3 模板方法设计:AQS怎么把"流程"与"规则"解耦
AQS 用了一个非常经典的模板方法模式。它把"获取锁的流程"固定死,把"什么情况算获取成功"留给子类。子类只需要按需重写这几个方法:
java复制protected boolean tryAcquire(int arg) // 独占式尝试获取
protected boolean tryRelease(int arg) // 独占式尝试释放
protected int tryAcquireShared(int arg) // 共享式尝试获取,负数=失败
protected boolean tryReleaseShared(int arg) // 共享式尝试释放
protected boolean isHeldExclusively() // 是否独占持有
而 AQS 内部定义好的 final 方法,比如 acquire、release、acquireShared、releaseShared,才是真正干活的骨架。子类写不了也改不了,只能提供上面的"规则"。
打个比方:AQS 是一条完整的流水线,传送带、机械臂、质检口都装好了,子类只需要告诉它"什么样的产品算合格"。倒过来想,正因为流程被定死了,ReentrantLock 和 Semaphore 这种语义完全不同的工具,才能共用同一套排队、休眠、唤醒机制,而且都被 Doug Lea 验证过无数遍,极少出并发 bug。这是整个 juc 包能保持高质量的一个重要原因。
3. 加锁失败后线程经历了什么:acquire整条链路拆解
看源码最忌讳囫囵吞枣。我们把独占模式下获取锁的入口整个拉出来:
java复制public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
就三行,但每一行都是一个深坑。我来逐个拆。
3.1 tryAcquire:先争取一次,拿不到再排队
第一步永远是最乐观的:直接尝试获取一次。对于 ReentrantLock,就是看看 state 是不是 0,如果是就 CAS 成 1;如果是自己持有的就加一。这一步不排队、不阻塞、不 park,纯粹是快速路径。
为什么先试一次?因为大部分锁竞争其实很短,如果每次获取都直接走到排队、休眠那套流程,线程切换的开销反而比锁本身还大。先试一次,能拿到就直接返回,拿不到再走完整链路。这也是 AQS 里几乎所有获取方法的第一动作——先抢,抢不到再排队。
3.2 addWaiter与enq:CAS入队背后的初始化与竞争
如果 tryAcquire 失败,就要入队了。addWaiter 先尝试一个快速路径:
java复制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;
}
这里有个细节:node.prev = pred 不是 CAS 操作,为什么安全?因为当前节点只有自己这一个线程在操作,别人不会碰它的 prev,所以先赋值没事。然后用 compareAndSetTail 把队尾从 pred 改成 node,一旦 CAS 成功,这个节点就正式入队了,再补上 pred.next = node。
CAS 失败怎么办?说明有人抢先入队了,那就走 enq 这个死循环:
java复制private Node enq(final Node node) {
for (;;) {
Node t = tail;
if (t == null) { // 队列还没初始化
if (compareAndSetHead(new Node()))
tail = head;
} else {
node.prev = t;
if (compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
}
}
enq 里最值得讲的是队列初始化。当第一个线程入队时,tail 是 null,它不会直接把等待线程设为 head,而是先创建一个哨兵节点当 head,再让 tail 指向它。为什么要多此一举?因为如果 head 直接指向真实等待线程,那这个线程获取锁后要清空 head 的 thread 字段,多一步操作不说,并发场景下还容易把"当前持有锁的线程"和"正在等待的线程"搞混。用哨兵节点,head 永远代表"当前位置已被占用",逻辑干净很多。
3.3 acquireQueued:只有老二能抢,抢不到就睡
入队之后进入最核心的 acquireQueued:
java复制final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
boolean interrupted = false;
for (;;) {
final Node p = node.predecessor();
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null; // help GC
failed = false;
return interrupted;
}
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
注意那个 p == head 的判断:只有当前节点的前驱是 head 时,才有资格再次 tryAcquire。也就是说,排队的人里只有"老二"能抢锁。为什么?因为 head 是哨兵,它代表"锁当前被某个人占着",head 后面的第一个节点才是真正排在队伍最前面的等待者。队列是 FIFO 的,所有在队列里的线程不会互相插队。
这里有个非常重要的认知:非公平锁的不公平,只针对"还没入队的新线程"。一旦你进了队列,就得老老实实按顺序来,前面的没拿到锁,后面的只能等。
如果前驱不是 head,或者 tryAcquire 又失败了,就进入休眠逻辑。acquireQueued 是整个 AQS 里最容易看晕的地方,建议配合一个具体场景读:线程 A 持有锁,线程 B、C 排队,B 是老二。A 释放锁后 unpark 了 B,B 醒过来,循环回到顶部,发现前驱是 head,tryAcquire 成功,于是把自己设为新 head,老 head 的 next 置空方便 GC。新线程 D 此时过来,直接在 tryAcquire 里 CAS 成功——这就是非公平锁的插队。
还有一个细节值得记:acquireQueued 返回的是 boolean,表示等待过程中是否被中断过。但它不会把 InterruptedException 抛出来,而是在拿到锁后用 selfInterrupt() 把中断标志补回去。这个语义很重要:普通 acquire 响应中断,但中断只会被"记住",不会让线程放弃拿锁。
3.4 shouldParkAfterFailedAcquire:睡前为什么要给前驱"留话"
真正决定 park 不 park 的是 shouldParkAfterFailedAcquire:
java复制private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
int ws = pred.waitStatus;
if (ws == Node.SIGNAL)
return true;
if (ws > 0) {
do {
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);
pred.next = node;
} else {
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
return false;
}
第一次调用时,前驱的 waitStatus 一般是 0,不是 SIGNAL,所以会走到 else 分支,把前驱的 waitStatus CAS 成 -1,然后返回 false。回到 acquireQueued 的循环,再试一次 tryAcquire,如果还是失败,再次进入 shouldParkAfterFailedAcquire,这次发现前驱已经是 SIGNAL,才真正返回 true,然后 park。
为什么要绕这一圈?因为"睡前必须给前驱留话"。SIGNAL 的含义就是"我的后继在睡觉,你释放的时候记得叫我"。如果直接 park 而不设置 SIGNAL,极端情况下会丢失唤醒:你刚要 park,前驱刚好释放锁,它发现没有后继需要唤醒就直接返回了,然后你 sleep 下去了,再也没人叫你。先设置 SIGNAL,再循环检查一次锁状态,最后才 park,这就保证 release 侧一定能看到 SIGNAL 并执行 unpark。这是 AQS 防止 lost wakeup 的关键。
另外一个细节:if (ws > 0) 表示前驱已取消,就跳过它,把当前节点往前挂。这里频繁用到 prev 指针,也解释了为什么队列要双向。cancelAcquire 只改前置链不断后置链,从前往后找有效节点可能踩空,但沿着 prev 从后往前找一定找得到。
4. 对照ReentrantLock看AQS的独占锁落地
理论讲完,拿最熟悉的 ReentrantLock 当案例,把 AQS 的抽象方法填上具体逻辑。
4.1 非公平锁插队:tryAcquire那一下的"抢占"
ReentrantLock 默认是非公平的,它的 tryAcquire 长这样:
java复制final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
两个分支很清晰。c == 0 说明没人持有,CAS 抢一下,抢到就设 owner;c != 0 且当前线程就是 owner,说明是可重入,state 累加。为什么非公平?因为这段代码在入队之前就会执行,新来的线程无视队列里已经有人在等,直接参与竞争。如果它赢了,排了半天队的人还得继续等。
这看起来很不公平,但实际吞吐量往往更高。原因很简单:刚释放锁的线程和刚来的线程可能在同一个 CPU 核上,缓存还热着,CAS 基本一次成功;而唤醒队首线程要经过 park/unpark、线程调度,代价高得多。所以 JDK 默认非公平是有性能考量的。公平锁牺牲部分吞吐,换取"先来后到"的确定性。
4.2 公平锁:hasQueuedPredecessors检查的边界情况
公平锁的 tryAcquire 只多了一个判断:
java复制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()) {
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
hasQueuedPredecessors() 的意思是"队列里有没有排在我前面的人":
java复制public final boolean hasQueuedPredecessors() {
Node t = tail;
Node h = head;
Node s;
return h != t &&
((s = h.next) == null || s.thread != Thread.currentThread());
}
这个判断有讲究。h != t 表示队列非空;然后看 h.next:如果 h.next 为 null,可能是极端并发下队列正在初始化(head 已创建但第一个等待节点还没来得及接上),此时不能插队,返回 true,保守等待;如果 h.next 不为 null 但不是当前线程,说明前面确实有人,也不能插队;只有 h.next 就是当前线程自己时,才允许去抢。
注意:公平锁也允许重入。即使队列里有人在等,当前 owner 重入时不会走队列,而是直接 state+1。否则一个持锁线程想重入却因为队列非空而失败,那就死锁了。
4.3 解锁与unparkSuccessor:只叫醒队列里的一个人
释放端比获取端简单得多:
java复制public final boolean release(int arg) {
if (tryRelease(arg)) {
Node h = head;
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
tryRelease 在 ReentrantLock 里就是 state 减一,减到 0 才真正释放并清空 owner。注意这里强调必须是持锁线程,否则抛 IllegalMonitorStateException,这也是"谁持有谁释放"的规范。
真正唤醒的活是 unparkSuccessor:
java复制private void unparkSuccessor(Node node) {
int ws = node.waitStatus;
if (ws < 0)
compareAndSetWaitStatus(node, ws, 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);
}
它先从头节点的 next 找要唤醒的节点,如果 next 为 null 或者是取消状态,就从 tail 往前找第一个没取消的节点。为什么从后往前?因为 cancelAcquire 可能把 next 链弄断了,但 prev 链是可靠的。这个"往后找不到就从后往前兜底"的设计,是处理取消节点时的经典手段。
注意 release 只唤醒一个人。这和 synchronized 的 notifyAll 完全不同——AQS 觉得唤醒所有人只会造成无意义的竞争,队首那一个拿到锁之后,自然会在释放时唤醒下一个,形成一条精确的接力链。这也是避免惊群效应的核心。
5. Semaphore和CountDownLatch:共享模式下的另一种玩法
5.1 state从锁标记变成计数器
独占模式下,state 基本是 0 和 1 的游戏(ReentrantReadWriteLock 稍微复杂些)。共享模式则把 state 当成计数器,最典型的是 Semaphore:
java复制final int nonfairTryAcquireShared(int acquires) {
for (;;) {
int available = getState();
int remaining = available - acquires;
if (remaining < 0 ||
compareAndSetState(available, remaining))
return remaining;
}
}
方法名是 tryAcquireShared,返回值不是 boolean,而是 int:负数表示失败(剩余不够),0 表示成功且没有剩余许可,正数表示成功且还有剩余。这个返回值很关键,它决定了共享模式下要不要继续传播唤醒。
CountDownLatch 正好反过来。它的"获取"是 await——等待 state 变成 0;它的"释放"是 countDown——把 state 减一。tryReleaseShared 只有减到 0 时才返回 true,以此触发后面的唤醒逻辑:
java复制protected boolean tryReleaseShared(int releases) {
for (;;) {
int c = getState();
if (c == 0)
return false;
int nextc = c - 1;
if (compareAndSetState(c, nextc))
return nextc == 0;
}
}
注意它用了一个无锁循环加 CAS。为什么不用 setState?因为可能有多个线程同时 countDown,必须保证减操作原子性,而 compareAndSetState 失败就重读再算,这也叫乐观锁自旋。
5.2 共享模式为什么需要"传播唤醒"
共享模式和独占模式在唤醒上的最大区别,在于 setHeadAndPropagate:
java复制private void setHeadAndPropagate(Node node, int propagate) {
Node h = head;
setHead(node);
if (propagate > 0 || h == null || h.waitStatus < 0 ||
(h = head) == null || h.waitStatus < 0) {
Node s = node.next;
if (s == null || s.isShared())
doReleaseShared();
}
}
当一个共享节点成功获取后,如果发现剩余资源还足够(propagate > 0)或者有兄弟节点希望被传播唤醒,它会主动帮忙唤醒下一个共享节点。被唤醒的节点获锁后又唤醒下一个,形成级联。这样 Semaphore 释放多个许可时,多个等待线程能被依次叫醒,而不是只有一个醒过来发现资源又没了再睡回去。
COUNTDOWNLATCH 里这个传播尤为重要:state 一旦到 0,所有 await 的线程都应该醒来。第一个被唤醒的节点会发现 propagate 跟资源数量无关(它不消耗资源),继续往下传,最终整个队列的等待线程都被叫醒。这里的 PROPAGATE 状态专门用来处理边界并发:如果唤醒传播有丢失风险,把 head 的 waitStatus 置为 -3 提醒后面的节点"还有活要干"。
追问一句:为什么这么麻烦,直接全部唤醒不行吗?可以,但代价是惊群。AQS 选择用状态机精细控制,只有"确实存在共享资源剩余"或"确实有后继需要传播"时才继续叫醒,既保证正确性,又把无效唤醒压到最低。
5.3 独占与共享:一张表看清两种模式的差异
| 对比维度 | 独占模式 | 共享模式 |
|---|---|---|
| 典型实现 | ReentrantLock、ReentrantReadWriteLock 写锁 | Semaphore、CountDownLatch、读写锁读锁 |
| 核心方法 | tryAcquire / tryRelease | tryAcquireShared / tryReleaseShared |
| 获取成功标志 | 返回 true,state 变为已占用 | 返回非负数,state 减去消耗后剩余量 |
| 获取失败标志 | 返回 false,进入队列 | 返回负数,进入队列 |
| 唤醒节奏 | 每次只 unpark 队首下一节点 | 级联传播,可唤醒多个节点 |
| 典型语义 | 同一时刻只有一个线程通过 | 允许 N 个线程同时通过 |
这张表建议存下来。面试和日常读代码时,看到某个类继承了 AQS,第一件事就是看它重写了哪组 try 方法,然后立刻判断它是独占还是共享,再按对应的链路去理解,效率会高很多。
6. 源码之外:自定义同步器、线程Dump排查与面试误区
6.1 什么时候值得自己实现一个AQS同步器
先把话说在前面:99% 的场景不需要你自己写同步器,JDK 提供的锁、信号量、栅栏足够用。真正值得继承 AQS 的,是你需要一种 JDK 没有的"许可语义"时,比如"最多允许 3 个线程同时执行某段逻辑"的限流闸门。这种场景用 Semaphore 也能做,但如果带上"可重入""可公平排队"等定制要求,自己继承 AQS 往往只要二十行:
java复制public class SimpleGate {
private static final class Sync extends AbstractQueuedSynchronizer {
Sync(int permits) { setState(permits); }
@Override
protected int tryAcquireShared(int arg) {
for (;;) {
int cur = getState();
int next = cur - arg;
if (next < 0 || compareAndSetState(cur, next)) {
return next;
}
}
}
@Override
protected boolean tryReleaseShared(int arg) {
for (;;) {
int cur = getState();
int next = cur + arg;
if (compareAndSetState(cur, next)) {
return true;
}
}
}
}
private final Sync sync;
public SimpleGate(int permits) { sync = new Sync(permits); }
public void enter() { sync.acquireShared(1); }
public void leave() { sync.releaseShared(1); }
}
注意几个坑。第一,setState 只能放在构造函数里或者子类自己的受保护方法中,别在外面乱调;第二,tryAcquireShared 返回负数就是"失败"信号,千万别把剩余量算错;第三,释放操作必须幂等且线程安全,否则会出现许可凭空多出来或丢失。自己实现同步器后,一定要用多线程压力测试反复验证,AQS 框架虽然帮忙兜住了排队和唤醒,但"什么算成功"还是你自己的责任。
6.2 线程Dump里那些WAITING(parking)怎么看
回到我开头说的事故。当时线程栈大量出现类似这样的信息:
code复制"pool-1-thread-5" #11 prio=5 os_prio=0 tid=0x00007f nid=0x1c2 waiting on condition
java.lang.Thread.State: WAITING (parking)
at sun.misc.Unsafe.park(Native Method)
at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175)
at java.util.concurrent.locks.AbstractQueuedSynchronizer.parkAndCheckInterrupt(...)
at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquireQueued(...)
...
看到 WAITING (parking) + LockSupport.park + acquireQueued 这条链路,基本可以断定线程阻塞在 AQS 队列里。想确认它等的是哪把锁,往下翻 jstack,通常会有一段:
code复制Locked ownable synchronizers:
- <0x00000000c1234567> (a java.util.concurrent.locks.ReentrantLock$NonfairSync)
这就是线程当前持有的 ReentrantLock 对象。如果很多线程都 park 在同一个地址上,而这段 Locked ownable synchronizers 只出现在少数线程里,说明锁竞争激烈,排队的人太多。排查思路就是:先找出持锁线程在干嘛,是慢查询、外部调用还是死循环,然后针对那个业务路径优化。只看 WAITING 状态本身没意义,关键是把"等待同一把锁的所有线程"串起来分析。
6.3 面试高频题背后真正想考的东西
最后说几个 AQS 面试题,以及它们真正在考察什么。
"讲讲 AQS 的原理"——得分点是三件套:state 同步状态、CLH 变体等待队列、模板方法模式。能说出"子类通过 tryAcquire/tryRelease 决定语义,AQS 负责排队和阻塞唤醒",基本就过关了。
"公平锁和非公平锁的区别"——考察点不只是那行 hasQueuedPredecessors,而是插队发生在入队前的 tryAcquire,一旦入队必须 FIFO。能讲出"非公平锁吞吐更高的原因"是加分项。
"为什么获取锁要自旋 + park 结合"——考察的是性能权衡。短暂自旋避免线程切换,长时间拿不到就用 park 让出 CPU。只答"自旋"或只答"阻塞"都不完整。
"为什么 state 用 volatile"——可见性 + CAS 原子更新,两者缺一不可。这里可以顺势讲一下 setState 和 compareAndSetState 的使用场景差异:前者用于持锁线程内部修改,后者用于多线程竞争改值。
"中断对 acquire 的影响"——这是个很容易翻车的点。Lock 接口和 synchronized 最大的行为差异之一就是中断响应。AQS 的 acquire 会把中断记录下来,拿到锁后补回中断标志;只有 acquireInterruptibly 才会在中途直接抛 InterruptedException。答到这个层次,面试官基本就认可你真的读过源码了。
我个人还有个学习建议:读 AQS 别从头文件一路往下啃,按"主线"读——先读 acquire/release 骨架,再对照 ReentrantLock 的非公平实现,然后跑一个多线程 demo 打断点看队列变化,最后再碰共享模式和 Condition。条件队列的 CONDITION 状态和同步队列是两套东西,第一次读很容易被绕晕,建议放在第二遍再看。这样两轮下来,juc 里大部分类你都能举一反三地看懂了。
