在 Java 高并发编程里,StampedLock 是 JDK 1.8 引入、专门解决读多写少场景的一把“特殊锁”。它用乐观读(optimistic read)换来了读路径几乎零开销,又保留了悲观读与写锁的强一致保证。本文用可运行代码讲清三种模式的取舍,以及那些上线后才发现的坑。
一、读多写少,为什么 ReentrantReadWriteLock 不够用
经典的 ReentrantReadWriteLock 允许多个读线程同时进入,写线程则需独占。问题在于:一旦读请求源源不断,写线程就可能长时间拿不到锁而“饿死”。在读多写少的缓存、配置中心场景里,这恰恰是最高发的痛点。下面的示意代码模拟了读远多于写时的阻塞形态:
// 读多写少下,读不释放会导致写线程长时间等待
ReentrantReadWriteLock rw = new ReentrantReadWriteLock();
rw.readLock().lock();
try {
// 大量读操作持续占用,写线程 rw.writeLock().lock() 长期拿不到
} finally {
rw.readLock().unlock();
}
StampedLock 的解法是:读的时候先“乐观地”读一把(不真正加锁),读完用一个小印章(stamp)校验数据有没有被写过;没被写就直接返回,被写了再升级成悲观读。这样绝大多数读路径完全不用阻塞写线程。
二、StampedLock 的三种模式
2.1 写锁 writeLock
和普通的写锁一样,独占访问,返回的是一个 stamp。写完必须 unlockWrite(stamp)。
2.2 悲观读锁 readLock
等同于 ReentrantReadWriteLock 的读锁,会阻塞写线程,适合读出来要做复杂计算、且不能忍受重读的场景。
2.3 乐观读 tryOptimisticRead
核心卖点:不加锁,直接返回一个戳。读完后用 validate(stamp) 判断期间是否有写发生。没有写就零成本返回;有写就升级为悲观读重试。读多写少时,乐观读几乎总是成功的,性能极高。
| 模式 | 是否阻塞写 | 开销 | 典型适用 |
|---|---|---|---|
| 写锁 writeLock | 是(独占) | 最高 | 修改共享状态 |
| 悲观读 readLock | 是(写等待) | 中 | 读后做重计算 |
| 乐观读 tryOptimisticRead | 否 | 几乎为零 | 读多写少、读取简单 |
三、乐观读的标准写法
下面是一段教科书式的乐观读模板:先乐观读,validate 通过就直接用;不通过再退化成悲观读锁重试。
StampedLock lock = new StampedLock();
double[] point = new double[2];
double distanceFromOrigin() {
long stamp = lock.tryOptimisticRead(); // 乐观读,不加锁
double x = point[0], y = point[1];
if (!lock.validate(stamp)) { // 期间有写?升级悲观读
stamp = lock.readLock();
try {
x = point[0]; y = point[1];
} finally {
lock.unlockRead(stamp);
}
}
return Math.sqrt(x * x + y * y);
}
注意 validate 必须在读完数据之后调用,且读出来的局部变量要先保存到栈上,避免二次读取时数据已被修改。
四、线程安全缓存实战
一个读多写少的本地缓存,用 StampedLock 包一层读写分离,写操作加写锁、读操作走乐观读:
public class StampedCache<K, V> {
private final Map<K, V> map = new HashMap<>();
private final StampedLock lock = new StampedLock();
public V get(K key) {
long stamp = lock.tryOptimisticRead();
V v = map.get(key);
if (!lock.validate(stamp)) {
stamp = lock.readLock();
try { v = map.get(key); }
finally { lock.unlockRead(stamp); }
}
return v;
}
public void put(K key, V value) {
long stamp = lock.writeLock();
try { map.put(key, value); }
finally { lock.unlockWrite(stamp); }
}
}
这种写法在高频读、低频写的配置缓存、元数据缓存上,吞吐通常明显优于 ReentrantReadWriteLock。若缓存 value 是不可变对象(参考 Java 21 Record 的不可变设计),乐观读的安全性会更高。
五、必须知道的 4 个坑
5.1 不可重入
StampedLock 不是可重入锁。同一个线程在持有读锁时再去拿写锁会直接死锁。代码里务必避免“读里套写”。
5.2 没有 Condition
它不提供条件变量(await/signal)。需要等待某条件再继续的逻辑,应退回 ReentrantReadWriteLock 或用其它同步原语。
5.3 乐观读看到的是快照
乐观读期间数据可能被写线程改动,所以读到的值只是“某一瞬间的快照”,绝不能拿去做跨字段的一致性判断,除非 validate 通过。
5.4 stamp 必须来自同一把锁
解锁时传入的 stamp 必须来自加锁时返回的同一个对象,混用会抛出 IllegalMonitorStateException。
| 误区 | 后果 | 对策 |
|---|---|---|
| 读锁里再拿写锁 | 死锁 | 拆方法,避免重入 |
| 用乐观读做一致性计算 | 读到半成品 | validate 后再算 |
| 忘记解锁 | 锁泄漏 | try/finally 兜底 |
| 跨锁复用 stamp | 异常 | stamp 就近使用 |
六、三种锁怎么选
| 方案 | 读性能 | 写公平 | 可重入 | 适用 |
|---|---|---|---|---|
| synchronized | 低(全互斥) | — | 是 | 简单低竞争 |
| ReentrantReadWriteLock | 中 | 易写饿死 | 是 | 读写均衡 |
| StampedLock | 高(乐观读) | 可控 | 否 | 读多写少 |
经验法则:写远少于读、读逻辑短平快,优先 StampedLock 乐观读;读写差不多或需要条件等待,用 ReentrantReadWriteLock;竞争极低、逻辑简单,synchronized 就够了。
七、性能基准与适用场景
在读占比 95% 以上、写占比不足 5% 的缓存场景,乐观读能把读路径的锁竞争降到极低。下面是一段简化的压测骨架,真实基准建议用 JMH:
// 用大量虚拟线程并发读,模拟读多写少
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 100_000; i++) {
executor.submit(cache::getRandomKey);
}
}
想进一步压榨 I/O 友好的并发,可结合 Java 21 虚拟线程 把读操作编排得更加丝滑。函数式风格的数据转换也能让缓存计算更清晰,见 Java 函数式编程。
八、JUnit5 并发测试怎么写
验证线程安全,可以用 JUnit5 起多个线程同时读写,断言最终状态一致:
@Test
void concurrentPutGet() throws Exception {
StampedCache<String, Integer> c = new StampedCache<>();
int threads = 16;
CountDownLatch start = new CountDownLatch(1);
ExecutorService es = Executors.newFixedThreadPool(threads);
for (int i = 0; i < threads; i++) {
es.submit(() -> {
start.await();
for (int k = 0; k < 1000; k++) c.put("k" + k, k);
});
}
start.countDown();
es.shutdown();
es.awaitTermination(10, TimeUnit.SECONDS);
assertEquals(999, c.get("k999"));
}
九、落地清单
- 确认场景是“读多写少”再上 StampedLock,否则收益有限;
- 读逻辑保持短小,乐观读后立刻 validate;
- 杜绝读锁内重入写锁,统一用 try/finally 解锁;
- 需要 Condition 时退回 ReentrantReadWriteLock;
- 用 JUnit5 并发用例守住线程安全底线。
把乐观读用对,StampedLock 是 Java 并发工具箱里性价比极高的一把锁。它不替代其它锁,而是补齐了“高频读、低频写”这一特定战场的最优解。
十、锁的相互转换(tryConvertToWriteLock)
StampedLock 还提供了一个很实用的能力:在持有读锁时,如果发现需要写,可以用 tryConvertToWriteLock(stamp) 尝试“原地升级”为写锁,而无需先释放读锁再抢写锁,从而绕开释放瞬间被别的写线程抢先的竞态。
long stamp = lock.readLock();
try {
// 读了一部分后发现必须修改
long ws = lock.tryConvertToWriteLock(stamp);
if (ws != 0L) { // 升级成功
stamp = ws;
// 此处已持有写锁,可安全修改
} else { // 升级失败,回退策略
lock.unlockRead(stamp);
stamp = lock.writeLock();
// 重新读取再修改
}
} finally {
lock.unlock(stamp);
}
转换失败时必须走“释放读锁 → 抢写锁 → 重新读”的回退路径,不要假设转换一定成功,否则会读到不一致的状态。
十一、和 synchronized 的取舍再补充
不少团队一上来就用 synchronized,图省事。但在读多写少的缓存、计数器聚合、配置热更新等场景,synchronized 的全互斥会让读吞吐被写拖垮。下面这张表给出一个粗略的选型参考:
| 读:写 比例 | 推荐 | 理由 |
|---|---|---|
| 1:1 左右 | synchronized / RRWL | 竞争均衡,简单优先 |
| 9:1 以上 | StampedLock 乐观读 | 读路径几乎无锁 |
| 写很频繁 | synchronized | 乐观读频繁失效反而更慢 |
一句话总结:StampedLock 不是万能锁,它的价值集中在“读远多于写”这一窄而高频的战场。把乐观读、悲观读、写锁三者用对,就能在几乎不增加复杂度的前提下,换来肉眼可见的读吞吐提升。




