《Java 并发编程·线程与内存模型》9.3 读写锁与 StampedLock

读多写少的缓存该用什么锁:ReentrantReadWriteLock 让读并行、写互斥,StampedLock 再用版本戳把读变成乐观尝试。三版缓存代码对照,附读写并行、乐观读失效重试、不可重入、writeLock 不响应中断的本机实测输出。

承接的问题

9.2 的 LockedLoanService 用一把互斥锁保护所有操作。借阅写入必须互斥,但图书查询不是:同一本《Effective Java》被 100 个请求同时读取,彼此之间没有任何冲突,用互斥锁把它们排成一队是纯浪费。

读写锁把「共享读」和「独占写」分开:

  • 多个读线程可以同时持有读锁;
  • 写线程独占,持有写锁时没有读者,也没有其它写者;
  • 读锁和写锁之间互斥。

ReentrantReadWriteLock 是标准实现;StampedLock 更进一步,提供乐观读:读的时候不加锁,读完用一个版本戳验证「这段时间有没有人写过」,没写过就直接用结果,写过再退化成加锁读。这一节比较三版缓存实现,并说明各自的边界。

ReentrantReadWriteLock:读并行、写互斥

先看语义。两个读线程可以同时进入:

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.locks.ReentrantReadWriteLock;

ReentrantReadWriteLock rw = new ReentrantReadWriteLock();
CountDownLatch readersIn = new CountDownLatch(2);
CountDownLatch releaseReaders = new CountDownLatch(1);

for (int i = 0; i < 2; i++) {
    new Thread(() -> {
        rw.readLock().lock();
        try {
            readersIn.countDown();
            releaseReaders.await();
        } catch (InterruptedException ignored) {
        } finally {
            rw.readLock().unlock();
        }
    }, "reader-" + i).start();
}
readersIn.await();

本机输出确认两个读锁同时持有、写锁未被占用,而写线程必须等所有读者退出:

rrw separation: 2 readers hold read lock concurrently=true, writeLocked=false
rrw separation: writer queued while readers hold=true
rrw separation: writer acquired after readers released

这就是读写锁的收益与代价:读吞吐上去了,但写线程要等所有读线程退出。如果读临界区很长(比如在锁里做远程调用),写线程会被拖住;如果读请求源源不断,写线程可能长期拿不到锁,也就是「写饥饿」。

写饥饿要说清楚

写饥饿不是 ReentrantReadWriteLock 的 bug,而是读优先调度的结果。需要区分两种说法:

  • 完全饿死:在非公平模式下,JDK 的实现会在「队首是写线程」时阻止新读者插队(readerShouldBlock 的 apparentlyFirstQueuedIsExclusive 判断),所以持续读流不会无限推后一个已经排到队首的写线程。
  • 等待被反复推后:如果写线程还没排到队首,新来的读者仍可能先拿到读锁;读临界区越长,写线程的等待越久。公平模式用严格 FIFO 消除插队,但每次交接都要唤醒排队线程,吞吐下降。

实践上的处理顺序是:先缩短读临界区,再考虑公平锁。把数据库查询、HTTP 调用放进读锁,任何调度策略都救不了。

可重入与降级

ReentrantReadWriteLock 的读锁和写锁都可重入。它还支持锁降级:持有写锁时先获取读锁,再释放写锁,读锁仍然有效(顺序不能反,写锁释放前必须已经拿到读锁)。反过来不支持升级:持有读锁再去拿写锁会死锁,因为写锁要等所有读者(包括自己)退出。需要「读到后条件性写」时,必须先释放读锁,再重新竞争写锁,并重新检查条件。

Condition 只能从写锁创建:rw.writeLock().newCondition()。读锁上没有条件队列。

StampedLock:乐观读与版本戳

StampedLock 有三种模式:

模式方法行为
写writeLock()独占,返回 stamp
读readLock()共享,返回 stamp
乐观读tryOptimisticRead()不加锁,返回 stamp,仅用于验证

