StampedLock 深入讲解
StampedLock 是 Java 8 引入的,目的就是解决 ReentrantReadWriteLock 的那三个痛点(写饥饿、无法升级、读锁持有时间长)。理解它之后,你会对"读写并发"有全新的认识。
一、核心思想:用"版本号"代替"锁"
先回顾 ReentrantReadWriteLock 的读模式:读的时候真的加了锁,所以写线程必须等所有读锁释放。
StampedLock 换了个思路:
读的时候先不加锁,先"偷偷读"一下,同时记下版本号;读完再检查一下版本号有没有变。没变 → 读的数据有效;变了 → 说明期间有人写过,退化去加真正的读锁重读。
这就是乐观读(Optimistic Read)。
关键:那把"戳"(stamp)
每次获取锁,StampedLock 返回一个 long 类型的 stamp(版本号/邮戳):
java
long stamp = lock.tryOptimisticRead(); // 乐观读,返回一个版本号
long stamp = lock.readLock(); // 悲观读,也返回版本号
long stamp = lock.writeLock(); // 写锁,返回版本号
所有解锁操作都必须把 stamp 传回去:
java
lock.unlockRead(stamp);
lock.unlockWrite(stamp);
这个 stamp 就是用来校验数据有没有被改过的。
二、三种模式详解
1. 写锁(Write Lock)------ 独占
和普通写锁一样,排他。
java
long stamp = lock.writeLock();
try {
// 修改数据
} finally {
lock.unlockWrite(stamp);
}
2. 悲观读锁(Read Lock)------ 共享
和 ReentrantReadWriteLock 的读锁类似,真的加锁,会阻塞写。
java
long stamp = lock.readLock();
try {
// 读数据
} finally {
lock.unlockRead(stamp);
}
3. 乐观读(Optimistic Read)------ 不加锁!
这是 StampedLock 的精华 ,注意它没有对应的 unlock:
java
long stamp = lock.tryOptimisticRead(); // 拿到版本号,但不加锁!
// 读数据(此刻没加锁,别的写线程可能正在改!)
Object v = sharedData;
if (!lock.validate(stamp)) { // 校验:读的过程中有没有被写过?
// 被写过 → 数据可能无效 → 退化为悲观读,重读
stamp = lock.readLock();
try {
v = sharedData;
} finally {
lock.unlockRead(stamp);
}
}
// 到这里 v 一定是有效的
注意 tryOptimisticRead() 不加锁,所以:
- 不阻塞写线程 → 不会有写饥饿
- 自己也不阻塞 → 极快
代价是:读到的数据可能已经过期 ,所以要靠 validate(stamp) 来检查。
三、validate 到底在检查什么?
validate(stamp) 的逻辑很简单:
从我拿到 stamp 到现在,有没有发生过"写锁被获取"这件事?
- 没有 → 返回
true,说明数据没被改过,读到的有效。 - 有 → 返回
false,说明期间有写线程改过数据,你读到的可能是"改了一半"的脏数据,必须重读。
注意:乐观读期间如果有另一个乐观读,不算数 (读读不冲突),只有写才会让版本号失效。
线程A: tryOptimisticRead() → stamp=5
线程B: tryOptimisticRead() → stamp=5(读读,没关系)
线程C: writeLock() → 修改数据 → unlockWrite → 版本号变成6
线程A: validate(5) → false!(因为中间发生过写)→ 重读
四、完整示例:一个并发缓存
java
public class StampedCache<K, V> {
private final Map<K, V> map = new HashMap<>();
private final StampedLock lock = new StampedLock();
// 写
public void put(K key, V value) {
long stamp = lock.writeLock();
try {
map.put(key, value);
} finally {
lock.unlockWrite(stamp);
}
}
// 乐观读
public V get(K key) {
long stamp = lock.tryOptimisticRead(); // 1. 乐观读,不加锁
V value = map.get(key); // 2. 直接读
if (!lock.validate(stamp)) { // 3. 校验
stamp = lock.readLock(); // 4. 失败,退化悲观读
try {
value = map.get(key);
} finally {
lock.unlockRead(stamp);
}
}
return value; // 5. 一定是有效值
}
}
对比 ReentrantReadWriteLock 版本:
- 大多数读操作(没被写打断)完全不加锁,性能极高。
- 写线程不会被大量读锁卡住 → 没有写饥饿。
五、StampedLock 的"三大禁令"(非常重要)
StampedLock 性能好,但用起来很危险,因为它不支持很多东西:
禁令 1:不可重入!
java
long stamp = lock.writeLock();
lock.writeLock(); // ❌ 死锁!自己等自己
ReentrantLock、ReentrantReadWriteLock 都支持重入,StampedLock 不支持。同一个线程重复加锁会死锁。
禁令 2:不支持条件变量
没有 newCondition(),不能用 await/signal。
禁令 3:不支持中断(部分操作)
readLock()、writeLock() 这些不可中断 ;只有 readLockInterruptibly() 这类才行。
后果:不能用 synchronized 那种"顺手就嵌套"的写法
比如下面这种"在一个加锁方法里调用另一个加锁方法"的代码,用 StampedLock 会死锁:
java
public void methodA() {
long stamp = lock.writeLock();
try {
methodB(); // methodB 内部又 writeLock() → 死锁
} finally {
lock.unlockWrite(stamp);
}
}
所以 StampedLock 必须非常小心地使用,锁的获取和释放要严格配对,且不能嵌套。
六、StampedLock 如何解决你的三个问题
回到你之前问的三个问题,看看 StampedLock 的答案:
问题1:写线程会等很久吗?
基本不会。 因为乐观读根本不加锁,写线程随时可以拿到写锁。只有当某个读线程恰好处于"悲观读"阶段(乐观读失败后重读),才会短暂阻塞写。
ReentrantReadWriteLock: 写线程必须等【所有】读锁释放
StampedLock: 写线程只需等【正在悲观读】的线程
(而乐观读不阻塞它)
问题2:读→写升级会死锁吗?
不会(但要换写法)。 你不能"持锁升级",但你完全可以:
java
// 乐观读
long stamp = lock.tryOptimisticRead();
V v = map.get(key);
if (!lock.validate(stamp)) {
// 失败了就重试,或者去拿写锁
}
// 用完了,根本没有锁需要释放
long wStamp = lock.writeLock(); // 这里直接拿写锁,因为之前没持任何锁
try {
// 重新校验 + 修改
} finally {
lock.unlockWrite(wStamp);
}
因为乐观读不算持有锁,所以不存在"自己持有读锁导致写锁拿不到"的问题。
问题3:读完立刻存快照再释放?
StampedLock 把这件事做成了内建机制------你本来就是"读 + 校验",读到有效值后根本不需要持有任何锁去做后续计算:
java
long stamp = lock.tryOptimisticRead();
V v = map.get(key);
if (lock.validate(stamp)) {
// v 有效,接下来随便算,反正没持锁
return heavyCompute(v);
}
// 无效,重来或退化
七、StampedLock vs ReentrantReadWriteLock 对比表
| 特性 | ReentrantReadWriteLock | StampedLock |
|---|---|---|
| 读读并发 | ✅(真加锁) | ✅(乐观读不加锁) |
| 写饥饿 | 可能(非公平) | 基本没有 |
| 可重入 | ✅ | ❌ |
| 支持 Condition | ✅(写锁) | ❌ |
| 锁升级 | ❌ 死锁 | ✅(因为乐观读不持锁) |
| 锁降级 | ✅ | 不需要(直接读) |
| 性能(读多写少) | 中 | 高 |
| 使用难度 | 低 | 高(易踩坑) |
| 适用场景 | 通用 | 读极多、写少、临界区短 |
八、StampedLock 使用注意事项(避坑清单)
- 绝对不能重入 → 锁的获取/释放严格配对,不要嵌套。
- 乐观读后必须
validate→ 否则可能读到脏数据。 tryOptimisticRead不需要 unlock → 别多此一举去解锁。- stamp 要保存好传回 unlock → 不能丢失。
- 不要在线程间共享 stamp → 每个线程用自己拿到的。
- 读多写少才用 → 写多的话它反而不如
ReentrantReadWriteLock。 - 临界区短才用 → 临界区长会拖住写线程。
九、一个经典陷阱:读多写少但临界区长
很多人以为 StampedLock 一定比 ReentrantReadWriteLock 快,其实不一定:
java
// 如果读操作本身要花很久(比如 100ms 的计算)
long stamp = lock.tryOptimisticRead();
V v = map.get(key);
heavyCompute(v); // ← 这里花很久
if (!lock.validate(stamp)) { // 校验时发现被写过,前功尽弃,重读
...
}
如果你在乐观读和 validate 之间做了大量工作,期间被写的概率大大增加,导致频繁 validate 失败、频繁重读,性能反而更差。
正确姿势:乐观读只做"读 + validate"这一小段,拿到有效快照后再去做重计算。
java
long stamp = lock.tryOptimisticRead();
V v = map.get(key);
if (!lock.validate(stamp)) {
// 退化重读
}
// 校验通过后,才做重计算
heavyCompute(v);
十、总结:一张图理解 StampedLock
┌─────────────────────────────────────────────┐
│ StampedLock │
├─────────────────────────────────────────────┤
│ writeLock() → 独占,返回 stamp │
│ readLock() → 共享,返回 stamp │
│ tryOptimisticRead() → 不加锁,返回 stamp ⭐ │
│ │
│ 写时:版本号 +1 │
│ 校验:validate(stamp) 检查版本号有没有变 │
└─────────────────────────────────────────────┘
乐观读流程:
拿stamp → 读数据 → validate?
├─ true → 直接用 ✅
└─ false → 转悲观读重读
一句话记忆:
StampedLock用版本号校验 替代"读锁加锁",让大多数读操作零阻塞 ,从而消除写饥饿、支持"读后写"、缩短读临界区;代价是不可重入、不支持 Condition、用错就死锁。
到这里,从 ReentrantReadWriteLock 到 StampedLock 的完整脉络就打通了。如果你的场景是读极多、写很少、临界区短 ,StampedLock 是首选;否则老实用 ReentrantReadWriteLock 或 ReentrantLock 更稳妥。
需要我再补一个 StampedLock 的 tryConvertToWriteLock(真正意义上的"锁升级") 的讲解吗?它提供了一种安全升级的方式,是对你问题2的另一种解法。