Lesson 17 · 并发编程
读写锁 ReentrantReadWriteLock 与 StampedLock
面试官:读多写少,用什么锁效率最高?
面试官问:"一个配置中心,99% 的操作是读配置,只有 1% 是更新配置。用什么锁来保证线程安全?"
如果你的回答是 ReentrantLock——那所有的读操作也会被串行化。明明两个线程只是看看数据、根本没修改,却还要排队拿锁。这就像高速公路只有一个 ETC 通道,所有车都得停下来刷卡。
读写锁把锁分成两把:读锁(共享锁)和写锁(排他锁)。读与读之间不互斥,读与写互斥,写与写互斥。对于读多写少的场景,吞吐量可以提升一个数量级。
今天我们从 ReentrantReadWriteLock 的源码讲起,再看 JDK 8 引入的 StampedLock 如何用乐观读进一步突破性能天花板。
ReentrantReadWriteLock:一个 int 拆成两把锁
ReentrantLock 用 AQS 的一个 int state 表示锁状态。ReentrantReadWriteLock 更巧妙——把同一个 int 拆成高 16 位和低 16 位,高 16 位存读锁计数,低 16 位存写锁计数:
来看源码中的位运算常量和核心方法:
// 位运算常量
static final int SHARED_SHIFT = 16;
static final int SHARED_UNIT = 1 << SHARED_SHIFT; // 0x00010000 = 65536
static final int MAX_COUNT = (1 << SHARED_SHIFT) - 1; // 0x0000FFFF = 65535
static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1; // 同上,低16位掩码
// 从 state 中提取读锁计数(高 16 位无符号右移)
static int sharedCount(int c) {
return c >>> SHARED_SHIFT;
}
// 从 state 中提取写锁计数(低 16 位 & 掩码)
static int exclusiveCount(int c) {
return c & EXCLUSIVE_MASK;
}
三条核心规则:
| 组合 | 是否互斥 | 说明 |
|---|---|---|
| 读 — 读 | 不互斥 | 多个读者可同时持有读锁,sharedCount +1 |
| 读 — 写 | 互斥 | 有读者时写者阻塞,有写者时读者阻塞 |
| 写 — 写 | 互斥 | 同一时刻只有一个写者 |
protected final boolean tryAcquire(int acquires) {
Thread current = Thread.currentThread();
int c = getState();
int w = exclusiveCount(c); // 提取低 16 位:写锁计数
if (c != 0) {
// c != 0 说明有人持锁(读或写)
// w == 0 说明写锁计数为 0 → 那就是有读者 → 写者必须等
// current != getExclusiveOwnerThread() → 不是自己重入 → 也等
if (w == 0 || current != getExclusiveOwnerThread())
return false;
if (w + exclusiveCount(acquires) > MAX_COUNT)
throw new Error("Maximum lock count exceeded");
setState(c + acquires); // 写锁重入
return true;
}
// c == 0 → 无人持锁,尝试 CAS 抢占写锁
if (writerShouldBlock() || !compareAndSetState(c, c + acquires))
return false;
setExclusiveOwnerThread(current);
return true;
}
因为 AQS 的 compareAndSetState() 只能原子更新一个 int。如果把读计数和写计数存在两个变量里,就需要两次 CAS 才能保证一致性,这在并发环境下会产生竞态条件。用一个 int 的两个半区,一次 CAS 就能同时更新读写状态。
锁降级:写锁 → 读锁 → 释放写锁
面试中有一个高频追问:"持有写锁的线程,能不能不释放写锁就直接获取读锁?"
答案是:可以。这叫锁降级(Lock Downgrade)——从权限更高的写锁降级到权限更低的读锁。Javadoc 中明确说明这是被允许的。
ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
// ① 获取写锁
rwl.writeLock().lock();
try {
// 修改共享数据
data.update(newValue);
} finally {
// ③ 释放写锁(在此之前必须先获取读锁!)
rwl.writeLock().unlock();
}
// ② 在释放写锁之前获取读锁
rwl.readLock().lock();
try {
// 以只读方式使用刚写入的数据
return data.get();
} finally {
rwl.readLock().unlock(); // ④ 最终释放读锁
}
如果在释放写锁之后、获取读锁之前,有另一个写线程抢到了写锁并修改了数据,那当前线程读到的就是被别人改过的数据,不是自己刚写入的值。先拿读锁,等于在释放写权限的同时仍然"占住"读权限,保证数据的连续性。
但反过来——锁升级(读锁 → 写锁)是不允许的:
// 在 tryAcquire 中有这个判断:
if (c != 0) {
if (w == 0 || current != getExclusiveOwnerThread())
return false; // ← 如果有读者(w==0 但 c!=0),直接失败
}
// 即使当前线程持有读锁,w == 0 依然成立 → 获取写锁失败
假设允许锁升级,考虑两个线程 T1、T2 都持有读锁,然后同时尝试升级为写锁——两个线程都在等对方释放读锁,死锁。即使只有一个读者,升级也需要先释放读锁,而释放和获取之间有一个"窗口期",其他写线程可能趁虚而入修改数据。所以 JDK 的设计是:想从读变成写?先释放读锁,再竞争写锁。
写锁饥饿:读者源源不断,写者永远排队
读写锁引入了一个新问题——写者饥饿(Writer Starvation)。
设想一个场景:缓存系统每秒有 1000 个读请求,每 10 秒才来一个写请求。在非公平模式下,只要有读锁被持有,新的读请求就能不断获取读锁。写锁?永远排不上号——因为读锁从来不为空。
final boolean tryReadLock() {
Thread current = Thread.currentThread();
for (;;) {
int c = getState();
// 关键判断:写锁被别的线程持有时,读者才需要排队
if (exclusiveCount(c) != 0 &&
getExclusiveOwnerThread() != current)
return false; // 有写者 → 读者阻塞
int r = sharedCount(c);
if (r == MAX_COUNT)
throw new Error("Maximum lock count exceeded");
if (compareAndSetState(c, c + SHARED_UNIT)) {
// CAS 成功,读锁 +1
...
return true;
}
}
}
注意看:非公平模式下,读者不会检查是否有写者在排队。只要没有活跃的写锁,读者就直接拿锁。这就导致写者可能被无限延迟。
公平模式如何解决?
// 公平模式的写锁获取 —— 前面有人排队就阻塞
final boolean writerShouldBlock() {
return hasQueuedPredecessors(); // AQS 队列中有人在我前面
}
// 公平模式的读锁获取 —— 前面有写者在排队就阻塞
final boolean readerShouldBlock() {
return hasQueuedPredecessors(); // 队列中有写者排在我前面 → 读者也让路
}
| 模式 | 读者行为 | 写者饥饿 | 吞吐量 |
|---|---|---|---|
| 非公平(默认) | 直接尝试获取,不看队列 | 可能饥饿 | 高(减少线程切换) |
| 公平 | 检查队列前面是否有写者 | 不会饥饿 | 低(频繁线程切换) |
如果你的业务场景中写操作不能丢、不能无限延迟,可以选择公平模式:new ReentrantReadWriteLock(true)。但公平模式的代价是吞吐量下降 30%~50%,因为频繁地在读者和写者之间切换。
StampedLock:乐观读——先读后验,零开销读
ReentrantReadWriteLock 的读锁虽然允许多个读者并发,但每个读者仍然需要CAS 修改 state 的高 16 位——这意味着读操作本身也有锁竞争的开销。
JDK 8 引入的 StampedLock 提供了一种更激进的模式:乐观读(Optimistic Read)——读的时候根本不加锁,只是记住一个"版本号"(stamp),读完之后验证版本号是否变化。如果没变,说明读到的数据是一致的;如果变了,再升级为悲观读锁。
来看标准用法:
public class Point {
private double x, y;
private final StampedLock sl = new StampedLock();
// 写操作
public void move(double deltaX, double deltaY) {
long stamp = sl.writeLock(); // 获取写锁
try {
x += deltaX;
y += deltaY;
} finally {
sl.unlockWrite(stamp); // 释放写锁
}
}
// 乐观读 —— 零锁开销
public 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);
}
}
关键要点:tryOptimisticRead() 返回的 stamp 不是锁,只是一个"快照版本号"。如果在此期间有写操作修改了 state,版本号就会变化,validate() 返回 false,此时需要升级为悲观读锁重新读取。
| 模式 | API | 互斥 | 开销 |
|---|---|---|---|
| 写锁(Write) | writeLock() | 与读、写都互斥 | CAS + 排他 |
| 悲观读锁(Read) | readLock() | 与写互斥,读之间不互斥 | CAS + 共享 |
| 乐观读(Optimistic) | tryOptimisticRead() | 不加锁,仅事后验证 | 零开销(成功时) |
StampedLock vs ReentrantReadWriteLock
既然 StampedLock 的乐观读性能这么好,是不是可以全面替换 ReentrantReadWriteLock?先看一张完整对比表:
| 特性 | ReentrantReadWriteLock | StampedLock |
|---|---|---|
| JDK 版本 | JDK 5 | JDK 8 |
| 乐观读 | 不支持 | 支持(tryOptimisticRead) |
| 可重入 | 是 | 否(最大限制) |
| 公平模式 | 支持 | 不支持(只有非公平) |
| Condition | 支持 | 不支持 |
| 读锁 → 写锁升级 | 不支持 | 支持(tryConvertToWrite) |
| 读操作开销 | CAS 修改 state | 乐观读时零开销 |
| API 复杂度 | 简单 | 较复杂(需要管理 stamp) |
性能差距:在读多写少(读:写 > 10:1)的场景下,StampedLock 的乐观读比 ReentrantReadWriteLock 快 5~10 倍,因为完全避免了 CAS 竞争。但如果写操作频繁,乐观读频繁失败,反而会因为反复升级而变慢。
StampedLock 最大的坑——不可重入:
StampedLock sl = new StampedLock();
long stamp = sl.writeLock();
try {
doSomething(); // 这个方法内部又调用了 sl.writeLock()
// → 死锁!StampedLock 不支持重入!
} finally {
sl.unlockWrite(stamp);
}
void doSomething() {
long s = sl.writeLock(); // 同一个线程再次获取写锁 → 阻塞
try { ... } finally { sl.unlockWrite(s); }
}
记住一个简单的判断框架:
1. 写操作为主(写:读 > 1:1)→
ReentrantLock(读写锁没有意义)2. 读多写少,且需要可重入 / Condition / 公平模式 →
ReentrantReadWriteLock3. 读多写少,追求极致读性能,不需要可重入 →
StampedLock
总结:场景 → 锁 决策表
面试官问"读多写少用什么锁",你的回答应该是:先看场景特征,再选锁。
| 场景 | 推荐锁 | 原因 |
|---|---|---|
| 本地缓存(配置中心、元数据) | StampedLock | 读远多于写,乐观读零开销,性能最优 |
| 简单的读写共享资源 | ReentrantReadWriteLock | API 简单,支持可重入和 Condition |
| 写操作为主 | ReentrantLock | 读写锁的读锁反而增加开销 |
| 需要公平调度 | ReentrantReadWriteLock(fair) | StampedLock 不支持公平模式 |
| 读 + 偶尔批量更新 | StampedLock | 乐观读 + tryConvertToWrite 原子升级 |
核心知识点回顾
- ReentrantReadWriteLock:AQS state 高 16 位存读计数,低 16 位存写计数;一次 CAS 管理两把锁
- 锁降级:写锁 → 读锁 → 释放写锁——被允许;锁升级(读 → 写)——不允许,因为有死锁风险
- 写者饥饿:非公平模式下读者持续进入会饿死写者,公平模式通过队列检查解决
- StampedLock 乐观读:tryOptimisticRead() 不加锁、零开销,validate() 事后验证
- StampedLock 限制:不可重入、不支持 Condition、不支持公平模式
- 选型口诀:缓存用 StampedLock,通用读写用 ReadWriteLock,写多不用读写锁
1. 读写锁思想:读锁共享、写锁排他,读多写少场景大幅提升并发度
2. ReentrantReadWriteLock:AQS state 位拆分实现,支持锁降级但不支持锁升级
3. StampedLock:JDK 8 引入,乐观读零锁开销,读多写少性能最优,但不可重入
4. 选型建议:缓存场景优先 StampedLock,需要可重入或 Condition 时用 ReentrantReadWriteLock