Lock 体系与 AQS 原理
synchronized 是 JVM 层面的内置锁,使用简单但功能有限。从 JDK 5 开始,Doug Lea 设计了 java.util.concurrent.locks 包,提供了更灵活的锁实现。整个 Lock 体系的基石是 AQS(AbstractQueuedSynchronizer)——理解 AQS,就理解了 JUC 一半以上组件的底层原理。
java.util.concurrent.locks 包核心框架图:
java.util.concurrent.locks
├── [接口层]
│ ├── Lock (互斥锁基本接口)
│ ├── ReadWriteLock (读写锁基本接口)
│ └── Condition (条件变量接口,替代 wait/notify)
│
├── [AQS 框架层] (整个包的核心)
│ ├── AbstractOwnableSynchronizer (维护独占模式下持有同步状态的线程)
│ └── AbstractQueuedSynchronizer (AQS核心:维护 state 状态与 CLH 等待队列)
│
├── [具体锁实现]
│ ├── ReentrantLock (实现 Lock 接口)
│ │ └── 依赖内部类 Sync:继承自 AQS (内部拆分了 FairSync 和 NonfairSync)
│ │
│ ├── ReentrantReadWriteLock (实现 ReadWriteLock 接口)
│ │ ├── ReadLock (共享锁,实现了 Lock 接口)
│ │ ├── WriteLock (独占锁,实现了 Lock 接口)
│ │ └── 依赖内部类 Sync:继承自 AQS (内部拆分了 FairSync 和 NonfairSync)
│ │
│ └── StampedLock (JDK8+ 引入)
│ └── ⚠️特殊设计:不基于 AQS,内部提供“乐观读锁”机制解决写饥饿问题
│
└── [底层辅助工具]
└── LockSupport (提供底层原语 park / unpark 方法,负责线程阻塞和唤醒)
ReentrantLock vs synchronized
先看一张对比表,记住核心差异:
| 特性 | synchronized | ReentrantLock |
|---|---|---|
| 实现层面 | JVM 内置(monitorenter/monitorexit) | Java 类库(AQS) |
| 加锁/解锁 | 自动(进入/退出代码块) | 手动(必须在 finally 中 unlock) |
| 可中断 | 不可中断(等待锁时无法响应中断) | lockInterruptibly() 可中断 |
| 超时获取 | 不支持 | tryLock(timeout) 可超时 |
| 公平性 | 非公平 | 可选公平/非公平 |
| 条件变量 | 只有一个等待队列(wait/notify) | 支持多个 Condition |
| 锁绑定 | 锁绑定到对象/类 | 锁是独立的对象 |
| 性能 | JDK 6+ 优化后差距不大 | 高竞争时略优 |
基本用法
private final ReentrantLock lock = new ReentrantLock();
public void doSomething() {
lock.lock(); // 加锁
try {
// 临界区代码
} finally {
lock.unlock(); // 必须在 finally 中解锁!
}
}
为什么 unlock 必须放在 finally 中? 如果临界区抛出异常而没有 finally 保障解锁,锁将永远不会被释放,其他线程将永远阻塞。
synchronized不会有这个问题——JVM 保证异常退出时自动释放锁。
可中断(Interruptible)锁:解决盲目“死等”
什么叫“可中断”?能中断什么? 所谓的“可中断”,中断的并非是拿到锁之后的执行过程,而是“排队等待锁的过程”。
想象你正在排队买饭。synchronized 或者普通的 lock.lock() 的排队策略是“死等”:哪怕系统发生死锁了、或者外部想紧急取消你的任务,只要还没轮到你拿到锁,外界发来的 thread.interrupt() 中断信号你都会置之不理。你会被一直死死卡在排队队列里。
而 lock.lockInterruptibly() 提供了一种**“能听劝”**的排队机制:
try {
lock.lockInterruptibly(); // 排队等待锁,同时竖起耳朵监听“中断”信号
try {
// 终于拿到锁了,执行业务
} finally {
lock.unlock();
}
} catch (InterruptedException e) {
// 如果在【排队期间】,别的线程调用了当前线程的 interrupt()
// 线程不会再继续死等,而是立刻吃下异常、退出等待队列,去执行别的逻辑
System.out.println("排队太久接到了取消通知,放弃抢锁!");
}
底层对
interrupt()信号的响应差异:
synchronized(处于 BLOCKED 状态):完全无视中断信号,无法被外部唤醒,非常容易造成死锁无法自动解开。lock.lock()(处于 WAITING 状态):底层AQS排队时虽然能察觉到中断信号,但它只会把中断标记暂存下来,然后继续死皮赖脸地排队。强行憋到自己拿到锁之后,才会把当初暂存的中断状态重新标记上 (selfInterrupt())。lock.lockInterruptibly()(处于 WAITING 状态):底层AQS排队时如果在park阻塞期间被人打断,会立刻放弃排队并抛出InterruptedException。这在进行“取消任务”或“打破死锁僵局”的业务场景中至关重要。
超时获取
if (lock.tryLock(3, TimeUnit.SECONDS)) {
try {
// 获取到锁,执行业务
} finally {
lock.unlock();
}
} else {
// 超时未获得锁,执行降级逻辑
}
tryLock() 的无参版本是非阻塞的——如果锁被占用,立即返回 false,不会等待。
Condition 条件变量
synchronized 只有一个等待队列(一个对象对应一个 wait/notify)。Condition 可以创建多个等待队列,实现更细粒度的线程通信。
private final ReentrantLock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition(); // 缓冲区未满
private final Condition notEmpty = lock.newCondition(); // 缓冲区非空
// 生产者
public void put(Object item) throws InterruptedException {
lock.lock();
try {
while (isFull()) {
notFull.await(); // 等待"未满"条件
}
addItem(item);
notEmpty.signal(); // 通知"非空"条件
} finally {
lock.unlock();
}
}
// 消费者
public Object take() throws InterruptedException {
lock.lock();
try {
while (isEmpty()) {
notEmpty.await(); // 等待"非空"条件
}
Object item = removeItem();
notFull.signal(); // 通知"未满"条件
return item;
} finally {
lock.unlock();
}
}
AQS 核心原理(源码级深度解析)
❓ 灵魂拷问:什么是 AQS?为什么要学它?
AQS(AbstractQueuedSynchronizer)是整个 JUC 锁体系的基石。如果将并发编程比作建造医院,volatile 和 CAS 是砖块,那么 AQS 就是医院的排队叫号系统。ReentrantLock、CountDownLatch、Semaphore 都是基于这套叫号系统建出来的不同科室。它是通过模板方法设计模式实现的,子类只管写自己的“接客逻辑”,而排队、叫号、阻塞、唤醒这种复杂的脏活儿累活儿全由 AQS 兜底。
1. 核心架构:一灯、一队、一闹钟
AQS 的底层完全依赖两个核心实体:state 状态字段 和 CLH 变体双向等待队列。
CLH 是什么? CLH 取自三位发明者的姓氏首字母:Craig、Landin、Hagersten。它最初是一种基于链表的自旋锁算法——每个等待线程被封装成一个节点,挂在链表尾部,通过不断自旋检查前驱节点的状态来判断"轮到我了没"。AQS 并非原封不动地照搬,而是在其基础上做了两大改造:① 把单向链表升级为双向链表(方便从后往前找人);② 把纯自旋改为自旋 +
LockSupport.park()阻塞(避免 CPU 空转浪费)。所以我们称之为 "CLH 变体"。
为了便于理解,我们把 AQS 想象成一个只有一个单间的公共更衣室。
❓ 疑问一:state 究竟代表什么?有几种值?
// AQS 源码
private volatile int state; // 同步状态
state 就是更衣室锁上的【状态指示灯】。
volatile保证所有人都能立刻看到灯颜色的变化。CAS(Compare-And-Swap) 机制保证多个人同时伸手去锁门时,只有一个人能锁成功。
它有几种值?(它的语义完全由子类定制)
- 在
ReentrantLock(独占锁) 中:state == 0:绿灯,更衣室空闲。state == 1:红灯,更衣室有人。state > 1:也是红灯,表示同一个大哥反复进去了多次(可重入,值为重入的次数)。
- 在
Semaphore(共享锁) 中:state == 5:还有 5 个空闲更衣室。state == 0:全满了,后面的人排队。
❓ 疑问二:没抢到锁的人去哪?一直排队自旋会耗死 CPU 吗?(CLH 等待双向队列)
当你看到指示灯是红的(抢锁失败),AQS 不会让线程像无头苍蝇一样无限进行 while(true) 的“死自旋”(这会把 CPU 跑满)。
相反,AQS 采用了**“短暂自旋 + 阻塞休眠”的混合机制。它会把你和你的等待状态封装成一个 Node(相当于给你发一把椅子),让你去走廊排队。在排队安顿好之后,它会调用底层 OS 原语把线程真正陷入休眠(LockSupport.park)**,此时线程处于 WAITING 状态,将不再占用任何 CPU 资源。
🎨 队列结构图解:
[AQS 排队等候区:双向链表]
+------+ prev +---------+ prev +---------+
| head | <======== | Node(A) | <======== | tail |
(虚拟头结点) | | ========> | 线程 A | ========> | 线程 B |
+------+ next +---------+ next +---------+
▲ ▲ ▲
(在更衣室里的人) (前面有人,坐在椅子上睡觉) (刚来排队找位置)
关键设计:为什么最前面有一个空的 head 节点?
你可以把 head 节点理解为**“前台登记处”或“更衣室里正在换衣服的那个人留在走廊上的空椅子”**。队伍里的任何一个人在睡觉前,都必须找他前面的人帮他“设闹钟”(留个纸条:你走的时候叫醒我)。如果第一个排队的人前面没有实体节点,就没有人帮他设闹钟(空指针报错),所以必须初始化一个空椅子放在最前面化解边界问题。
2. ❓ 疑问三:加锁的流程到底是怎样的?图解抢锁挣扎史
当你调用 lock.lock() 时,一场激烈的争夺战就开始了。在这个层级,ReentrantLock 作为最外层的锁壳子,充当了第一个“拦路虎”。以默认非公平锁为例,你一脚踹开更衣室大厅的门:
// ReentrantLock (NonfairSync 的实现)
final void lock() {
// 进大厅的第一秒,不管三七二十一,先试图“白嫖”一把锁 (CAS state 从 0 变 1)
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread()); // 竟然门是绿灯!大漏特漏,直接抢滩登陆!
else
// 门是红灯,或者刚好没白嫖到被别人抢先一秒,认命吧,去呼叫底层主管 AQS,走正式加锁排队流程。
// 参数 "1" 代表每次索要 1 个资源。这也是后续重入计数累加的基本加减单位。
acquire(1);
}
紧跟着点入 acquire(1),正式移交控制权给 AQS 抽象类,进入无情铁律流程:
👨🏫 场景设想:你刚才开盲盒抢锁失败了,于是你开始接受下面这套极其严密的流转程序的审判:
// AQS 提供的独占式加锁总指挥模板
public final void acquire(int arg) {
if (!tryAcquire(arg) && // 1. 【拼手速】尝试直接去拧门把手抢
acquireQueued( // 3. 【睡椅子】在椅子上老老实实死等
addWaiter(Node.EXCLUSIVE), // 2. 【搬椅子排队】拿把椅子放到走廊队伍最后
arg))
selfInterrupt(); // 4. 获取期间如果被中断,退出后补发中断标志
}
动作 0:模板方法与打头阵的 tryAcquire()
AQS 作为一个核心骨架(模板方法模式),它帮我们写好了排队、睡觉、唤醒的所有逻辑,唯独把最重要的“门把手长什么样”、“怎么判断门被锁了”交给了子类去自定义。
因此源码中 AQS 自身的 tryAcquire 只是一个空抛异常的坑位,必须由子类实现。
我们以 ReentrantLock(默认非公平锁)为例,看看它的 tryAcquire 取锁逻辑:
// ReentrantLock.NonfairSync 最终调用的尝试加锁方法
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread(); // 看看我是谁
int c = getState(); // 抬头看一眼更衣室的指示灯 (state)
if (c == 0) {
// 门头是绿灯 (state=0)!赶紧拼单身 20 年的手速抢门把手!
if (compareAndSetState(0, acquires)) { // [CAS 强行将 state 改为 1]
setExclusiveOwnerThread(current); // 抢滩登陆!把更衣室的所有权写上我的名字
return true; // 成功进门!不用排队了!
}
}
// 门头是红灯,但走近定睛一看,门上写的是我自己的名字!(所谓“锁的可重入性”)
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires; // 计数器累加,表示我又进了一层(重入)
setState(nextc); // 更新指示灯(因为本身就在我手里,单线程访问不需要 CAS)
return true; // 成功进门!
}
// 里面的不是我,或者刚才门虽然是绿的,但我手速慢了一毫秒被别人抢先进了
return false; // 尝试宣告彻底失败,乖乖准备去受苦排队吧
}
只有当 tryAcquire 返回 false(第一关战败),外层的 if (!tryAcquire()) 判断才会成真,并继续用 && 往后走,进入 AQS 最核心的排队大阵:搬椅子和死睡。
动作 1:安全进队 addWaiter() 与 enq()(把椅子搬到队尾)
要把椅子放到最后,但在高并发下一群人都想把椅子放最后,怎么办?AQS 用了自旋 (死循环) + CAS:
private Node enq(final Node node) {
for (;;) { // 死循环,直到你的椅子放好为止才准放行!
Node t = tail;
if (t == null) {
// 队伍空荡荡,先摆一张空椅子(虚拟头节点)
if (compareAndSetHead(new Node())) tail = head;
} else {
// 开始把自己的椅子往上接
node.prev = t; // [步骤 1] 定睛一看:我盯着前面的人的后脑勺
if (compareAndSetTail(t, node)) { // [步骤 2] 大喊一声:现在“队尾(tail)”是我了!(CAS)
t.next = node; // [步骤 3] 前面的人回头:哦,行,我看着你了
return t;
}
}
}
}
动作 2:等待与挂闹钟 acquireQueued()(在队伍里等)
椅子放好了,你坐下,但不能马上睡。万一此时更衣室里的大哥恰好出来了呢?
final boolean acquireQueued(final Node node, int arg) {
// ...
for (;;) { // 再次死循环
final Node p = node.predecessor(); // 看看坐在我前面的是谁
// 巧合:如果我前面就是【那张空椅子(head)】,意味着我是排在第一位的!
if (p == head && tryAcquire(arg)) {
// 我立刻冲上去试转一下门把手,啪嗒,开了!进!
setHead(node); // 把我的椅子变成空椅子,我进去了!
p.next = null; // 踢走旧空椅子(帮助 GC)
return interrupted;
}
// 门没开/前面还有一大排人,准备睡觉了 (挂闹钟牌子 waitStatus = SIGNAL)
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt()) { // 真正的深睡!LockSupport.park()
// 呼呼大睡中... (即使外面天崩地裂也不会动,直到彻底被唤醒才从这里继续执行)
}
}
}
🛎️ 前半句:挂闹钟(shouldParkAfterFailedAcquire):
在正式闭眼死睡之前,必须确保前面的人知道你要睡了,交代他“你走的时候一定要叫醒我”。这就是这个长名字的方法做的事:
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
int ws = pred.waitStatus; // 看看坐在前面那个人的状态 (挂着什么牌子)
if (ws == Node.SIGNAL) // SIGNAL 的值是 -1
return true; // 他背后已经妥妥挂好“-1(求叫醒)”的牌子了,我可以绝对安心去睡了!
if (ws > 0) { // 大于 0 只有一种可能:CANCELLED (这个人等不及已经跑路了)
// 也就是前面是个空座或者死人
do {
node.prev = pred = pred.prev; // 绕过所有跑路的人,一直往前找
} while (pred.waitStatus > 0); // 直到找到一个还在正常排队的大哥
pred.next = node; // 把自己的椅子重新接到这位大哥后面
} else {
// 前面的人状态正常,但还没挂牌子 (默认是 0)。
// 赶紧揪住他,给他贴上“-1”的牌子 (CAS 尝试将前一个节点的状态改为 -1)。
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
// 🚨 注意:如果没进第一个 if(也就是刚把废节点清理掉,或者刚挂上牌子),最后都会返回 false!
// 返回 false 就会导致外层的 `for(;;)` 重新再转一圈,给本线程最后一次活命的机会再去抢一次门。
return false;
}
😴 后半句:彻底休眠(parkAndCheckInterrupt):
当如果上一圈已经挂好了牌子,再转一圈进来时上面的方法终于返回了 true,外层短路与 && 就会放行。紧接着执行右半部分,也是真正交出 CPU 使用权的核心最后的一步:
private final boolean parkAndCheckInterrupt() {
// 💀 呼叫 OS 操作系统底层原语!当前线程在这里被瞬间冻结,彻底陷入休眠 (WAITING 状态)!
// 你的代码会在这里停滞不前,不再执行下面的哪怕半行代码,也剥夺了一切 CPU 调度权!
LockSupport.park(this);
// ==============================================================
// 【时光飞逝... 直到有一天,前面排队的人推门出来,调用 unpark 狠狠拍醒了你】
// You are Wake Up!你倒吸一口凉气睁眼了,接着从下面这行代码继续跑:
// ==============================================================
// 醒来第一件事:迷迷糊糊检查一下自己深睡的时候,是不是有人试图打我电话取消任务 (发出过 Interrupt 信号)
return Thread.interrupted();
} // 带着中断的状态返回到外层 acquireQueued 的死循环中,再次高吼着冲向 tryAcquire 抢门!
3. ❓ 疑问四:释放锁的流程是怎么样的?
当你在更衣室换完衣服,推门出来(lock.unlock()),会发生什么?
与加锁一样,我们先从最外层的 ReentrantLock 的壳子开始看:
// ReentrantLock.unlock() —— 最外层入口
public void unlock() {
sync.release(1); // 直接呼叫 AQS 的 release 模板方法,参数 "1" 表示释放 1 个资源
}
一行代码,简单粗暴地呼叫 AQS 的 release(1),进入解锁总调度:
// AQS 释放锁总指挥模板
public final boolean release(int arg) {
if (tryRelease(arg)) { // 1. 【关红灯】先问子类:state 扣完了没?
Node h = head;
if (h != null && h.waitStatus != 0) // 接待员(也就是刚刚的你)背后如果贴着"-1(求叫醒)"的纸条
unparkSuccessor(h); // 2. 【叫醒后面的人】
return true;
}
return false; // tryRelease 返回 false,说明锁还没完全释放(重入计数没扣到 0),还不到叫人的时候
}
动作 0:tryRelease() —— 把指示灯拨回绿色
和加锁时的 tryAcquire 一样,tryRelease 也是由子类 ReentrantLock.Sync 自己实现的模板方法:
// ReentrantLock.Sync 的释放锁实现
protected final boolean tryRelease(int releases) {
int c = getState() - releases; // 把指示灯的数字减 1(如果重入了 3 次,state 是 3,减完后变 2)
// 安全校验:如果锁明明不是你的,你却要解锁,直接抛异常!
// 这就是为什么在没 lock() 的情况下调用 unlock() 会报 IllegalMonitorStateException
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false;
if (c == 0) {
// state 扣到 0 了!说明所有重入层级全部退出完毕,锁真正被释放了!
free = true;
setExclusiveOwnerThread(null); // 把更衣室门上的名字擦掉,变成无主之地
}
setState(c); // 更新指示灯(不需要 CAS,因为此时锁还在自己手里,是单线程独占访问)
return free; // 只有当 free=true(state 归零)时,外层 release 才会去叫醒排队的人
}
🔑 关键细节:为什么重入锁不是
unlock()一次就完全释放? 假设你调用了 3 次lock()(重入 3 次,state=3),那么你也必须精确地调用 3 次unlock(),每次state减 1,减到state=0才算真正释放。如果只 unlock 了 2 次就跑路了,tryRelease会返回false,外层的release永远不会去执行unparkSuccessor唤醒后面的人,走廊里排队的线程将永远沉睡!
动作:寻找下一个睡着的人
unparkSuccessor(h) 负责去把队伍里的下一个人叫醒(LockSupport.unpark)。完整源码如下:
private void unparkSuccessor(Node node) {
// node 就是当前的 head(你刚才待过的那张空椅子)
int ws = node.waitStatus;
if (ws < 0)
// 你背后贴着"-1(求叫醒)"的纸条,先把纸条撕掉(CAS 归零),免得别人重复操作
compareAndSetWaitStatus(node, ws, 0);
// ====== 开始找人:谁是下一个该被叫醒的? ======
Node s = node.next; // 直觉:先看紧挨着我后面的那个人
if (s == null || s.waitStatus > 0) {
// 糟糕!后面那个位子要么是空的 (null),要么那个人已经等不及跑路了 (CANCELLED, ws>0)
s = null;
// 🚨 经典神级操作:正向找不到人,那就从队伍最末尾 (tail) 开始,一路往前倒着找!
for (Node t = tail; t != null && t != node; t = t.prev)
if (t.waitStatus <= 0)
s = t; // 记住这个人,但不急着停,继续往前找,确保找到的是最靠前的那个正常排队者
}
if (s != null)
// 找到了!狠狠拍醒他!他将在 parkAndCheckInterrupt 的 park() 处猛然苏醒!
LockSupport.unpark(s.thread);
}
🔥为什么要从后往前找(.prev),而不是从前往后找(.next)?
回忆刚才加锁排队时“把椅子塞进队尾 (enq)”的三个步骤:
node.prev = t(我盯着前面的后脑勺)CAS (修改全局 tail)(我大喊我是最后一位)t.next = node(前面的人回头看我)
这三步不是原子操作!想一下这导致了什么可怕的极端情况:
如果并发量极大,来排队的人刚走完步骤 1 和 2(他的椅子放下了,尾巴 tail 也指向了他),但 CPU 时间片突然切走了,他没来得及走步骤 3(前面那个人的 .next 指针还没来得及指过来,此时还是 null!)。
恰好就在这一微秒,你推门跑出来了!此时如果你顺着前门 .next 往后找,一看是 null,就会惊呼:“没人排队,散了散了!” 从而导致那个排到一半的人永远在角落深睡,导致严重的死锁!
但如果由于 tail 已经变了,从后方看,你顺着 .prev 从后往前逆向追踪,因为步骤 1 是最早执行的最坚固的链接,.prev 指针绝对没有任何断链风险。它天然具有强一致性!绝对不会漏掉任何一个排队的人。
4. ❓ 疑问五:图解插队:公平锁与非公平锁的本质区别
在 ReentrantLock 内部,公平锁与非公平锁所有的核心逻辑复用率高达 99%。它们唯一的区别就只有抢锁(tryAcquire)时的一行代码。
- 非公平锁(NonFairSync,默认实现): 就像一个不讲武德的人,刚进大厅不管走廊上是不是黑压压排满了人,只要一看见大门恰好是绿灯,直接如闪电般冲过去按门把手(执行一次 CAS 争抢锁)。如果运气爆表,刚好上个人推门出来,这个家伙就无视了所有苦苦坐在椅子上排队的人,直接插队进去了!如果没抢过(变红灯了),他才骂骂咧咧走刚刚讲好的排队流程。
- 公平锁(FairSync):
讲究中国传统的先来后到,进大门前一定要调用方法
hasQueuedPredecessors()—— 伸出头看一眼:前面有没有人在排队? 只要走廊里有哪怕一个哪怕刚刚放下的椅子,他立刻搬着自己的空椅子去队最后坐下,绝不插队半步。
💡 抛出问题:非公平锁这么不道德,为什么 JUC 偏偏拿它做默认锁?
因为性能(吞吐量)。
你要知道,把一个在椅子上死睡的人唤醒(调用 unpark(),操作系统涉及复杂的【内核态 / 用户态】上下文切换),是一个非常缓慢且开销巨大的过程!
就在等待被唤醒的这零点几个毫秒的时间里,更衣室完全是空着的(锁是空闲的)!
这时如果允许一个刚好溜达进来的活蹦乱跳的线程直接进去办事,他办完事出来甚至都不耽误刚被叫醒的那个人起身进去。整个更衣室的利用率(吞吐量)就得到了巨量的提升。
非公平锁仅仅是用"排队头部的人容易稍稍被插队"的代价,换来了整体几十甚至上百倍的性能飞跃。
5. AQS 的共享模式:从独占到共享的跃迁
前面疑问二到疑问四,我们完整拆解了**独占模式(Exclusive)**的加锁 acquire() 和解锁 release() 流程。但 AQS 还有另一半武功:共享模式(Shared)——同一时间允许多个线程同时"进门"。CountDownLatch、Semaphore、ReadWriteLock 的读锁都基于此。
共享模式加锁:acquireSharedInterruptibly()
共享模式的总指挥方法,和独占模式的 acquire() 是同一级别:
// AQS 共享模式获取资源的模板方法
public final void acquireSharedInterruptibly(int arg) throws InterruptedException {
if (Thread.interrupted())
throw new InterruptedException(); // 共享模式默认支持响应中断(和 lockInterruptibly 类似)
if (tryAcquireShared(arg) < 0) // 问子类:资源够不够?(返回值 < 0 代表不够)
doAcquireSharedInterruptibly(arg); // 不够 → 和独占模式一样:进 CLH 队列排队 park 休眠
}
doAcquireSharedInterruptibly 的内部逻辑和独占模式的 acquireQueued 几乎一模一样(进队 → 自旋 → 挂闹钟 → park 休眠),唯一的关键区别是:
独占模式 acquireQueued: 共享模式 doAcquireSharedInterruptibly:
┌─────────────────────────┐ ┌─────────────────────────────────────┐
│ 抢到锁后: │ │ 抢到资源后: │
│ setHead(node) │ │ setHeadAndPropagate(node, r) │
│ (只唤醒自己,独占) │ │ (唤醒自己 + 连锁唤醒后面的共享节点!) │
└─────────────────────────┘ └─────────────────────────────────────┘
🔑
setHeadAndPropagate的连锁唤醒(Propagate):共享模式的线程被唤醒并成功获取资源后,会再调用doReleaseShared()去唤醒它后面排队的共享节点——如此像多米诺骨牌一样一个接一个地传播,直到队列中所有共享模式的节点全部苏醒。这就是为什么CountDownLatch.await()归零时,所有等待线程会同时被唤醒,而不是一个一个来。
共享模式解锁:releaseShared()
// AQS 共享模式释放资源的模板方法
public final boolean releaseShared(int arg) {
if (tryReleaseShared(arg)) { // 问子类:资源释放到可以唤醒别人的程度了吗?
doReleaseShared(); // 是的!→ 唤醒队列中等待的共享节点(连锁传播唤醒)
return true;
}
return false; // 还没释放到位,静默返回,不唤醒任何人
}
独占 vs 共享模式对比总结:
| 对比维度 | 独占模式 (Exclusive) | 共享模式 (Shared) |
|---|---|---|
| 加锁总指挥 | acquire() |
acquireSharedInterruptibly() |
| 子类实现 | tryAcquire() 返回 boolean |
tryAcquireShared() 返回 int(<0 失败) |
| 解锁总指挥 | release() |
releaseShared() |
| 子类实现 | tryRelease() 返回 boolean |
tryReleaseShared() 返回 boolean |
| 唤醒行为 | 只唤醒后继一个节点 | 连锁传播唤醒所有共享节点 |
| 典型子类 | ReentrantLock | CountDownLatch, Semaphore |
6. ❓ 疑问六:AQS 经典子类源码——state 的千变万化
理解了上面独占模式和共享模式的 AQS 框架层源码后,我们来看看不同子类是如何通过定制 tryAcquireShared / tryReleaseShared 的语义,把同一个 state 字段玩出完全不同的花样的:
子类一:CountDownLatch(倒计时门闩)
现实比喻:你是一位老师,安排了 5 个学生去不同的餐厅打饭。你站在教室门口等着,直到 5 个学生全部回来了,你才开门让大家一起吃。
state的含义:还有几个学生没回来。初始化为new CountDownLatch(5)时,state = 5。countDown():每有一个学生回来,state减 1。await():老师在门口等(park阻塞),直到state减到 0 才被唤醒。
典型使用场景:微服务启动时,主线程需要等待多个子模块(数据库连接、缓存预热、配置加载)全部就绪后才能对外提供服务:
// 场景:系统启动,主线程等 3 个子模块全部初始化完毕后才开放流量
CountDownLatch latch = new CountDownLatch(3); // state = 3
// 子模块 1:数据库连接池初始化
new Thread(() -> {
initDatabasePool();
System.out.println("数据库连接池就绪");
latch.countDown(); // state: 3 → 2
}).start();
// 子模块 2:Redis 缓存预热
new Thread(() -> {
warmUpCache();
System.out.println("缓存预热完成");
latch.countDown(); // state: 2 → 1
}).start();
// 子模块 3:加载远程配置
new Thread(() -> {
loadRemoteConfig();
System.out.println("配置加载完成");
latch.countDown(); // state: 1 → 0
}).start();
// 主线程:在这里阻塞等待,直到 3 个子模块全部 countDown 完毕 (state 归零)
latch.await();
System.out.println("所有模块就绪,系统开始对外服务!");
await() 的调用链:await() → sync.acquireSharedInterruptibly(1)(AQS 共享模式总指挥,流程详见上方疑问五之后的「AQS 共享模式」章节)→ tryAcquireShared()(子类实现):
// CountDownLatch.Sync 子类实现的"门能开吗?"判断
protected int tryAcquireShared(int acquires) {
return (getState() == 0) ? 1 : -1;
// state 还不是 0?说明还有学生没回来,返回 -1 → AQS 框架会把老师线程送进 CLH 队列 park 死等
// state == 0?所有学生到齐了!返回 1 → 获取成功,老师直接通过
}
countDown() 的调用链:countDown() → sync.releaseShared(1)(AQS 共享模式释放总指挥,流程详见上方「AQS 共享模式」章节)→ tryReleaseShared()(子类实现):
// CountDownLatch.Sync 子类实现的"学生回来报到"
protected boolean tryReleaseShared(int releases) {
for (;;) { // 自旋 CAS,保证并发安全(多个学生可能同时到)
int c = getState();
if (c == 0)
return false; // 已经是 0 了,不需要再减了(防御性编程)
int nextc = c - 1;
if (compareAndSetState(c, nextc)) // CAS 将 state 从 c 改为 c-1
return nextc == 0;
// 减完恰好是 0 → 返回 true → AQS 框架执行 doReleaseShared() 连锁唤醒所有等待的线程!
// 减完还不是 0 → 返回 false → 还有学生没到,继续等
}
}
💡 注意:
CountDownLatch是一次性的!state减到 0 之后不能重置。如果需要可以重复使用的屏障,请看CyclicBarrier(它底层用的是ReentrantLock+Condition,不直接基于 AQS)。
子类二:Semaphore(信号量 / 许可证机制)
现实比喻:一个停车场有 3 个车位(new Semaphore(3))。每来一辆车试图 acquire() 一个车位,车位满了就在门口排队等(park)。有车开走时 release() 归还车位,排队的车被叫醒进场。
state的含义:当前剩余的可用车位数(许可证数量)。acquire():消耗一个车位,state减 1。减到 0 后再来的车全部排队。release():归还一个车位,state加 1,同时唤醒排队的车。
// Semaphore 内部的 NonfairSync(非公平实现,默认)
// acquire() 最终调用的:能不能进停车场?
protected int tryAcquireShared(int acquires) {
for (;;) { // 自旋 CAS
int available = getState(); // 看看还有几个空车位
int remaining = available - acquires; // 如果我停进去,还剩几个?
if (remaining < 0 || // 车位不够了!(remaining < 0)
compareAndSetState(available, remaining)) // 车位够!CAS 抢占一个车位
return remaining;
// 返回负数 → 进入 CLH 队列排队 park
// 返回 0 或正数 → 成功拿到车位,进场!
}
}
// release() 最终调用的:归还车位
protected final boolean tryReleaseShared(int releases) {
for (;;) { // 自旋 CAS
int current = getState();
int next = current + releases; // 归还后车位数 +1
if (next < current) // 溢出检测(防御性编程,防止 release 次数超过 int 上限)
throw new Error("Maximum permit count exceeded");
if (compareAndSetState(current, next)) // CAS 更新剩余车位数
return true; // 返回 true → 外层 AQS 会唤醒排队等车位的线程!
}
}
🔑 Semaphore vs ReentrantLock 的本质区别:
ReentrantLock:state从 0 开始,加锁时 +1(0→1 代表"从空到占"),是独占模式。Semaphore:state从 N 开始,获取时 -1(N→N-1 代表"消耗一个许可"),是共享模式。- 两者对
state的增减方向恰好相反!但底层排队、阻塞、唤醒机制是同一套 AQS。
🎯 小结:一套 AQS 骨架,通过 state 的不同语义,撑起了整个 JUC 的半壁江山
| AQS 子类 | state 的含义 |
模式 | 加锁方向 |
|---|---|---|---|
ReentrantLock |
重入计数(0=空闲,>0=占用) | 独占 | 0 → +1 |
ReentrantReadWriteLock |
高16位=读计数,低16位=写计数 | 独占+共享 | 混合 |
CountDownLatch |
剩余未完成任务数 | 共享 | N → -1 → 0 |
Semaphore |
剩余可用许可数 | 共享 | N → -1 |
读写锁
ReentrantReadWriteLock
互斥锁的粒度太粗——即使多个线程只是读取数据,也必须串行执行。读写锁解决这个问题:
- 读锁(共享锁):多个线程可以同时持有
- 写锁(独占锁):只能一个线程持有,且与读锁互斥
ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
ReadLock readLock = rwLock.readLock();
WriteLock writeLock = rwLock.writeLock();
// 读操作
readLock.lock();
try {
// 多个线程可以同时执行
return data;
} finally {
readLock.unlock();
}
// 写操作
writeLock.lock();
try {
// 独占
data = newValue;
} finally {
writeLock.unlock();
}
锁降级:持有写锁的线程可以获取读锁(先获取读锁,再释放写锁),但不支持锁升级(持有读锁时获取写锁会死锁)。
writeLock.lock();
try {
// 修改数据
data = newValue;
readLock.lock(); // 锁降级:先获取读锁
} finally {
writeLock.unlock(); // 再释放写锁
}
try {
// 以读锁保护读取修改后的数据
return data;
} finally {
readLock.unlock();
}
state 的分割:ReentrantReadWriteLock 用 AQS 的 state 的高 16 位表示读锁重入次数,低 16 位表示写锁重入次数。
state (32 bit)
┌────────────────┬────────────────┐
│ 高 16 位 │ 低 16 位 │
│ 读锁计数 │ 写锁计数 │
└────────────────┴────────────────┘
StampedLock(JDK 8+)
ReentrantReadWriteLock 有一个问题:写饥饿——大量读线程不断获取读锁时,写线程可能长时间无法获取写锁。
StampedLock 引入了乐观读模式来解决这个问题:
StampedLock lock = new StampedLock();
// 乐观读(不加锁,只获取一个"邮戳")
long stamp = lock.tryOptimisticRead();
double x = this.x, y = this.y; // 读取共享数据
if (!lock.validate(stamp)) { // 检查期间是否有写操作
// 乐观读失败,升级为悲观读锁
stamp = lock.readLock();
try {
x = this.x;
y = this.y;
} finally {
lock.unlockRead(stamp);
}
}
// 使用 x, y
乐观读的思路:先假设没有并发写,读完后验证。如果验证通过,相当于零成本读取。如果验证失败,再回退到悲观读锁。
StampedLock 的限制:
- 不可重入
- 不支持 Condition
- 不适合短临界区(乐观读的 validate 有开销)
LockSupport
LockSupport 是 AQS 底层使用的线程阻塞/唤醒工具,比 wait/notify 更灵活:
Thread t = new Thread(() -> {
System.out.println("准备 park");
LockSupport.park(); // 阻塞当前线程
System.out.println("被唤醒了");
});
t.start();
Thread.sleep(1000);
LockSupport.unpark(t); // 唤醒指定线程
vs wait/notify 的优势:
- 不需要在 synchronized 块中使用
- unpark 可以先于 park 调用——先 unpark 再 park 不会阻塞(类似许可证机制)
- 可以精确唤醒指定线程,notify 是随机唤醒
底层原理:每个线程有一个"许可(permit)",unpark() 给予许可,park() 消费许可。许可最多只有一个,不会累积。
生产环境核心踩坑点
| 问题 | 答案要点 |
|---|---|
| ReentrantLock 和 synchronized 的区别? | 可中断、可超时、公平锁、多 Condition |
| AQS 是什么?原理? | 抽象同步框架,核心是 state + CLH 变体队列 |
| 公平锁和非公平锁的区别? | 公平锁检查队列是否有等待者,非公平锁直接 CAS 抢 |
| 为什么默认非公平锁? | 吞吐量高,减少线程切换 |
| ReentrantReadWriteLock 的原理? | state 高 16 位读计数,低 16 位写计数 |
| StampedLock 的特点? | 乐观读模式,解决写饥饿,但不可重入 |
| LockSupport.park/unpark 和 wait/notify 的区别? | 不需要 synchronized,可先 unpark,可精确唤醒 |