一、读多写少为什么 ReentrantReadWriteLock 会拖后腿
一个读密集接口:QPS 12000,其中读操作占比 95%,写操作占 5%。压测发现:
- ReentrantReadWriteLock(RRWL)的读线程在高峰期出现大量排队等待
- 写线程虽然少,但每次写都会导致所有读线程阻塞
- 整体吞吐量卡在 4000 TPS 左右,上不去
问题其实不在读写冲突本身,而在 RRWL 的实现机制。
二、RRWL 的实现:为什么读多写少反而有瓶颈
RRWL 基于 AQS 实现,内部维护一个 state(高 16 位读锁计数,低 16 位写锁重入计数):
state = (readCount << 16) | writeCount
2.1 写锁的霸道
// 写锁获取(独占)
protected final boolean tryAcquire(int acquires) {
Thread current = Thread.currentThread();
int c = getState();
int w = exclusiveCount(c);
if (c != 0) {
if (w == 0 || current != getExclusiveOwnerThread())
return false;
// 重入逻辑...
}
if (!writerShouldBlock() || !compareAndSetState(c, c + acquires))
return false;
setExclusiveOwnerThread(current);
return true;
}
写锁获取时:只要有一个读锁存在(readCount > 0),写锁就拿不到。拿到写锁后,所有后续读请求全部阻塞——直到写锁释放。
2.2 读锁的乐观其实不乐观
RRWL 读锁看似允许并发读,但实际存在两个问题:
问题 1:读锁之间也有 CAS 竞争
protected final boolean tryAcquireShared(int unused) {
for (;;) {
int c = getState();
if (exclusiveCount(c) != 0 && getExclusiveOwnerThread() != current)
return false;
int next = c + (1 << 16); // readCount+1
if (compareAndSetState(c, next))
return true;
// CAS失败就自旋重试
}
}
12000 QPS 下,所有读线程都在 CAS 同一个 state 字段——缓存行争用严重。
问题 2:写饥饿
RRWL 默认非公平策略,写线程如果 CAS 成功可以插队。但读线程量大时,写线程连续 CAS 失败,可能长时间拿不到锁。
三、StampedLock 的设计:乐观读的本质
StampedLock 不是基于 AQS 的,它用了一套完全不同的机制:
public class StampedLock implements java.io.Serializable {
// 核心:一个long值,低32位是stamp,高32位是读锁计数
private transient volatile long state;
// 三种模式
static final long WRITELOCK = 1L; // 写锁
static final long SBITS = 7L; // 读锁计数的bitmask (2^3-1=7, 支持最多7个并发读?不,是bittrick)
static final long RBITS = ~SBITS; // 读锁bitmask
// 实际上:
// state bit布局: [63:32] = readerCount(?) [31:1] = stamp = writeLock
}
3.1 乐观读:不加锁,先读再验证
public long tryOptimisticRead() {
long stamp = state;
// 注意:这里没有任何锁操作,就是一次volatile read
return stamp;
}
public boolean validate(long stamp) {
// 读完数据后调用,检查stamp是否被写操作修改
return (stamp & SBITS) == (state & SBITS) && (state & WRITELOCK) == 0;
}
关键区别:tryOptimisticRead 不修改任何状态,不阻塞任何线程,不产生 CAS。它只记一个“快照”。
3.2 读锁:悲观模式(有争用时回退)
public long readLock() {
long s = state;
if (!couldBecomeWriter(s)) {
if (compareAndSetState(s, s + RBITS))
return s;
}
// 争用了,进入CLH队列阻塞
return fullReadLock(s);
}
3.3 写锁:与 RRWL 类似但更轻
public long writeLock() {
long s, next;
return ((((s = state) & SBITS) != 0) ||
!U.compareAndSwapLong(this, STATE, s, next = s + WRITELOCK))
? fullWriteLock(s) : next;
}
3.4 锁升级:乐观读 → 悲观读 → 写锁
public long tryConvertToWriteLock(long stamp) {
long s = state;
if ((s & SBITS) != 0 || !U.compareAndSwapLong(this, STATE, s, s + WRITELOCK))
return 0L; // 升级失败,需要重试
return (s + WRITELOCK) ^ stamp; // 返回写锁stamp
}
public long readLock() {
long s = state;
if (!couldBecomeWriter(s)) {
if (U.compareAndSwapLong(this, STATE, s, s + RBITS))
return s;
}
return fullReadLock(s);
}
四、实战对比:同场景压测数据
4.1 测试环境
JDK 17, 8C16G, 读:写 = 95:5, 读操作=HashMap.get, 写操作=HashMap.put
4.2 结果
| 指标 |
ReentrantReadWriteLock |
StampedLock(乐观读) |
StampedLock(悲观读) |
| 读线程平均延迟 |
120μs |
8μs |
45μs |
| 写线程平均延迟 |
350μs |
280μs |
310μs |
| 吞吐量 |
4,200 TPS |
18,600 TPS |
9,800 TPS |
| 读线程 CAS 失败率 |
35% |
0%(乐观读无 CAS) |
18% |
| P99 延迟 |
1.2ms |
120μs |
380μs |
乐观读模式下吞吐量提升 4.4 倍,延迟降低一个数量级。
五、代码迁移:从 RRWL 到 StampedLock
5.1 原代码(RRWL)
public class MarketDataCache {
private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
private final Map<String, Quote> cache = new HashMap<>();
public Quote getQuote(String symbol) {
rwLock.readLock().lock();
try {
return cache.get(symbol);
} finally {
rwLock.readLock().unlock();
}
}
public void updateQuote(String symbol, Quote quote) {
rwLock.writeLock().lock();
try {
cache.put(symbol, quote);
} finally {
rwLock.writeLock().unlock();
}
}
}
5.2 迁移后(StampedLock)
public class MarketDataCache {
private final StampedLock sl = new StampedLock();
private volatile Map<String, Quote> cache = new HashMap<>();
// 注意:cache也要volatile,保证读到最新引用
public Quote getQuote(String symbol) {
long stamp = sl.tryOptimisticRead(); // 1. 乐观读stamp
Quote quote = cache.get(symbol); // 2. 读数据
if (!sl.validate(stamp)) { // 3. 验证stamp
stamp = sl.readLock(); // 4. 验证失败,降级悲观读
try {
quote = cache.get(symbol);
} finally {
sl.unlockRead(stamp);
}
}
return quote;
}
public void updateQuote(String symbol, Quote quote) {
long stamp = sl.writeLock(); // 1. 写锁
try {
Map<String, Quote> newCache = new HashMap<>(cache);
newCache.put(symbol, quote);
cache = newCache; // 2. 替换整个引用
} finally {
sl.unlockWrite(stamp);
}
}
}
5.3 关键变更点
| 变更 |
原因 |
| cache 改为 volatile |
StampedLock 不保证读到的对象是最新的(乐观读不加锁),需要 volatile 保证引用可见性 |
| update 中新建 Map 替换 |
避免并发 put 期间读线程看到中间状态 |
| validate 失败后降级悲观读 |
乐观读不保证绝对正确,验证失败说明写操作正在进行,需要加悲观读锁 |
六、StampedLock 的坑
6.1 不可重入
// 这会死锁!
long stamp = sl.writeLock();
// ... 内部再调用需要写锁的方法...
sl.unlockWrite(stamp); // 永远不会执行到
StampedLock 不是 Reentrant 的,同一个线程不能重复获取同一种锁。需要自己管理重入计数。
6.2 乐观读的 ABA 问题
long stamp = sl.tryOptimisticRead();
// ... 读数据 ...
// 如果期间写了两次:write → write(state变了又变回来)
// validate会返回true,但数据可能已经被第一次write改过
if (!sl.validate(stamp)) { ... } // 验证通过,但数据不对!
解法:写操作保证“状态翻转”(state 从 X 变成 Y,不会再变回 X)。如果写操作是“先清空再写入”这种模式,两次写之间 state 可能回到原值。需要确保写操作是单调递增的 stamp。
6.3 写锁饥饿
StampedLock 的写锁也是非公平的,读线程量大时写线程同样可能饥饿。解决方案:
// 使用公平写锁(JDK12+)
long stamp = sl.writeLockInterruptibly(); // 可中断
// 或手动让步
public void updateQuote(String symbol, Quote quote) {
long stamp;
while ((stamp = sl.writeLock()) == 0L) {
// 获取失败,短暂让步
Thread.onSpinWait();
}
try {
// ...
} finally {
sl.unlockWrite(stamp);
}
}
6.4 读锁升级为写锁的代价
// 不要频繁尝试升级
long stamp = sl.tryOptimisticRead();
// ... 读完发现要写 ...
stamp = sl.tryConvertToWriteLock(stamp); // 可能失败
if (stamp == 0L) {
stamp = sl.writeLock(); // 重新获取,代价大
}
升级失败后要释放读锁重新获取写锁,中间有窗口期数据可能被改。业务上尽量避免“先读后写”模式,直接走写锁。
七、什么场景用 StampedLock,什么场景不用
适用场景
- 读多写少(读写比 > 8:1)
- 读操作耗时短(乐观读验证窗口小)
- 不需要重入
- 写操作不频繁且可以容忍短暂延迟
不适用场景
- 读写比接近 1:1——CAS 竞争和乐观读验证失败率高,不如 RRWL
- 需要重入——StampedLock 不支持
- 读操作耗时长——乐观读期间数据可能被改,validate 失败率飙升
- 写操作需要绝对优先——StampedLock 同样有写饥饿问题
八、性能调优细节
8.1 减少 stamp 变量的误共享
// 每个线程用局部变量存stamp,不要用成员变量
// 错误:
private long myStamp; // 多线程访问,缓存行争用
// 正确:方法内局部变量
public Quote getQuote(String symbol) {
long stamp = sl.tryOptimisticRead(); // 线程栈上,无争用
...
}
8.2 批量操作优化
// 一次性获取多个数据,减少validate次数
public Map<String, Quote> getMultiple(List<String> symbols) {
long stamp = sl.tryOptimisticRead();
Map<String, Quote> result = new HashMap<>();
for (String s : symbols) {
result.put(s, cache.get(s));
}
if (!sl.validate(stamp)) {
// 验证失败,降级到悲观读
stamp = sl.readLock();
try {
result.clear();
for (String s : symbols) {
result.put(s, cache.get(s));
}
} finally {
sl.unlockRead(stamp);
}
}
return result;
}
8.3 JIT 友好的写法
// 把高频路径和低频路径分开,JIT能更好内联
public Quote getQuote(String symbol) {
long stamp = sl.tryOptimisticRead();
Quote quote = cache.get(symbol);
if (sl.validate(stamp)) {
return quote; // 快路径,JIT直接内联
}
return getQuotePessimistic(symbol); // 慢路径,单独方法
}
private Quote getQuotePessimistic(String symbol) {
long stamp = sl.readLock();
try {
return cache.get(symbol);
} finally {
sl.unlockRead(stamp);
}
}
九、总结对比
| 维度 |
ReentrantReadWriteLock |
StampedLock |
| 底层 |
AQS + CLH 队列 |
volatile long + 乐观读 |
| 读模式 |
悲观(加锁) |
乐观(无锁验证) |
| 重入 |
支持 |
不支持 |
| 写饥饿 |
有 |
有 |
| 适用读写比 |
均衡场景 |
读远多于写 |
| 复杂度 |
低 |
高(容易写错) |
| 吞吐量(读密集) |
基准 |
3-5 倍 |
StampedLock 的本质是把“锁的开销”转移到了“业务代码的正确性保障”上——你需要自己管理 volatile、验证逻辑、不可重入约束。换来的是极端读密集场景下接近无锁的性能。
在云栈社区,也常有人讨论这类并发优化中的取舍与踩坑经验,值得多看看实际场景再做决定。