ReentrantLock、ReentrantReadWriteLock、Semaphore、CountDownLatch……JUC 里这一大票同步器,底层大多落在同一个核心类:AbstractQueuedSynchronizer(AQS)。Doug Lea 巧妙地用「一个 volatile 的 state + 一个 FIFO 等待队列 + CAS」把「获取/释放同步状态」抽离成了模板方法,各同步器只需定义 state 的具体语义即可。
从 synchronized 与锁升级 过来,我们会发现,当业务场景需要超时控制、可中断获取、公平锁排队、多条件等待或是读写分离时,内置锁就显得捉襟见肘了,此时就该换到基于 AQS 的显式锁登场。
本文是 并发编程 / JUC 系列的第 3 篇(AQS 与显式锁)。 全系列 6 篇:
一句话定位:深入剖析 AQS 的同步状态流转与队列排队机制,彻底搞懂 ReentrantLock、读写锁与 Condition 的底层原理及高并发场景下的选型取舍。
TL;DR
- AQS = state + CLH 变体等待队列 + CAS:
state是一个 volatile int,表示同步状态;抢不到锁的线程会被封装成 Node 节点进入 FIFO 队列排队,并通过LockSupport.park挂起。 - 两种模式:独占(exclusive,一次仅一个线程持有,如 ReentrantLock)与共享(shared,可多个线程同时持有,如读锁、Semaphore、CountDownLatch)。
- 模板方法模式:AQS 实现了排队、阻塞与唤醒的通用骨架,子类只需重写
tryAcquire/tryRelease(独占)或tryAcquireShared/tryReleaseShared(共享)来定义 state 的具体语义。 - ReentrantLock 用 state 记录重入次数:公平锁会多一步”是否有前驱节点在排队”的检查;非公平锁允许直接插队抢占,吞吐量更高但可能导致部分线程饥饿等待。
- ReentrantReadWriteLock 把 state 按位拆分:高 16 位记录读锁计数,低 16 位记录写锁计数,从而实现”读读共享、读写与写写互斥”。
- Condition 是挂载在 AQS 上的条件队列:调用
await会释放锁并进入条件队列,调用signal则将节点转移回同步队列重新竞争锁——这比 synchronized 的单一 wait set 提供了更精确的线程间通信能力。
Table of contents
Open Table of contents
1. 为什么需要 AQS
如果让你自己手搓一把锁,你必须处理这些棘手的问题:怎么表示”锁已被占用”、抢不到锁的线程往哪里放、怎么安全地挂起与唤醒线程、怎么保证这些操作本身的原子性与可见性、要不要支持公平排队……这些排队与阻塞的机制是所有同步器共通的底层需求,只有”什么状态算获取成功”这一规则因器而异。
AQS 就是把这些共通部分抽离出来的底层框架:它负责管理同步队列、调用 park/unpark 挂起与唤醒线程、处理 CAS 竞争等脏活累活,而把”state 怎么算获取成功”的逻辑留给子类去定义。于是,ReentrantLock 只需规定”state=0 代表可获取,获取成功则 +1”,Semaphore 只需规定”state 代表剩余的可用许可数”,CountDownLatch 则只需规定”state 减到 0 才允许放行”。
2. AQS 的三根支柱
2.1 state:volatile 的同步状态
private volatile int state; // 同步状态,语义由子类定义
protected final int getState() { return state; }
protected final void setState(int v) { state = v; }
protected final boolean compareAndSetState(int expect, int update) {
return U.compareAndSetInt(this, STATE, expect, update); // CAS,底层依赖 Unsafe
}
state 的具体含义完全由子类赋予:在 ReentrantLock 里它是重入次数,在读写锁里它被高低位拆分成了读写两段计数,在 Semaphore 里它则是剩余许可数量。所有对 state 的修改都必须通过 CAS 操作,以此保证高并发下的原子性。
2.2 CLH 变体等待队列
当线程抢不到同步状态时,会被封装成 Node 节点加入到一个双向 FIFO 队列(即 CLH 锁队列的变体)中。每个 Node 节点不仅记录了线程引用,还维护了等待状态(waitStatus,如 SIGNAL、CANCELLED、CONDITION、PROPAGATE)以及前驱和后继指针:
AQS 同步队列结构:队首 dummy 节点代表当前持锁者,后续节点依次排队,前驱节点释放时负责唤醒后继节点。
需要注意的是,队首的 dummy 节点是一个占位符,代表当前已经持锁(或者曾经持锁)的线程。当后续节点被唤醒并尝试获取同步状态成功后,它就会把自己设置为新的 head。在这个队列中,每个节点只监视它的前驱节点——前驱节点释放锁时负责唤醒它,这正是 CLH 变体能够保持低竞争开销的关键所在。
2.3 CAS + park/unpark
为了看清底层是怎么流转的,我们来看一段获取锁的自旋与阻塞逻辑(独占模式的简化版):
final boolean acquireQueued(Node node, int arg) {
boolean interrupted = false;
for (;;) {
Node p = node.predecessor();
if (p == head && tryAcquire(arg)) { // 只有紧跟在 head 后面的队首节点才有资格尝试获取
setHead(node); // 拿到锁,成为新 head
p.next = null;
return interrupted;
}
if (shouldParkAfterFailedAcquire(p, node)) // 把前驱节点的 waitStatus 置为 SIGNAL
interrupted |= parkAndCheckInterrupt(); // 调用 LockSupport.park() 将当前线程挂起
}
}
在这里,LockSupport.park() 和 unpark(thread) 提供了线程挂起与唤醒的底层原语。它们基于许可(permit)机制,比传统的 wait/notify 更加灵活——unpark 甚至可以先于 park 调用,许可会被保留,从而避免了丢失唤醒信号的风险。
3. 模板方法:独占与共享
AQS 巧妙地运用了模板方法模式,将”通用骨架”与”特定语义”彻底分离。子类只需按需重写以下几个方法(未重写的方法默认抛出 UnsupportedOperationException):
| 模式 | 获取同步状态 | 释放同步状态 |
|---|---|---|
| 独占 exclusive | tryAcquire(int) | tryRelease(int) |
| 共享 shared | tryAcquireShared(int) | tryReleaseShared(int) |
| 通用 | isHeldExclusively() | —— |
以对外的获取入口 acquire() 为例,其内部会先调用子类实现的 tryAcquire,只有当获取失败时,才会走入入队排队与 park 挂起的通用流程:
public final void acquire(int arg) {
if (!tryAcquire(arg) && // 子类定义"能否获取成功"的语义
acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) // 获取失败则进入同步队列排队阻塞
selfInterrupt();
}
独占模式意味着一次只能有一个线程持有同步状态(如 ReentrantLock 和写锁);而共享模式则允许多个线程同时持有(如读锁、Semaphore 和 CountDownLatch)。特别地,共享模式在获取成功后,还会触发向后传播(propagate) 机制,依次唤醒后续的共享节点,从而实现”一个释放,唤醒一串读线程”的连锁反应。
4. ReentrantLock:可重入独占锁
ReentrantLock 内部持有一个继承自 AQS 的 Sync 组件,这里的 state 被用来表示重入次数(0 代表锁空闲,n 代表同一个线程重入了 n 次)。
4.1 非公平获取(默认策略)
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) { // 锁处于空闲状态
if (compareAndSetState(0, acquires)) { // 直接通过 CAS 抢占,完全不看队列 → 允许插队
setExclusiveOwnerThread(current);
return true;
}
} else if (current == getExclusiveOwnerThread()) { // 当前线程已经持有该锁(重入)
setState(c + acquires); // 重入计数 +1,无需 CAS(因为已经持锁,是线程安全的)
return true;
}
return false;
}
4.2 公平获取
对比前一种方案,公平锁在尝试获取时仅仅多了一步判断——必须先检查同步队列里是否有前驱节点正在排队等待:
protected final boolean tryAcquire(int acquires) {
// ... 省略部分代码
if (c == 0) {
if (!hasQueuedPredecessors() && // 关键差异:只有当队列中没有前驱节点时,才允许尝试抢占
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
// ... 省略部分代码
}
取舍与权衡:非公平锁允许刚到达的线程直接”插队”抢占,这极大地减少了线程被挂起和唤醒带来的上下文切换开销,因此吞吐量显著更高,也是 ReentrantLock 的默认选择;但其代价是,处于队列尾部的线程可能会长期抢不到锁,陷入饥饿等待。公平锁虽然严格遵循 FIFO 原则,彻底杜绝了饥饿问题,但频繁的唤醒与上下文切换会导致系统吞吐量大幅下降。除非业务场景明确要求必须防止饥饿,否则一律推荐使用默认的非公平锁。
4.3 正确使用姿势
使用显式锁时,必须在 finally 块中释放锁,否则一旦 try 块中抛出异常,锁将永远无法释放,导致死锁:
private final ReentrantLock lock = new ReentrantLock();
public void doWork() {
lock.lock();
try {
// 临界区业务逻辑
} finally {
lock.unlock(); // 必须放在 finally 块中确保释放
}
}
// 带超时的尝试获取:拿不到就走降级逻辑,避免死等
public boolean doWorkWithTimeout() throws InterruptedException {
if (!lock.tryLock(200, TimeUnit.MILLISECONDS)) {
return false; // 超时未获取到锁,快速失败(这对控制高并发场景下的尾延迟至关重要)
}
try {
// 临界区业务逻辑
return true;
} finally {
lock.unlock();
}
}
tryLock(timeout) 与 lockInterruptibly() 正是 synchronized 无法提供的核心能力——它们让”获取锁”这个动作本身变得可控,是高可用架构中避免线程池被慢锁拖垮的重要手段(这与我们在 依赖韧性 中探讨的超时阻断思路一脉相承)。
5. ReentrantReadWriteLock:一个 state 装两把锁
在读多写少的业务场景下,读操作之间本可以完全并发执行,如果一味使用独占锁就造成了极大的性能浪费。读写锁的规则非常明确:读读共享、读写互斥、写写互斥。
这里的难点在于:AQS 只有一个 int state,如何同时记录读锁和写锁的状态?答案是按位拆分——将 32 位的 int 高 16 位用于记录读锁(所有读线程的总持有次数),低 16 位用于记录写锁(写锁的重入次数):
读写锁的 state 拆分机制:高 16 位记录读状态,低 16 位记录写状态,通过位运算实现两把锁的独立计数。
- 获取写锁:要求低 16 位必须为 0(除了当前线程的写锁重入外,无其他写占用),且高 16 位也必须为 0(没有任何读锁占用),否则当前线程必须阻塞等待。这保证了写锁的绝对独占性。
- 获取读锁:只要当前没有写锁被其他线程持有(如果写锁是当前线程自己持有的,则允许获取,这支持了锁降级),就可以通过 CAS 将高 16 位加 1,从而允许多个读线程共享进入临界区。
需要强调的是,锁降级(从写锁降级为读锁)是被原生支持的:线程在持有写锁期间可以再次获取读锁,随后释放写锁,从而在不丢失内存可见性的前提下平滑降级为读锁。但反过来,锁升级(从读锁直接升级为写锁)是绝对不支持的,这会直接导致死锁。
风险提示:读写锁只有在读操作远多于写操作时才划算;如果写操作并不算太少,读写锁内部状态维护的复杂度和读线程排队的开销,反而可能不如直接使用 ReentrantLock 来得快。在 JDK 8 之后,如果是”读极其频繁、写极少,且业务能接受乐观读重试”的场景,强烈建议考虑使用 StampedLock(见下一节)。
5.1 StampedLock(附带一提)
StampedLock 提供了一种极具侵略性的乐观读机制:读数据之前先获取一个 stamp(时间戳),读完后再校验这个 stamp 是否仍然有效(即在此期间没有发生过写入)。如果有效,就意味着全程无需加任何锁,性能极高:
StampedLock sl = new StampedLock();
long stamp = sl.tryOptimisticRead(); // 乐观读,不加任何锁,直接返回标记
int x = shared; // 读取共享数据到局部变量
if (!sl.validate(stamp)) { // 校验期间是否被写入过?如果失效,则升级为悲观读锁
stamp = sl.readLock();
try { x = shared; } finally { sl.unlockRead(stamp); }
}
当然,极致性能的代价是极其严苛的使用限制:StampedLock 不可重入、不支持 Condition,一旦用错极易引发死锁或 CPU 飙高。它是性能压榨到极致时的利器,绝非日常业务开发的默认选择。
6. Condition:更灵活的等待/通知
上一篇提到的 synchronized 只有一个隐式的 wait set,所有调用 wait() 的线程都混杂在一起,导致 notify 无法做到定向唤醒。而 Condition 则允许一把显式锁挂载多个条件队列,从而实现精确的线程间通信。以经典的阻塞有界缓冲区为例:
private final ReentrantLock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition(); // "非满"条件队列
private final Condition notEmpty = lock.newCondition(); // "非空"条件队列
private final Object[] items = new Object[100];
private int count, putIdx, takeIdx;
public void put(Object x) throws InterruptedException {
lock.lock();
try {
while (count == items.length) notFull.await(); // 队列已满 → 生产者在 notFull 上挂起等待
items[putIdx] = x;
if (++putIdx == items.length) putIdx = 0;
count++;
notEmpty.signal(); // 放入元素后,精确唤醒等待"非空"的消费者
} finally { lock.unlock(); }
}
public Object take() throws InterruptedException {
lock.lock();
try {
while (count == 0) notEmpty.await(); // 队列为空 → 消费者在 notEmpty 上挂起等待
Object x = items[takeIdx];
if (++takeIdx == items.length) takeIdx = 0;
count--;
notFull.signal(); // 取出元素后,精确唤醒等待"非满"的生产者
return x;
} finally { lock.unlock(); }
}
使用约定:① 调用 await/signal 之前必须已经持有对应的锁;② 等待条件的判断必须放在 while 循环里(这是为了防止虚假唤醒,以及应对被唤醒后条件又被其他线程改变的竞态情况)。
底层原理:调用 await() 会将当前线程节点从 AQS 的同步队列中移除,转移到该 Condition 的条件队列中,并彻底释放锁;而调用 signal() 则是将条件队列的头节点重新转移回同步队列,等待它重新竞争到锁之后,才能从 await 方法中返回:
Condition 节点转移机制:await 使节点进入条件队列,signal 将节点移回同步队列重新参与锁竞争。
这套机制,正是我们在 异步削峰 架构中广泛使用的各类阻塞队列(如 ArrayBlockingQueue)的核心底层依赖。
7. AQS 上的常见同步器
为了更直观地理解模板方法,我们用一张表总结 state 在各核心同步器里的具体语义:
| 同步器 | 模式 | state 的含义 |
|---|---|---|
| ReentrantLock | 独占 | 重入次数(0 代表空闲) |
| ReentrantReadWriteLock | 独占+共享 | 高 16 位为读锁计数 / 低 16 位为写锁计数 |
| Semaphore | 共享 | 剩余的可用许可数 |
| CountDownLatch | 共享 | 剩余的倒数计数,减到 0 则放行 |
| ThreadPoolExecutor.Worker | 独占 | 是否处于运行中状态(用于区分线程是否可被中断) |
后三个同步器的实战场景将在 并发协作与异步编排 里详细展开,而线程池 Worker 的状态流转则见 线程池原理与调优。
8. 常见陷阱
- 忘记在 finally 里 unlock:异常路径导致永久持锁,是显式锁引发线上故障最常见的原因。务必使用固定模板,或将其封装成
runWithLock(() -> ...)的高阶函数。 - await 不放在 while 循环里:如果用
if判断条件,极易遭遇虚假唤醒或竞态击穿。永远牢记:while (条件不满足) cond.await();。 - 把公平锁当默认选项用:公平锁的吞吐量明显低于非公平锁,且绝大多数业务场景并不需要严格的 FIFO 排队。千万不要无脑设置
new ReentrantLock(true)。 - 在写操作不算少的场景滥用读写锁:读线程会因为写锁的频繁抢占而大量排队,得不偿失,此时不如直接使用互斥锁。
- 试图进行锁升级(读→写):
ReentrantReadWriteLock坚决不支持锁升级,持有读锁时再去请求写锁必然导致死锁;如果需要写入,必须先彻底释放读锁。 - 把 StampedLock 当作可重入锁用:它根本不可重入,同一个线程重复获取会直接引发死锁。
锁的两条核心路线——JVM 内建的 synchronized 与基于 AQS 的显式锁——到此已全部对齐。在实际工程中,我们更常打交道的是线程池:它的七个核心参数究竟该如何交互、拒绝策略又该怎么选?下一篇我们将深入探讨。