乐观读的套路是「读 → 验证 → 失败重试」:

long stamp = lock.tryOptimisticRead();
Book value = snapshot.get(isbn);
if (!lock.validate(stamp)) {
    stamp = lock.readLock();
    try {
        value = snapshot.get(isbn);
    } finally {
        lock.unlockRead(stamp);
    }
}

validate(stamp) 返回 true,表示从取 stamp 到验证之间没有写锁成功提交;返回 false,表示数据可能已经变了,必须重新读。注意 validate 只回答「有没有写发生过」,不保证你读到的是某一时刻的完整快照——读取的对象本身必须是不可变的,或者通过 volatile/VarHandle 安全发布。

一个完整的乐观读缓存

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.StampedLock;

public final class StampedCache<K, V> {

    private volatile Map<K, V> snapshot = Map.of();
    private final StampedLock lock = new StampedLock();

    public V get(K key) {
        long stamp = lock.tryOptimisticRead();
        V value = snapshot.get(key);
        if (!lock.validate(stamp)) {
            stamp = lock.readLock();
            try {
                value = snapshot.get(key);
            } finally {
                lock.unlockRead(stamp);
            }
        }
        return value;
    }

    public void put(K key, V value) {
        long stamp = lock.writeLock();
        try {
            Map<K, V> copy = new HashMap<>(snapshot);
            copy.put(key, value);
            snapshot = Map.copyOf(copy);
        } finally {
            lock.unlockWrite(stamp);
        }
    }
}

写路径用「复制 + 替换」维护不可变快照,读路径读 volatile 引用。这样乐观读即使与写并发,读到的也是一个完整的旧快照,不会看到 HashMap 扩容的中间状态。

用两个闩锁强制一次「读的过程中发生写」,验证戳会失效并触发重试:

optimistic read: first=10, validate=false, retryNeeded=true
optimistic read after retry: 9

实验做法:先 put 库存 10;读者取 stamp 并读到 10,再放行写线程写成 9;写线程退出后读者才 validate,因此必然得到 false。第一次读到库存 10,重试后拿到 9。整个过程由闩锁控制,是确定性的,和机器负载无关。

不可重入

StampedLock 不是可重入锁。已经持有写锁的线程再调用 writeLock() 会永久阻塞;tryWriteLock() 则立即返回 0(下面的输出把返回值转成了 boolean):

stamped non-reentrant: tryWriteLock()=false, tryWriteLock(10ms)=false

所以 StampedLock 不能像 ReentrantLock 那样在辅助方法里随意嵌套加锁。要么把锁的获取集中在一层,要么换回 ReentrantReadWriteLock。

不响应中断

StampedLock.writeLock() 不抛 InterruptedException,中断不会取消等待。实测一个线程阻塞在 writeLock() 上,中断 300 毫秒后仍然是 WAITING:

stamped writeLock: state before interrupt=WAITING
stamped writeLock: 300ms after interrupt, state=WAITING, isInterrupted=false
stamped writeLock: acquired after interrupt, isInterrupted=true

有意思的是中间那行:等待期间中断标志是 false。读 JDK 源码可以看到,acquireWrite 在 park 返回后用 Thread.interrupted() 查询并清除标志,把结果存进局部变量;真正取到锁后再 Thread.currentThread().interrupt() 恢复。所以中断没有丢,但它不能让你停止等待,只能在你拿到锁之后补一个标志。需要可取消的等待,用 ReentrantLock.lockInterruptibly()。

此外 StampedLock 没有 Condition,也不保证公平性;写线程可能被持续的读锁推迟(乐观读本身不阻塞写,但会让读路径不断重试)。

三版缓存对比

缓存值直接用领域里的不可变 Book,三个版本对外接口相同:

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.ReentrantReadWriteLock;

interface Cache {
    Book get(String isbn);
    void put(String isbn, Book book);
}

final class SyncCache implements Cache {
    private final Map<String, Book> map = new HashMap<>();
    public synchronized Book get(String isbn) { return map.get(isbn); }
    public synchronized void put(String isbn, Book book) { map.put(isbn, book); }
}

