StampedLock 实战:高并发读多写少的乐观读锁方案

在 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 不是万能锁,它的价值集中在“读远多于写”这一窄而高频的战场。把乐观读、悲观读、写锁三者用对,就能在几乎不增加复杂度的前提下,换来肉眼可见的读吞吐提升。

上一篇 Watchtower 实战:Docker 镜像自动更新
下一篇 Python 异步编程实战:用 asyncio 写出高并发 IO 应用
最新评论
Docker 网络模式详解:bridge/host 生产选型 - FSData
Docker 网络模式详解:bridge/host 生产选型 - FSData
8月20日
[…] 注意最后那条:把端口绑定到 127.0.0.1,再由宿主机上的 Nginx 反代进来,比直接 -p 80:80 暴露安全得多,也方便统一做
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
8月20日
[…] Pinia 的工程化用法,可以参考我们之前的实战文章 《Vue3 组合式 API + Pinia:状态管理从入门到实战》,那里覆盖了 Sto
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
8月19日
[…] 状态,点一下就能起停容器,配合我们讲过的 Docker Compose 编排 […]
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
8月19日
[…] 缓存三大坑的本质都是”缓存失效导致请求穿透到数据库”。记住口诀:穿透用布隆、击穿用锁、雪崩用错峰。配合合理的过期策略
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
8月19日
[…] Docker 镜像部署。结合我们之前写的 Docker Compose 一键编排实战,可以在 CI […]