Java 并发编程写到第七篇,今天把 ReentrantReadWriteLock 单独拎出来聊透。这名字看着长,核心其实一句话:读锁共享,写锁独占,读写互斥。我在处理本地缓存、配置刷新、路由表这类“读多写少”的场景时,经常用它替代 ReentrantLock。因为 ReentrantLock 不管你是读还是写,统统互斥排队;而一个服务 95% 的请求可能都在读,读线程之间互相等待,纯属浪费。这篇文章会从设计原理、源码关键点、实战用法三个角度讲透,顺手聊几个我踩过的坑。适合已经熟悉 synchronized 和 ReentrantLock、想进一步优化并发读性能的同学。
1. 先搞清楚读写锁到底解决什么问题
1.1 读多写少场景下的锁竞争
假如你现在维护一个本地缓存,数据从远端加载到内存,绝大多数线程都在读,只有零星几次更新。如果用 synchronized 或者 ReentrantLock 包住 get 和 put,读线程之间也会变成串行。一次读操作不过几十纳秒,却要排队等锁,吞吐量直接掉一个量级。
我拿实际场景说:一个交易配置服务,配置项加载到本地 Map 之后,QPS 高达几万,但配置更新一天可能只有几次。如果用 ReentrantLock 保护 Map 的读写,所有配置读取都得串行,服务基本就废了。换成 ReentrantReadWriteLock 之后,读锁可以被多个线程同时持有,读请求并发执行;只有真正的写请求过来,才会和读者互斥。这就是读写锁存在的意义。
代价也不是没有。读写锁内部要同时维护“读”和“写”两种状态,判断逻辑比普通互斥锁复杂,单线程场景反而更慢。它的适用面很明确:读多写少,写操作不频繁但必须原子。
1.2 为什么不能用两把锁分别锁读和写
之前有读者问过我:既然读读不互斥,写写互斥,那我搞两个 Lock,读方法用 lockA,写方法用 lockB,不也一样吗?这恰恰是最大的误区。两把锁互相感知不到对方,读线程持 lockA 读数据的同时,写线程持 lockB 改同一个数据,读写之间没有任何阻塞关系,数据一致性直接崩了。
ReentrantReadWriteLock 能做到读-写互斥,本质原因是读锁和写锁看的是同一份 AQS 同步状态。你可以把它想成一个总闸门:读锁是共享通道,多个读者能同时通过;但只要有写者申请,这份共享就没了,大家全部互斥。这不是两把独立锁能拼出来的语义,而是同一把锁在不同方向上的两种形态。
2. 核心设计:一个 state 怎么装下两把锁
2.1 高 16 位给读锁,低 16 位给写锁
ReentrantReadWriteLock 的同步机制建立在 AQS 之上,同步状态就是 AQS 里那个 int state。一个 int 要同时装下两把锁,设计者把 state 从中间一刀切开:
- 高 16 位:读锁被获取的总重入次数。
- 低 16 位:写锁被获取的总重入次数。
对应源码常量:
static final int SHARED_SHIFT = 16; static final int SHARED_UNIT = (1 << SHARED_SHIFT); static final int MAX_COUNT = (1 << SHARED_SHIFT) - 1; static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1;读锁每获取一次,state 直接加 SHARED_UNIT,也就是 65536;写锁每获取一次,state 加 1。读取计数时这样算:
- 读锁数量 = state >>> 16
- 写锁数量 = state & 0xFFFF
好处很明显:锁的计数、读写判断、CAS 更新全部在同一个 int 上完成。多线程并发抢读锁时,只需要一个 CAS 把 state 加上 65536,不需要再引入额外的锁来保护锁本身。这是整个设计最精妙的地方。
2.2 读写互斥是怎么实现的
写锁获取时,会先检查当前 state:
- 如果 state 不为 0,说明要么有读者占着读锁,要么有写者占着写锁。此时只有一种情况能继续:写锁已经被当前线程自己持有,这是重入。
- 如果 state 为 0,说明锁完全空闲,这时候还要考虑公平性策略,再尝试 CAS 抢占。
读锁获取时,检查逻辑反过来:
- 如果写锁数量不为 0,且持有写锁的不是当前线程,直接失败,排队等待。
- 否则说明没有写锁(或者写锁是自己持有的,对应锁降级场景),可以尝试把高 16 位加 1。
这里有个容易被忽略的细节:读锁获取并不是只要写锁没人持有就一定能成功。如果同步队列头部已经有写锁在等待,非公平模式下读锁会先让一步,防止大量读线程把写线程饿死。
2.3 可重入、锁降级与公平性
可重入:同一个线程可以重复获取写锁,写锁计数加 1;同一个线程也可以重复获取读锁,读锁总数加 1。注意这里读锁的总数只是统计了“所有读者重入次数之和”,具体每个线程各占几次,要额外机制来记录,下一节细说。
锁降级:线程已经持有写锁时,可以再获取一把读锁,然后把写锁释放掉。最终这个线程手里只剩读锁,角色从写者降级成读者。源码对读锁获取的判断专门留了口子:只要当前持有写锁的线程是自己,读锁就能获取成功。
看一个状态变化就清楚了:
初始 state = 0 写线程获取写锁:state = 1 当前线程继续获取读锁:state = 1 + 65536 = 65537 释放写锁:state = 65536 此时读锁计数 = 1,写锁计数 = 0,降级完成公平性:构造函数传 true 走 FairSync,传 false 走 NonfairSync。公平模式下,读写锁获取都要看同步队列前面有没有更早等待的线程;非公平模式更激进,写锁几乎可以硬抢,读锁因为有饥饿风险,还是会在一定程度上礼让等待的写锁。
3. 从源码细节看读写锁的实现
3.1 每个线程的重入次数:HoldCounter
读锁总数存在 state 高 16 位,但它只知道一个总数,不知道每个线程分别重入了几次。比如线程 A 和线程 B 同时读,A 重入两次,B 重入一次。这里有一个关键约束:只有真正持有读锁的线程才能释放读锁。要判断“当前线程是不是读锁持有者”,就必须知道当前线程自己的重入次数。
所以读锁内部维护了一个 HoldCounter,它保存当前线程的重入次数:
static final class HoldCounter { int count = 0; final long tid = getThreadId(Thread.currentThread()); }释放读锁时,会先取出当前线程的 HoldCounter,如果 count 小于等于 0 直接抛 IllegalMonitorStateException。这个检查保证了锁的持有和释放必须是同一个线程,也顺带支持了 getReadHoldCount() 查询当前线程读锁重入次数。
3.2 firstReader、cachedHoldCounter 与 readHolds
每个读线程都要记录重入次数,最直接的方案是给每个线程放一个 ThreadLocal。但 ThreadLocal 取值有开销,源码做了两层优化:
- firstReader:记录第一个获取读锁的线程。第一次加读锁时,不需要创建 ThreadLocal,直接记录线程引用和 count。
- cachedHoldCounter:缓存最近一个成功获取读锁线程的 HoldCounter。实际场景中,同一个线程连续加锁、释放非常常见,命中缓存就能省掉一次 ThreadLocal 查找。
- readHolds:兜底方案,这才是一个 ThreadLocal ,保存所有其他线程的计数。
注意:HoldCounter 的 count 归零后,源码会把对应的 ThreadLocal 条目 remove 掉。如果不 remove,在线程池这种线程长期复用的环境里,ThreadLocalMap 会残留大量 entry,最终引发内存泄漏。线上排查线程池占用问题时,这个细节很值得留意。
读锁释放时的路径也分三档:先判断 firstReader,再判断 cachedHoldCounter,最后才走 readHolds。这套优化串联起来,绝大多数锁获取和释放都不会真正触及 ThreadLocal 的全局查找,性能比想象中好很多。
3.3 读锁为什么不能配 Condition
很多人会问,为什么写锁能 newCondition(),读锁就不行?其实 Condition 本质是等待/通知机制,它要求等待线程释放“独占”的状态,然后在条件队列里挂起。写锁是独占锁,state 的归属很明确,signal 之后能准确唤醒一个写者。读锁是共享锁,多个读者同时持有,如果大家都在同一个条件队列里等着,signal 之后到底唤醒谁?其他读者持有的读锁又怎么处理?语义上根本说不清。
所以 ReadLock 的 newCondition() 直接抛 UnsupportedOperationException。如果你确实需要读锁配合等待/通知,通常的做法是把等待条件拆出来,单独用一把互斥锁保护,或者直接用写锁承担条件等待的角色。
4. 实战:本地缓存与锁降级
4.1 基础版本:读多写少的本地缓存
先写一个最常见的用法。本地缓存,读操作占绝大多数,写操作偶尔发生:
public class LocalCache { private final Map<String, Object> cache = new HashMap<>(); private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock(); private final Lock readLock = rwLock.readLock(); private final Lock writeLock = rwLock.writeLock(); public Object get(String key) { readLock.lock(); try { return cache.get(key); } finally { readLock.unlock(); } } public void put(String key, Object value) { writeLock.lock(); try { cache.put(key, value); } finally { writeLock.unlock(); } } }lock() 和 unlock() 必须成对出现,哪怕中间抛异常也要在 finally 里释放。这里我没用 tryLock,因为读锁冲突概率低,tryLock 失败还得走重试逻辑,反而增加复杂度。
4.2 一个更真实的需求:写完还要连续做一致性操作
假设你的缓存不止放数据,还维护一个统计信息。更新后要基于最新数据触发配置回调、写审计日志、对比新旧配置。这一连串操作期间,不希望有别的写线程插入。直接释放写锁再做,另一个写线程就可能在这条时间线上插入,导致“刚统计完就变了”。这时候就有必要用锁降级。
public class ConfigCache { private Map<String, String> config = new HashMap<>(); private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock(); private final Lock readLock = rwLock.readLock(); private final Lock writeLock = rwLock.writeLock(); public String get(String key) { readLock.lock(); try { return config.get(key); } finally { readLock.unlock(); } } public void update(Map<String, String> newConfig) { writeLock.lock(); try { Map<String, String> oldConfig = config; config = newConfig; // 写锁释放前,先获取读锁,完成锁降级 readLock.lock(); } finally { writeLock.unlock(); } try { // 此时当前线程持有读锁,其他写线程无法修改 config // 后续读取到的一定是刚写入的版本 for (String key : newConfig.keySet()) { String currentValue = config.get(key); // 这里可以安全地做新旧配置对比、触发回调、写日志 } } finally { readLock.unlock(); } } }代码的顺序是关键:先持有写锁,写完后拿读锁,再释放写锁。释放写锁后,当前线程还握着读锁,所以后续那段遍历逻辑不会被其他写线程打断。
4.3 锁降级的三个条件,缺一不可
结合源码,我把锁降级的前提总结成三条:
- 当前线程必须先持有写锁。
- 在写锁尚未释放时,成功获取读锁。
- 先释放写锁,再继续以读锁身份执行后续逻辑。
顺序上最容易错的是有人先释放写锁再尝试加读锁,那就不叫降级了,中间存在一个“无锁”窗口,别的写线程可能插进来。还有更隐蔽的错误:拿写锁前先拿读锁,然后想升级成写锁,这是死路,下一节细说。
另外要说清楚:锁降级保护的是“读这段逻辑的连续性”,不是“在这个阶段修改数据”。如果你在降级段里还想删缓存条目、改容器结构,那应该老老实实再走一次完整的写锁流程,或者把修改操作放到写锁段内完成。我见过有人把淘汰逻辑放进降级段,然后发现环形依赖,排查了半天。
5. 常见坑与选型建议
5.1 读锁升级写锁:死锁隐患
锁降级是合法的,方向反过来的“锁升级”却是个死局。线程先持有读锁,再去抢写锁,而写锁必须等待所有读锁释放才能拿到。你自己手里的读锁还没释放,写锁永远不可能成功。于是当前线程既占着读锁又等写锁,形成自己等自己的死锁。JVM 不会给你报任何错,代码直接卡在 lock() 那一行,线上表现就是线程一直 RUNNABLE 却不继续执行。
排查方法:看到某个线程长时间停在 ReentrantReadWriteLock 的 lock 方法上,dump 线程栈,如果它之前已经获取过读锁,基本可以断定是这里出了问题。
5.2 写锁饥饿:非公平模式读多写多一样出事
默认的非公平模式性能好,但有一个隐患:如果读线程持续不断进来,state 高 16 位的读锁计数长期不为 0,写线程就只能一直排队。极端情况下,写请求的延迟会变得非常难看。
ReentrantReadWriteLock 提供了公平模式new ReentrantReadWriteLock(true),让读写线程都按队列顺序获取,读写都走先到先得。代价是吞吐下降,因为每次锁获取都要检查队列前序节点。我的经验是:写操作占比超过 20%,或者写线程对延迟要求很高,就不要在非公平模式下硬扛,尽早换公平模式或者换别的方案。
5.3 什么场景别用 ReentrantReadWriteLock
很多人看完读写锁就觉得它比互斥锁好,这是错觉。在写多读少的场景,读锁带来的并发读优势没吃到,写锁反而要跟所有读者互斥,还不如一把互斥锁简单直接。
另外,如果“读多写少”但数据更新方式是整体替换,比如一个配置对象整体刷新,volatile + 不可变对象往往比读写锁更省:
private volatile ImmutableConfig config = loadConfig(); public ImmutableConfig getConfig() { return config; } public void refresh(ImmutableConfig newConfig) { config = newConfig; }volatile 保证可见性,不可变对象保证读的时候不会看到中间状态,读操作完全无锁。只有当操作不是“整体替换”,而是基于当前数据做复合修改时,读写锁才真正派上用场。
5.4 我自己的选型参考
简单总结一下我现在做并发控制时的选择路径:
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 数据整体替换、读操作极多 | volatile + 不可变对象 | 无锁读,性能最好 |
| 读多写少,且需要读写互斥 | ReentrantReadWriteLock | 并发读,吞吐高 |
| 写比例不低,锁内逻辑复杂 | ReentrantLock / 分段锁 | 读写锁收益有限,反而增加复杂度 |
读写锁不是万金油,它解决的是特定场景下的特定问题。理解了这一点,你在选型时才不会盲目跟风。
最后分享一个实际踩过的小技巧:用 ReentrantReadWriteLock 写缓存时,get 和 put 的锁路径千万别在同一个方法里乱串。我见过不止一次,有人在读锁没有释放前调用了写锁逻辑,结果线程卡死在压测环境里,dump 出来后大家才反应过来是读锁升级。建议你写完代码后,把“先读后写”“先写后读”的路径在草稿纸上画一遍,确认没有形成锁环,再往里填业务逻辑。这个习惯救过我很多次。