【JUC】读写锁 StampedLock

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 使用注意事项(避坑清单)

  1. 绝对不能重入 → 锁的获取/释放严格配对,不要嵌套。
  2. 乐观读后必须 validate → 否则可能读到脏数据。
  3. tryOptimisticRead 不需要 unlock → 别多此一举去解锁。
  4. stamp 要保存好传回 unlock → 不能丢失。
  5. 不要在线程间共享 stamp → 每个线程用自己拿到的。
  6. 读多写少才用 → 写多的话它反而不如 ReentrantReadWriteLock。
  7. 临界区短才用 → 临界区长会拖住写线程。

九、一个经典陷阱:读多写少但临界区长

很多人以为 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的另一种解法。

相关推荐
Yang96111 小时前
自适应放电加实时纠偏,解析TY-60H在矿石能源的接续表现
1024程序员节
吹什么轩1 小时前
LangChain 全解:为什么大模型应用需要一层“框架“
1024程序员节
一个平凡而乐于分享的小比特1 小时前
CODESYS V3.5 SP10 移植至 T536 平台实战指南(二)
1024程序员节·codesys·t536
csdn_aspnet1 小时前
.NETCore SqlSuagr 根据 mysql 数据表按顺序生成简单编号
mysql·.netcore·sqlsugar·1024程序员节·sequence·.net8·编号
奔跑的呱呱牛1 小时前
开源了一个轻量级 Web 3D 模型检视 SDK:免后端纯前端解压 ZIP/OBJ,带工程测量与截面剖切
1024程序员节
知行产研1 小时前
湖州样板+团体标准,伯镭科技引领水泥矿山无人化
人工智能·科技·1024程序员节
小白今天也很酷1 小时前
MCXN947更新板载DAP_Link固件的方法
c语言·python·单片机·ubuntu·excel·1024程序员节
.道阻且长.1 小时前
Linux 基本开发工具:编辑器vim,配置vim
linux·运维·服务器·职场和发展·编辑器·vim·1024程序员节
AIyy8661 小时前
在线PPT生成工具功能观察:百度文库智能PPT、WPS AI与Kimi的侧重点
1024程序员节