AQS 与 JUC 工具类全过程原理
AQS 是 Java 并发里非常关键、也非常容易学成“背八股”的知识点。很多人会背:
AQS 有一个
state和一个 FIFO 队列,ReentrantLock、CountDownLatch、Semaphore 都基于 AQS。
但真正面试或排查时,这句话远远不够。你要能说清:
state到底代表什么。- 线程抢不到资源为什么要入队。
- 入队后为什么要
park。 - 释放资源时为什么要
unpark后继线程。 - 独占模式和共享模式有什么区别。
- ReentrantLock、Semaphore、CountDownLatch 为什么行为不同但底层都能用 AQS。
- 公平锁为什么吞吐低一些,非公平锁为什么可能插队。
- 线上线程
WAITING、BLOCKED、锁等待时怎么联系到 AQS/JUC。
这一页把这些过程串起来。
学习目标
学完这一页,你要能回答:
- AQS 是什么,为什么 JUC 要抽象出 AQS。
state、同步队列、Node、waitStatus、park/unpark分别负责什么。- 独占获取和释放资源的完整流程。
- 共享获取和释放资源的完整流程。
- ReentrantLock 如何通过
state实现可重入。 - CountDownLatch 如何通过
state实现等待多个任务完成。 - Semaphore 如何通过
state实现许可证限流。 - Condition 条件队列和 AQS 同步队列有什么区别。
- 公平锁和非公平锁的底层差异。
- 商业项目里如何选择 ReentrantLock、CountDownLatch、Semaphore,而不是乱用。
为什么需要 AQS
并发工具经常要解决同一类问题:
多个线程竞争有限资源,拿到资源的继续执行,拿不到资源的排队等待,资源释放后再唤醒等待线程。
如果每个工具都自己写一套排队、阻塞、唤醒、中断、超时、取消逻辑,会非常复杂,也容易出错。
flowchart TD
A["并发工具共同问题"] --> B["资源状态怎么表示"]
A --> C["抢不到资源怎么排队"]
A --> D["线程如何安全阻塞"]
A --> E["资源释放后唤醒谁"]
A --> F["中断/超时/取消怎么处理"]
B --> G["AQS 抽象通用骨架"]
C --> G
D --> G
E --> G
F --> GAQS 的定位是:
AQS 不直接规定“什么是锁”,它提供一套同步器骨架。具体子类只需要定义“怎样算获取成功”和“怎样算释放成功”。
AQS 的三个核心部件
flowchart TD
A["AQS"] --> B["volatile int state<br/>同步状态"]
A --> C["CLH/FIFO 双向队列<br/>保存等待线程"]
A --> D["LockSupport<br/>park/unpark 阻塞和唤醒"]| 部件 | 可以理解成 | 作用 |
|---|---|---|
state | 资源数量或锁状态 | 判断当前资源能不能被获取 |
| 同步队列 | 排队区 | 抢不到资源的线程在这里排队 |
LockSupport | 停车/叫醒机制 | 让线程不占 CPU 地等待,释放后唤醒 |
不同工具对 state 的解释不同:
| 工具 | state 代表什么 | 什么时候线程通过 |
|---|---|---|
| ReentrantLock | 锁被重入的次数 | state == 0 或当前线程就是持有者 |
| Semaphore | 剩余许可证数量 | state > 0 且 CAS 扣减成功 |
| CountDownLatch | 剩余倒计数 | state == 0 |
| ReentrantReadWriteLock | 高位读锁数量,低位写锁重入次数 | 按读写锁兼容规则判断 |
为什么 state 要 volatile + CAS
state 会被多个线程同时读写。只用普通变量会有两个问题:
- 一个线程改了,其他线程可能看不到。
- 多个线程同时改,可能互相覆盖。
AQS 通过 volatile 保证可见性,通过 CAS 保证原子修改:
private volatile int state;
protected final int getState() {
return state;
}
protected final void setState(int newState) {
state = newState;
}
protected final boolean compareAndSetState(int expect, int update) {
// 底层是 CAS,只有当前值等于 expect 时才更新成 update
return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}举例:Semaphore 还有 1 个许可证,两个线程同时来拿。
flowchart TD
A["state = 1"] --> B["线程 A 看到还有许可"]
A --> C["线程 B 看到还有许可"]
B --> D["CAS 1 -> 0 成功"]
C --> E["CAS 1 -> 0 失败"]
D --> F["线程 A 获取许可"]
E --> G["线程 B 进入等待或重试"]如果没有 CAS,两个线程都可能以为自己拿到了最后一个许可证,限流就失效了。
同步队列是什么
线程抢不到资源,不能一直自旋消耗 CPU。AQS 会把它包装成 Node 放进同步队列。
flowchart TD
A["head<br/>哨兵节点"] --> B["Node A<br/>等待线程 A"]
B --> C["Node B<br/>等待线程 B"]
C --> D["tail<br/>队尾"]队列节点里通常关心:
| 字段 | 作用 |
|---|---|
prev | 指向前驱节点 |
next | 指向后继节点 |
thread | 当前节点对应的线程 |
waitStatus | 节点等待状态 |
nextWaiter | 条件队列或共享模式标记 |
为什么要有队列?
| 如果没有队列 | 后果 |
|---|---|
| 抢不到锁的线程全部忙等 | CPU 被自旋浪费 |
| 不记录等待顺序 | 唤醒谁不确定,容易混乱 |
| 不能处理中断和取消 | 超时等待、取消等待很难实现 |
| 不能支持公平策略 | 无法判断前面有没有人排队 |
waitStatus 怎么理解
waitStatus 是节点状态,常见值:
| 状态 | 含义 | 简单理解 |
|---|---|---|
0 | 初始状态 | 刚入队,还没明确后续动作 |
SIGNAL = -1 | 后继节点需要被唤醒 | 当前节点释放时要通知后面 |
CANCELLED = 1 | 节点取消等待 | 超时、中断或异常退出,不再竞争 |
CONDITION = -2 | 在条件队列中 | 等待 Condition.signal |
PROPAGATE = -3 | 共享模式继续传播 | 共享释放时继续唤醒后继 |
不用一开始背源码细节。先记住:
负数通常表示节点还有效并需要协作唤醒;正数
CANCELLED表示节点已经取消。
独占模式:ReentrantLock 的加锁流程
独占模式表示同一时刻只允许一个线程持有资源。
典型工具:ReentrantLock。
flowchart TD
A["线程调用 lock"] --> B["tryAcquire"]
B --> C{"获取成功"}
C -- "是" --> D["执行业务代码"]
C -- "否" --> E["封装成 Node 入队"]
E --> F{"前驱是否 head"}
F -- "是" --> G["再次 tryAcquire"]
G --> H{"是否成功"}
H -- "是" --> I["当前节点成为 head"]
H -- "否" --> J["park 挂起"]
F -- "否" --> J
J --> K["等待前驱释放后 unpark"]
K --> F关键点:
- 线程不会失败一次就马上睡眠,会先入队。
- 入队后只有前驱是
head的节点才有资格再次尝试获取。 - 获取失败后才
park,避免无意义消耗 CPU。 - 被唤醒后不是直接拥有锁,而是重新竞争。
为什么被唤醒后还要重新竞争?
因为唤醒只表示“你可以再试试”,不等于“锁一定归你”。在非公平锁中,新来的线程可能插队抢到锁;同时也可能存在中断、取消等情况。
独占模式:ReentrantLock 的释放流程
flowchart TD
A["线程调用 unlock"] --> B["tryRelease"]
B --> C["state 减 1"]
C --> D{"state 是否为 0"}
D -- "否" --> E["只是减少重入次数<br/>仍然持有锁"]
D -- "是" --> F["清空 owner"]
F --> G["找到有效后继节点"]
G --> H["LockSupport.unpark 唤醒后继线程"]
H --> I["后继线程重新竞争锁"]可重入锁最重要的点:
同一个线程加锁几次,就必须解锁几次。只有
state减到 0,锁才真正释放。
错误示例:
lock.lock();
lock.lock();
try {
// do something
} finally {
lock.unlock();
}这段代码只释放了一次,state 还没归零,其他线程会一直等。
正确写法:
lock.lock();
try {
lock.lock();
try {
// do something
} finally {
lock.unlock();
}
} finally {
lock.unlock();
}真实业务中不建议故意写复杂重入层级。更常见的是一个加锁方法内部调用另一个也需要同一把锁的方法,可重入避免自己把自己锁死。
公平锁和非公平锁
非公平锁
非公平锁允许新来的线程先尝试抢锁。
flowchart TD
A["锁刚释放"] --> B["队列中有等待线程"]
A --> C["新线程刚好来抢锁"]
C --> D{"CAS 抢锁是否成功"}
D -- "成功" --> E["新线程插队成功"]
D -- "失败" --> F["进入队列"]优点:
- 减少线程频繁挂起和唤醒。
- 吞吐量通常更高。
缺点:
- 等待线程可能被插队。
- 极端情况下等待时间不稳定。
公平锁
公平锁获取锁前会检查队列前面是否有人。
flowchart TD
A["线程请求锁"] --> B{"同步队列前面是否有等待线程"}
B -- "有" --> C["排队等待"]
B -- "没有" --> D["尝试 CAS 获取锁"]公平锁更接近先来先服务,但吞吐可能降低。原因是它更依赖排队唤醒,减少了“刚好正在运行的线程顺手拿锁”的机会。
商业项目默认一般用非公平锁。只有在确实需要等待顺序可控、避免长期饥饿时,才考虑公平锁。
共享模式:Semaphore 的流程
共享模式表示资源可以被多个线程同时获取,只要资源数量足够。
典型工具:Semaphore。
假设 Semaphore semaphore = new Semaphore(3),state = 3 表示还有 3 个许可证。
flowchart TD
A["线程 acquire"] --> B["读取 state"]
B --> C{"state 是否大于 0"}
C -- "是" --> D["CAS state - 1"]
D --> E{"CAS 是否成功"}
E -- "成功" --> F["获取许可,继续执行"]
E -- "失败" --> B
C -- "否" --> G["进入 AQS 队列等待"]
H["线程 release"] --> I["CAS state + 1"]
I --> J["唤醒等待线程"]Semaphore 适合做并发数限制:
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
public class HospitalApiLimitDemo {
private static final Semaphore LIMITER = new Semaphore(5);
public static void main(String[] args) {
for (int i = 0; i < 20; i++) {
int taskId = i;
new Thread(() -> callHospital(taskId)).start();
}
}
private static void callHospital(int taskId) {
boolean acquired = false;
try {
acquired = LIMITER.tryAcquire(2, TimeUnit.SECONDS);
if (!acquired) {
System.out.println("task " + taskId + " rejected by local limiter");
return;
}
System.out.println("task " + taskId + " calling hospital api");
TimeUnit.MILLISECONDS.sleep(300);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
if (acquired) {
LIMITER.release();
}
}
}
}注意:
acquire成功后必须release。- 推荐关键业务使用
tryAcquire(timeout),不要无限等。 - Semaphore 控制的是本 JVM 内并发,不是分布式限流。
共享模式:CountDownLatch 的流程
CountDownLatch 用来等待多个任务完成。
假设 new CountDownLatch(3),初始 state = 3。
flowchart TD
A["主线程 await"] --> B{"state 是否为 0"}
B -- "是" --> C["直接通过"]
B -- "否" --> D["进入 AQS 共享等待队列"]
E["任务 1 countDown"] --> F["state - 1"]
G["任务 2 countDown"] --> F
H["任务 3 countDown"] --> I["state 变为 0"]
I --> J["唤醒 await 等待线程"]
J --> C为什么 CountDownLatch 不能复用?
因为它的 state 只会从初始值不断减到 0,没有重置机制。归零后所有等待线程都会通过,后续再 await 也会直接通过。
Demo:
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class CollectSummaryDemo {
public static void main(String[] args) throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(3);
CountDownLatch latch = new CountDownLatch(3);
for (int i = 1; i <= 3; i++) {
int taskId = i;
pool.execute(() -> {
try {
System.out.println("collect part " + taskId);
} finally {
latch.countDown();
}
});
}
boolean finished = latch.await(3, TimeUnit.SECONDS);
if (!finished) {
System.out.println("collect timeout, need compensation");
} else {
System.out.println("all collect tasks finished");
}
pool.shutdown();
}
}关键点:
countDown()要放在finally,否则任务异常会导致主线程一直等。- 关键链路建议使用带超时的
await。 - 需要复用屏障时,用
CyclicBarrier或Phaser。
Condition:条件队列和同步队列
Condition 常和 ReentrantLock 搭配使用。它解决的是:
线程已经拿到锁,但某个业务条件不满足,需要释放锁并等待条件变化。
典型例子:队列为空时消费者等待;队列不为空时生产者唤醒消费者。
flowchart TD
A["线程持有 ReentrantLock"] --> B{"条件是否满足"}
B -- "满足" --> C["继续执行"]
B -- "不满足" --> D["condition.await"]
D --> E["释放锁"]
E --> F["进入 Condition 条件队列"]
G["其他线程 signal"] --> H["从条件队列转移到 AQS 同步队列"]
H --> I["重新竞争锁"]
I --> C注意:signal 不是让线程直接继续执行业务,而是把它从条件队列转移到同步队列。它还要重新竞争锁。
正确写法要用 while 检查条件:
lock.lock();
try {
while (queue.isEmpty()) {
notEmpty.await();
}
return queue.remove(0);
} finally {
lock.unlock();
}为什么不用 if?
因为线程被唤醒后,条件可能又被其他线程改变;也可能存在虚假唤醒。用 while 可以重新检查条件。
ReentrantLock、synchronized 怎么选
| 对比 | synchronized | ReentrantLock |
|---|---|---|
| 使用复杂度 | 简单,JVM 自动释放 | 必须手动 unlock |
| 可中断获取 | 不支持 | 支持 lockInterruptibly |
| 超时获取 | 不支持 | 支持 tryLock(time) |
| 公平锁 | 不支持直接配置 | 支持公平/非公平 |
| 多条件队列 | 一个对象 Monitor 条件队列 | 可以创建多个 Condition |
| 日常业务 | 优先使用 | 需要高级能力时使用 |
选择建议:
- 普通互斥代码块,用
synchronized更简单。 - 需要超时、可中断、公平锁、多条件队列,用
ReentrantLock。 - 不要为了“高级”滥用
ReentrantLock,忘记unlock的后果很严重。
线上排查:AQS/JUC 相关问题怎么看
大量线程 WAITING
如果线程栈显示:
java.lang.Thread.State: WAITING
at jdk.internal.misc.Unsafe.park
at java.util.concurrent.locks.LockSupport.park可能是:
- 等待 AQS 锁。
- 等待
CountDownLatch.await。 - 等待
Semaphore.acquire。 - 线程池 Worker 在队列中等待任务。
不要只看 WAITING,要继续看栈上方是哪一个类。
| 栈上方类 | 可能问题 |
|---|---|
ReentrantLock.lock | 锁竞争 |
CountDownLatch.await | 某些任务没 countDown |
Semaphore.acquire | 许可证耗尽或忘记 release |
LinkedBlockingQueue.take | 线程池空闲等待任务 |
大量线程 BLOCKED
BLOCKED 通常和 synchronized Monitor 竞争有关,不是 AQS 的 park 等待。
处理方向:
- 找到持有锁的线程。
- 看锁内是否有慢 SQL、远程调用、文件 IO。
- 缩小锁范围或改用并发容器。
CountDownLatch 一直不返回
常见原因:
- 子任务异常后没有执行
countDown。 - 任务根本没有提交成功。
- 线程池满了,子任务还在队列中。
await没有设置超时。
排查流程:
flowchart TD
A["await 一直等待"] --> B["检查 countDown 是否 finally"]
B --> C["检查子任务是否执行"]
C --> D["检查线程池队列和拒绝次数"]
D --> E["检查是否有子任务异常"]
E --> F["给 await 加超时和补偿"]Semaphore 许可泄漏
许可泄漏通常是 acquire 成功后业务异常,没有 release。
错误写法:
semaphore.acquire();
doWork();
semaphore.release();正确写法:
boolean acquired = false;
try {
semaphore.acquire();
acquired = true;
doWork();
} finally {
if (acquired) {
semaphore.release();
}
}商业场景怎么用
场景一:接口聚合等待多个结果
订单详情页需要同时查用户、订单、优惠券,可以用线程池 + CountDownLatch 等待结果汇总。但要设置超时,不能一个下游慢导致整个接口永久等待。
场景二:限制医院接口采集并发
外部医院接口承受能力有限,可以用 Semaphore 在单个服务实例内限制同时调用数量。分布式多实例场景还要配合 Redis 限流、网关限流或 Sentinel。
场景三:复杂队列通知
本地内存队列中,消费者等待“不为空”,生产者等待“不满”,可以用 ReentrantLock + 两个 Condition,比单个 wait/notify 更清晰。
场景四:热点资源保护
某个设备采集协议同一时间只能一个线程操作,可以用 ReentrantLock 或按设备维度的锁映射。但要避免锁对象无限增长,注意过期清理。
面试标准回答
AQS 是什么
标准回答:
AQS 是 AbstractQueuedSynchronizer,是 JUC 并发工具的同步器基础框架。它用 volatile int state 表示同步状态,用 FIFO 双向队列保存等待线程,用 CAS 修改状态,用 LockSupport.park/unpark 阻塞和唤醒线程。具体工具只需要实现 tryAcquire、tryRelease、tryAcquireShared、tryReleaseShared 等方法定义资源获取和释放规则。ReentrantLock 把 state 当作锁重入次数,Semaphore 把 state 当作许可证数量,CountDownLatch 把 state 当作剩余倒计数。追问点:
- 为什么 AQS 需要队列?
state为什么要 volatile + CAS?- 独占模式和共享模式区别是什么?
- 被
unpark的线程是不是马上拿到锁?
ReentrantLock 为什么可重入
标准回答:
ReentrantLock 基于 AQS 实现,可重入依赖 state 和独占持有线程。第一次加锁时,如果 state 为 0,线程 CAS 把 state 改成 1 并设置 owner。后续同一个线程再次加锁时,不需要排队,而是把 state 加 1。释放锁时 state 减 1,只有减到 0 才清空 owner 并唤醒后继线程。所以同一个线程加锁几次,就必须 unlock 几次。CountDownLatch 和 Semaphore 底层区别
标准回答:
它们都基于 AQS 共享模式,但 state 含义不同。CountDownLatch 的 state 表示剩余倒计数,await 时如果 state 不为 0 就进入等待队列,countDown 把 state 减到 0 后唤醒等待线程,且不能复用。Semaphore 的 state 表示剩余许可证,acquire 时 state 大于 0 才能扣减成功,否则等待;release 会归还许可证并唤醒等待线程。CountDownLatch 解决等待完成,Semaphore 解决并发限流。关联知识点
| 知识点 | 说明 |
|---|---|
| AQS | 原有 AQS 源码笔记 |
| ReentrantLock | 可重入锁、公平锁、非公平锁 |
| ReentrantReadWriteLock | 读写锁和共享/独占思想 |
| CAS 原理 | CAS 如何保证原子更新 |
| JUC 工具类 | CountDownLatch、Semaphore、CyclicBarrier |
| 线程池生命周期与排查 | 线程状态、队列堆积和 jstack 排查 |
| JavaSE 面试 | 标准回答和追问 |
本章小结
AQS 的核心不是背源码字段,而是理解同步器的通用模型:用 state 表示资源状态,用 CAS 修改状态,用队列保存等待线程,用 park/unpark 控制阻塞和唤醒。ReentrantLock、Semaphore、CountDownLatch 的差异,本质上是它们对 state 的解释不同,以及使用独占模式还是共享模式不同。掌握这条主线后,JUC 就不再是一堆零散 API。
