承接的问题
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。三者的读路径分别是:全程互斥、读锁共享、乐观读加验证重试。
| 维度 | synchronized | ReentrantReadWriteLock | StampedLock |
|---|---|---|---|
| 读并发 | 无 | 有 | 有,且乐观读不加锁 |
| 写互斥 | 有 | 有 | 有 |
| 可重入 | 是 | 是 | 否 |
| 等待可中断 | 否 | 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 线程池七参数与执行流程
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。