final class RwCache implements Cache {
    private final Map<String, Book> map = new HashMap<>();
    private final ReentrantReadWriteLock rw = new ReentrantReadWriteLock();

    public Book get(String isbn) {
        rw.readLock().lock();
        try { return map.get(isbn); } finally { rw.readLock().unlock(); }
    }

    public void put(String isbn, Book book) {
        rw.writeLock().lock();
        try { map.put(isbn, book); } finally { rw.writeLock().unlock(); }
    }
}

final class StampedBookCache implements Cache {
    private final StampedCache<String, Book> cache = new StampedCache<>();
    public Book get(String isbn) { return cache.get(isbn); }
    public void put(String isbn, Book book) { cache.put(isbn, book); }
}

StampedBookCache 直接复用了上文的 StampedCache。三者的读路径分别是:全程互斥、读锁共享、乐观读加验证重试。

维度synchronizedReentrantReadWriteLockStampedLock
读并发无有有,且乐观读不加锁
写互斥有有有
可重入是是否
等待可中断否lockInterruptibly()否
条件队列单个等待集写锁可建 Condition无
公平性否可配置否
锁降级不适用支持写降读用 tryConvertToReadLock
适用读写都少、临界区短读多写少、需要重入/条件读远多于写、读路径极短

我用 12 个线程(8 读 4 写)各跑 2 万次操作验证三版都能正确完成,读者会检查库存不为负:

synchronized mixed workload done: 12 threads x 20000 ops
rwlock mixed workload done: 12 threads x 20000 ops
stamped mixed workload done: 12 threads x 20000 ops

这组数字只证明功能正确、没有死锁,不证明性能。本机当前负载在 100 以上,任何耗时或吞吐对比都会被调度噪声淹没,所以本节不贴性能数字。StampedLock 版在混合负载下的乐观读重试次数每轮从数百到上千不等,随调度变化,同样不作为指标。

需要性能结论时,在空闲机器上用 JMH 固定线程数、固定读写比例,分别测三版;并且要用真实临界区长度,而不是空方法——乐观读的收益完全取决于「读的这段时间里写发生的概率」。

什么时候不要用 StampedLock

  • 读临界区里需要调用其它可能阻塞的方法:乐观读期间写线程不受阻,但你的读时间越长,重试概率越高;
  • 需要重入:嵌套 writeLock() 直接死锁;
  • 需要条件队列:StampedLock 没有 Condition;
  • 写比例不低:乐观读频繁失效,退化成「验证失败 + 加锁读」,比直接用读写锁多一次无用读;此外写路径每次复制整个快照,写多时复制开销也会成为瓶颈;
  • 需要可中断或公平:两者都不提供。

在 libraryd-conc 里落地

把乐观读缓存包装成 BookRepository 装饰器,读请求先查缓存,未命中再查下层:

import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.locks.StampedLock;

public final class CachedBookRepository implements BookRepository {

    private final BookRepository delegate;
    private final StampedLock lock = new StampedLock();
    private volatile Map<String, Book> snapshot = Map.of();

    public CachedBookRepository(BookRepository delegate) {
        this.delegate = delegate;
    }

    @Override
    public Optional<Book> findByIsbn(String isbn) {
        Book cached = optimisticGet(isbn);
        if (cached != null) {
            return Optional.of(cached);
        }
        Optional<Book> loaded = delegate.findByIsbn(isbn);
        loaded.ifPresent(this::cache);
        return loaded;
    }

    private Book optimisticGet(String isbn) {
        long stamp = lock.tryOptimisticRead();
        Book value = snapshot.get(isbn);
        if (!lock.validate(stamp)) {
            stamp = lock.readLock();
            try {
                value = snapshot.get(isbn);
            } finally {
                lock.unlockRead(stamp);
            }
        }
        return value;
    }

