ReentrantLock、ReentrantReadWriteLock、Semaphore、CountDownLatch……JUC 里这一大票同步器,底层大多落在同一个类:AbstractQueuedSynchronizer(AQS)。Doug Lea 用「一个 volatile 的 state + 一个 FIFO 等待队列 + CAS」把「获取/释放同步状态」抽成模板方法,各同步器只需定义 state 的语义。
从 synchronized 与锁升级 过来:需要超时、可中断、公平、多条件或读写分离时,就该换到基于 AQS 的显式锁。
TL;DR
- AQS = state + CLH 变体等待队列 + CAS:
state是一个 volatile int,表示同步状态;抢不到的线程包成 Node 进 FIFO 队列排队并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 里是剩余许可。所有对它的修改都走 CAS,保证原子。
2.2 CLH 变体等待队列
抢不到的线程被封装成 Node 加入一个双向 FIFO 队列(CLH 锁队列的变体)。每个 Node 记录线程引用、等待状态(waitStatus:SIGNAL / CANCELLED / CONDITION / PROPAGATE)与前驱后继指针:
队首的 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)) { // 只有队首才有资格尝试
setHead(node); // 拿到锁,成为新 head
p.next = null;
return interrupted;
}
if (shouldParkAfterFailedAcquire(p, node)) // 把前驱置为 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;
}
}
...
}
取舍:非公平锁允许刚到的线程”插队”,减少了线程唤醒切换,吞吐更高,是默认选择;代价是队尾线程可能长期抢不到(饥饿)。公平锁严格 FIFO,杜绝饥饿,但唤醒-切换更频繁,吞吐下降。除非明确需要防饥饿,否则用默认的非公平锁。
4.3 正确使用姿势
显式锁必须在 finally 里释放,否则异常路径会永久持锁:
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,怎么同时记读锁和写锁?答案是按位拆分——高 16 位记读锁(所有读线程总持有次数),低 16 位记写锁(写锁重入次数):
- 获取写锁:低 16 位必须为 0(无写锁重入外的写占用)且高 16 位为 0(无读锁),否则阻塞 → 写是独占。
- 获取读锁:只要没有写锁持有(或写锁是当前线程持有——支持锁降级),就能把高 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,用错易死锁。它是性能优化的利器而非默认选择。
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 返回:
这正是 异步削峰 里各种阻塞队列的底层机制之一。
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 的显式锁——到此对齐。工程上更常调的是线程池:七个参数如何交互、拒绝策略怎么选。