问题引入
2011 年,JDK7 发布后,一位开发者在多线程环境下使用 ReentrantReadWriteLock 实现了一个缓存系统。读操作远多于写操作,理论上读写锁应该能提供极好的并发性能。但实际测试发现,当读线程数超过 50 个时,吞吐量反而下降了——不是因为写锁的竞争,而是因为读锁之间的协调开销。
根本原因:ReentrantReadWriteLock 为了维护"读读共享、读写互斥"的语义,需要追踪当前有多少线程持有读锁。每个读锁的获取和释放都需要修改共享的读锁计数器,高并发下这个计数器成为了热点。
JDK8 引入的 StampedLock 用乐观读解决了这个问题:读操作不加锁,只是验证版本号,如果没有写操作干扰就直接使用数据。这种"不加锁的读"让读线程之间的协调开销降为零。
理解这个故事的关键,是理解 AQS(AbstractQueuedSynchronizer)——这个支撑了 Java 并发包半壁江山的基础框架。ReentrantLock、CountDownLatch、Semaphore、ReentrantReadWriteLock,全部基于 AQS 构建。
核心概念
AQS:state + FIFO 队列 = 一切同步器
AQS 的核心设计极简:一个 int 类型的 state 字段,加上一个 FIFO 等待队列。
java
public abstract class AbstractQueuedSynchronizer
extends AbstractOwnableSynchronizer {
private volatile int state; // 同步状态
private transient volatile Node head; // 队列头
private transient volatile Node tail; // 队列尾
}
读图导引:粉色节点是 state——同步状态的核心,所有子类通过 CAS 修改它来决定谁获得锁。蓝色节点是 FIFO 队列的头尾指针。当线程获取锁失败时,被包装成 Node 节点加入队列尾部,进入等待状态。
模板方法模式:AQS 定义了获取/释放的算法骨架,子类实现具体的语义:
java
// AQS 提供的模板方法(以独占模式为例)
public final void acquire(int arg) {
if (!tryAcquire(arg) && // 子类实现:尝试获取
acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) // 入队并自旋/阻塞
selfInterrupt();
}
// 子类必须实现的方法
protected boolean tryAcquire(int arg) { // 尝试获取独占锁
throw new UnsupportedOperationException();
}
protected boolean tryRelease(int arg) { // 尝试释放独占锁
throw new UnsupportedOperationException();
}
| 子类 | state 含义 | tryAcquire 语义 |
|---|---|---|
| ReentrantLock | 重入次数(0=未锁定) | CAS state 从 0 到 1 |
| CountDownLatch | 剩余计数 | 计数为 0 时获取成功 |
| Semaphore | 可用许可数 | 剩余许可 > 0 时获取 |
| ReentrantReadWriteLock | 高16位=读锁数,低16位=写锁重入 | 读/写分别判断 |
面试官视角:问 "AQS 是什么" 的标准答案是 "一个用 state 和 FIFO 队列实现的同步器框架"。但追问"Node 队列是 CLH 队列吗""为什么用 FIFO 而不是栈""自旋多少次后挂起",才能区分真正读过源码的人。
Node 队列:CLH 变体与自旋挂起
AQS 的等待队列是 CLH(Craig, Landin, Hagersten)锁队列的变体。原版 CLH 队列中,每个线程自旋检查前驱节点的状态;AQS 的变体中,线程自旋失败后会被挂起(park/unpark),由前驱释放锁时唤醒。
java
static final class Node {
volatile int waitStatus; // 节点状态:SIGNAL/CANCELLED/CONDITION/PROPAGATE
volatile Node prev; // 前驱
volatile Node next; // 后继
volatile Thread thread; // 绑定的线程
Node nextWaiter; // 共享/独占模式或 Condition 队列
}
读图导引:粉色节点是 head——当前持有锁的线程或哨兵节点。蓝色节点是队列中的等待线程。每个节点自旋检查前驱的 waitStatus,如果前驱是 SIGNAL,说明前驱释放锁时会唤醒自己,可以放心挂起。
acquireQueued 的核心逻辑:
- 线程获取锁失败,包装成 Node 入队
- 检查前驱是否是 head,如果是,再次尝试获取锁(自旋优化)
- 如果前驱不是 head 或获取失败,检查前驱状态:
- 前驱是
SIGNAL:安全挂起(LockSupport.park) - 前驱是
CANCELLED:跳过前驱,找有效的前驱 - 前驱是其他状态:设置为
SIGNAL
- 前驱是
- 被前驱唤醒后,回到步骤 2
为什么要用 FIFO? FIFO 保证等待最久的线程优先获得锁,避免了线程饥饿。如果用栈(LIFO),新来的线程总是插队,先来的线程可能永远等不到锁。
ReentrantLock:公平与非公平的哲学
ReentrantLock 是 AQS 最典型的应用。它与 synchronized 的核心差异:
- 可中断:
lockInterruptibly()允许在等待时响应中断 - 可超时:
tryLock(long timeout, TimeUnit unit)超时后放弃 - 公平/非公平可选:构造函数控制
- 条件变量:可创建多个
Condition实现精准唤醒
公平锁 vs 非公平锁:
java
public ReentrantLock() {
sync = new NonfairSync(); // 默认非公平
}
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
读图导引:粉色节点展示非公平锁的插队行为——新线程不检查队列,直接 CAS 尝试获取锁,成功就跳过所有等待者。蓝色节点展示公平锁的排队行为——新线程必须先检查队列是否有前驱,有则直接去队尾排队。
公平锁的实现:
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;
}
}
// ... 重入逻辑
}
为什么默认是非公平锁? 因为非公平锁的吞吐量更高。当锁被释放时,如果新来的线程刚好 CAS 成功,就省去了"唤醒队列头线程 → 头线程被调度执行"的上下文切换开销。代价是等待时间长的线程可能饥饿,但大多数情况下饥饿概率极低。
暗面:公平锁的吞吐量比非公平锁低 10-100 倍(取决于竞争强度),因为每次释放锁都强制唤醒队列头的线程,而新线程无法"偷"锁。只有在"线程饥饿会导致严重后果"的场景(如任务调度器)才需要用公平锁。
原理分析
synchronized 的锁升级路径
synchronized 是 JVM 层面的同步机制,它的实现经历了多次优化,核心思想是"能不加锁就不加锁,能轻量就不重量":
读图导引:绿色节点是偏向锁——只有一个线程访问时,Mark Word 记录线程 ID,后续该线程进入同步块无需任何同步操作。蓝色节点是轻量级锁——多线程竞争时,线程在栈帧中创建 Lock Record,用 CAS 替换对象的 Mark Word,自旋等待。粉色节点是重量级锁——自旋失败后,对象头指向 Monitor(操作系统互斥量),线程挂起和唤醒需要操作系统介入。
对象头中的 Mark Word:
| 锁状态 | 25bit | 4bit | 1bit(偏向) | 2bit(锁标志) |
|----------|----------------|--------|------------|--------------|
| 无锁 | 对象哈希码 | 分代年龄 | 0 | 01 |
| 偏向锁 | 线程ID + epoch | 分代年龄 | 1 | 01 |
| 轻量锁 | 指向 Lock Record | | | 00 |
| 重量锁 | 指向 Monitor | | | 10 |
| GC标记 | 空 | | | 11 |
锁升级的详细过程:
-
无锁 → 偏向锁:第一个线程进入同步块时,CAS 将 Mark Word 的线程 ID 设为自己。后续该线程进入同步块,只需检查 Mark Word 的线程 ID 是否等于自己——不需要 CAS,不需要原子操作。
-
偏向锁 → 轻量级锁:当另一个线程尝试获取锁时,偏向锁撤销。线程在栈帧中创建 Lock Record,用 CAS 将对象的 Mark Word 替换为指向 Lock Record 的指针。如果 CAS 成功,获得轻量级锁;如果失败,说明有竞争,进入自旋。
-
轻量级锁 → 重量级锁:自旋次数超过阈值(默认 10 次,可用
-XX:PreBlockSpin调整),或者自旋线程数超过 CPU 核数的一半,JVM 认为"自旋的代价大于挂起的代价",将锁膨胀为重量级锁。对象的 Mark Word 被替换为指向 Monitor 的指针,竞争线程被挂起。
面试官视角:问 "synchronized 是可重入锁吗" 的答案是"是"。JVM 在 Monitor 中记录了持有锁的线程和重入次数,同一线程多次进入同步块时重入计数加 1,退出时减 1,到 0 时真正释放。
StampedLock:乐观读的三态锁
ReentrantReadWriteLock 的读锁在高并发下有协调开销,因为需要维护读锁计数。StampedLock 引入乐观读——读操作不加锁,只验证版本号:
java
class Point {
private double x, y;
private final StampedLock sl = new StampedLock();
// 乐观读
double distanceFromOrigin() {
long stamp = sl.tryOptimisticRead(); // 获取版本戳(无锁)
double currentX = x, currentY = y; // 读取数据
if (!sl.validate(stamp)) { // 验证期间是否被写过
stamp = sl.readLock(); // 被写过,退化为悲观读锁
try {
currentX = x; currentY = y;
} finally {
sl.unlockRead(stamp);
}
}
return Math.sqrt(currentX * currentX + currentY * currentY);
}
void move(double deltaX, double deltaY) {
long stamp = sl.writeLock();
try {
x += deltaX; y += deltaY;
} finally {
sl.unlockWrite(stamp);
}
}
}
读图导引:绿色节点是乐观读——无锁获取版本戳,读取数据后验证版本戳是否变化。如果验证失败(蓝色节点),退化为悲观读锁。粉色节点是写锁——获取写锁时版本戳会变化,导致所有未验证的乐观读失效。
StampedLock 的限制:
- 不支持重入:获取锁后不能再次获取(会导致死锁)
- 不支持条件变量:没有
Condition支持 - 必须配对使用:
tryOptimisticRead必须配合validate,readLock必须配合unlockRead - 线程安全但非可重入:同一个线程不能同时持有读锁和写锁
暗面:
StampedLock的乐观读如果验证失败再降级为悲观读,中间的"无锁读取 + 验证失败 + 重新加锁"路径比直接用ReentrantReadWriteLock更复杂。只有在"读多写少且读操作极快"的场景,乐观读才能体现优势。
实战/源码
AQS 实现一个简单的互斥锁
java
public class SimpleMutex extends AbstractQueuedSynchronizer {
// state = 0 表示未锁定,state = 1 表示已锁定
protected boolean tryAcquire(int acquires) {
if (compareAndSetState(0, 1)) {
setExclusiveOwnerThread(Thread.currentThread());
return true;
}
return false;
}
protected boolean tryRelease(int releases) {
if (getState() == 0) throw new IllegalMonitorStateException();
setExclusiveOwnerThread(null);
setState(0);
return true;
}
public void lock() { acquire(1); }
public void unlock() { release(1); }
}
这个 20 行不到的代码,借助 AQS 的模板方法,就实现了一个功能完整的互斥锁(可排队、可重入检测、可中断、可超时)。这就是 AQS 的强大之处。
synchronized 的锁消除与锁粗化
JIT 编译器对 synchronized 有两项优化:
锁消除(Lock Elimination):如果编译器确定某段代码不可能被多线程访问,就消除同步:
java
public String concat(String s1, String s2) {
StringBuffer sb = new StringBuffer(); // sb 不会逃逸出方法
sb.append(s1).append(s2); // append 是 synchronized 方法
return sb.toString();
// JIT 发现 sb 只被当前线程使用,消除 append 内部的 synchronized
}
锁粗化(Lock Coarsening):如果相邻的同步块使用同一个锁,合并成一个:
java
// 优化前:反复获取/释放锁
for (int i = 0; i < 100; i++) {
synchronized (lock) { list.add(i); }
}
// 优化后:一次性持有锁
synchronized (lock) {
for (int i = 0; i < 100; i++) { list.add(i); }
}
ReadWriteLock 的正确使用模式
java
class Cache {
private final Map<String, Object> map = new HashMap<>();
private final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
private final Lock r = rwl.readLock();
private final Lock w = rwl.writeLock();
public Object get(String key) {
r.lock();
try {
return map.get(key);
} finally {
r.unlock();
}
}
public void put(String key, Object value) {
w.lock();
try {
map.put(key, value);
} finally {
w.unlock();
}
}
}
常见错误:在读锁内尝试获取写锁会导致死锁(
ReentrantReadWriteLock不支持锁升级)。如果读操作发现缓存未命中需要加载数据,应该释放读锁后再获取写锁(或者使用StampedLock)。
常见问题
Q1:AQS 的 Node 队列是 CLH 队列还是 MCS 队列?
CLH 队列的变体。原版 CLH 中每个线程自旋检查前驱节点的状态;AQS 的变体在自旋一定次数后挂起线程(LockSupport.park),由前驱释放时唤醒(unpark),而不是无限自旋。
Q2:synchronized 和 ReentrantLock 怎么选?
| 维度 | synchronized | ReentrantLock |
|---|---|---|
| 实现 | JVM 内置 | JDK 类库 |
| 功能 | 基本互斥 | 可中断、可超时、公平、多 Condition |
| 性能 | JDK6+ 优化后接近 | 与 synchronized 相当 |
| 灵活性 | 低(代码块级别) | 高(手动 lock/unlock) |
| 适用 | 简单同步场景 | 需要高级功能的场景 |
JDK6 之前 ReentrantLock 性能明显优于 synchronized;JDK6+ 后 synchronized 引入锁升级,两者性能差距很小。简单场景优先用 synchronized,复杂场景用 ReentrantLock。
Q3:偏向锁在 JDK15+ 被废弃了,为什么?
偏向锁的撤销(revoke)成本很高——当第二个线程竞争时,需要安全点(Safepoint)操作来修改对象头。现代应用中,多线程竞争是常态,偏向锁的收益(单线程无竞争时的零开销)远小于代价(撤销时的停顿)。JEP 374 在 JDK15 中默认禁用偏向锁,JEP 477 在 JDK21 中彻底移除。
Q4:StampedLock 的乐观读什么时候会失败?
当乐观读获取版本戳(stamp)后、验证(validate)前,如果有线程获取了写锁并修改了数据,版本戳就会变化,validate 返回 false。乐观读失败后需要降级为悲观读锁重新读取。
Q5:为什么 AQS 用 int 的 state 而不是 long?
因为 32 位 int 的 CAS 操作在大多数 CPU 上是原生的(一条指令)。64 位 long 的 CAS 在 32 位 JVM 上需要拆成两条指令,原子性保证更复杂。ReentrantReadWriteLock 用高 16 位存读锁数、低 16 位存写锁数,证明了 32 位足够表达丰富的语义。
总结
AQS 与锁的设计,体现了"统一抽象 + 灵活扩展"的架构智慧:
- AQS 的核心:
state+ FIFO 队列。通过模板方法模式,子类只需实现tryAcquire/tryRelease,就能获得完整的排队、阻塞、唤醒机制 - ReentrantLock 的公平/非公平:非公平锁允许插队,吞吐量更高;公平锁保证 FIFO,避免饥饿。默认非公平是工程上的最优选择
- synchronized 的锁升级:无锁 → 偏向锁 → 轻量级锁 → 重量级锁。JIT 还会做锁消除和锁粗化优化。JDK15+ 偏向锁已废弃
- ReentrantReadWriteLock:读读共享、读写互斥,但读锁的计数器维护在高并发下成为热点
- StampedLock:用乐观读(无锁 + 版本验证)解决读热点问题,但限制更多(不可重入、不支持 Condition)
理解锁机制的关键,是追问:这个锁在什么场景下会膨胀/降级?公平性对吞吐量有什么影响?不加锁的乐观读在什么条件下是安全的? 从"知道锁怎么用"到"懂得锁为什么这样设计",是跨越中级和高级开发者的分水岭。