    private void cache(Book book) {
        long stamp = lock.writeLock();
        try {
            Map<String, Book> copy = new HashMap<>(snapshot);
            copy.put(book.isbn(), book);
            snapshot = Map.copyOf(copy);
        } finally {
            lock.unlockWrite(stamp);
        }
    }

    @Override
    public void save(Book book) {
        delegate.save(book);   // 先落库,成功后再更新缓存
        cache(book);
    }
}

这段代码的取舍:

  • 缓存未命中时不加锁查库。 多个线程可能同时未命中、同时查库,这是有意的:加锁会把慢查询串行化,代价更大。代价是缓存击穿时会有重复查询,需要合并请求的话另做 single-flight。
  • save 先写库再写缓存。 写库失败时缓存保持旧值,不会出现「缓存说扣了、库里没扣」。反过来先写缓存再写库,失败时会短暂不一致。
  • 只解决单机缓存。 多实例部署时,A 实例 save 后 B 实例的缓存不会失效,需要额外的失效通道(消息、版本号、TTL),这超出本节范围。

缓存本身也不是万能的:如果 findByIsbn 的结果变化频繁,缓存命中率低,乐观读的收益会被写路径的复制开销吃掉。

常见错误与边界

在乐观读里直接读可变的 HashMap。 写线程可能正在扩容,读者看到的桶数组、链表指针都是中间状态;validate 是读完之后才调的,挡不住读的过程中抛异常或死循环。乐观读只能读不可变快照或 volatile 字段。

忘记 validate。 tryOptimisticRead 不加锁,没有 validate 就等价于无保护读取。返回值不能直接当最终结果。

把 stamp 当成锁的持有凭证。 stamp 只是一个版本号;validate 通过不代表你持有任何锁,也不需要 unlock。只有 readLock/writeLock 返回的 stamp 才需要对应的 unlockRead/unlockWrite。

重入 StampedLock。 在 writeLock 里调用另一个也拿 writeLock 的方法,线程会永远等自己。tryWriteLock 返回 0 已经是友好的失败方式。

以为中断能取消 writeLock。 上面实测了:中断只是把标志延后到取到锁之后,等待本身不结束。

在读锁里做 IO。 读写锁的写线程要等所有读者;读临界区越长,写延迟越大。缓存读取应该是内存操作,不应该在锁内查库。

读锁升级写锁。 ReentrantReadWriteLock 不支持升级,读锁未释放就申请写锁会死锁;必须先放读锁再竞争写锁,并重新检查条件。

用 StampedLock 替代所有锁。 它不可重入、不可中断、无公平、无条件队列;只有「读远多于写且读路径极短」时才值得。

小结

  • ReentrantReadWriteLock 让多个读并行、写独占;写线程必须等所有读者退出,长读临界区会造成写延迟甚至饥饿。
  • 它支持重入和写降读,不支持读升写;Condition 只能从写锁创建;公平模式可缓解插队但降低吞吐。
  • StampedLock 的乐观读不加锁:取 stamp、读数据、validate,失败再退化为加锁读;本机确定性实验里写发生后 validate=false,重试后读到新值。
  • StampedLock 不可重入(tryWriteLock 返回 0),writeLock 不响应中断(等待期间标志被内部清除,取到锁后恢复),没有 Condition 和公平性。
  • 三版缓存的功能都能正确完成 12 线程 × 2 万次混合读写;性能结论必须用 JMH 在空闲环境测,本机负载太高不提供数字。
  • 选择顺序:临界区短且读写都少用 synchronized;读多写少且需要重入/条件用 ReentrantReadWriteLock;读远多于写、读路径极短且不需要重入时再考虑 StampedLock。

下一章进入线程池:任务不再手工创建线程,而是交给 ThreadPoolExecutor。七参数、执行流程、队列与拒绝策略,以及为什么「无界队列 + 固定线程数」会把故障从线程池扩散到整个进程。

阅读导航:上一节 9.2 ReentrantLock 与 synchronized 的取舍 | 下一节 10.1 线程池七参数与执行流程

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收