之前我有一个内部系统的配置中心,读请求每秒几千次,配置更新却好几分钟才一次。最初图省事,我直接在 get 方法上加了 synchronized,结果每次配置一更新,所有读请求全被堵在门外,高峰期接口响应时间直接飙到几百毫秒。后来换成了 ReentrantReadWriteLock,读路径恢复到了微秒级。但真正让我意识到"这个锁不简单"的,是后面排查问题的时候——读锁和写锁到底把谁锁住、把谁放进去了,这里面门道远比我想象的多。
这篇我就从自己实际使用和踩坑的经历出发,把 ReentrantReadWriteLock 的行为完整拆一遍:它解决了什么、读锁和写锁各自的拦截规则、锁降级为什么是标准操作而锁升级必然死锁、公平模式怎么选、以及在实战中那些文档里不会明说的细节。
1. 先搞明白:这个锁到底把临界区拆成了什么
1.1 从一次缓存性能事故说起
大部分团队第一次接触到 ReentrantReadWriteLock,场景几乎都是同一个:读多写少。配置缓存、路由表、黑白名单、字典数据,都是这种特征。我那个配置中心就是一个典型。
老代码长这样:
public class ConfigCache { private final Map<String, String> cache = new HashMap<>(); public synchronized String get(String key) { return cache.get(key); } public synchronized void update(String key, String value) { cache.put(key, value); } }synchronized 是互斥锁,同一时刻只有一个线程能进临界区。读操作之间明明互不影响,却被强行串行化。高峰期一千个读请求进来,理论上是同一个 HashMap 的 get,谁先谁后根本无所谓,但加了 synchronized 之后它们只能一个一个过。那感觉就像一条八车道的高速路,硬是被管理员设成了一个单车道收费站。
换成 ReentrantLock 也一样,它同样是互斥锁,只是功能更丰富。只要是互斥锁,读操作之间的并行性就被牺牲掉了。
后来改成 ReentrantReadWriteLock,代码几乎是平移:
public class ConfigCache { private final Map<String, String> cache = new HashMap<>(); private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock(); public String get(String key) { rwLock.readLock().lock(); try { return cache.get(key); } finally { rwLock.readLock().unlock(); } } public void update(String key, String value) { rwLock.writeLock().lock(); try { cache.put(key, value); } finally { rwLock.writeLock().unlock(); } } }改完之后读请求全部并行,只有写操作进来时才会短暂挡住新的读请求。效果立竿见影。
1.2 读锁是共享门禁,写锁是独家通行证
ReentrantReadWriteLock 的精髓,是把"读"和"写"分成了两套锁机制。
读锁是共享锁。共享的意思是:只要当前没有线程持有写锁,任意多个线程可以同时持有读锁。这很好理解,大家同时读一份数据,谁也不会改坏它,自然不需要互相排斥。
写锁是排他锁。一旦有线程持有写锁,其他任何线程——不管是想读还是想写——都必须阻塞等待。因为写操作可能改变数据状态,如果允许读线程在写的同时去读,读到的可能是一半新一半旧的脏数据。
这个设计用一个生活化的类比来解释最清楚:图书馆的阅览室。读锁就是进阅览室看书的资格,可以很多人同时在屋里看书,前提是屋里没有人进行整理搬书的工作。写锁就是整理书架的工作证,一旦有人开始搬书,管理员会把其他读者请出去,直到整理完毕。
锁拆成两个之后,并发度就完全不一样了。读多写少的场景下,绝大部分请求走的都是读锁,彼此之间零竞争。只有偶尔的写操作,才会让并发短暂降级为串行。
2. 谁被锁、谁放行:几种线程组合的完整行为矩阵
标题问"把谁锁起来",这一节就用最直白的方式把答案列出来。我画过一张表,把"谁持有锁"和"谁想获取锁"的所有组合都列上,这张表后来帮我省了大量排查时间。
2.1 读锁之间:你可以进,他也可以进
线程 A 持有读锁,线程 B 尝试获取读锁。
结果是:B 直接成功,不需要等待。
这是读写锁相比普通互斥锁最大的优势。读操作是幂等的、无副作用的,多个线程同时读不会造成数据竞争。所以读锁的获取条件是"当前没有写锁被持有",而不是"当前没有人持有锁"。
这里有个细节容易引起误解:ReentrantReadWriteLock 的读锁虽然写着"共享",但它在 JVM 内部的实现里,并不是让每个线程各拿一把锁,而是用一个计数器统计当前有多少线程持有读锁。每成功获取一次读锁,计数器加一;释放一次,减一。只有当计数器清零时,写锁才有机会被获取。
2.2 写锁面前:所有线程一视同仁,全部排队
线程 A 持有写锁,线程 B 尝试获取读锁或者写锁。
结果是:B 无条件阻塞,不管它想读还是想写。
这一点非常关键。很多人以为"读锁和写锁互斥,那我持有了写锁,其他线程想写会被挡住,但想读应该可以吧"。不对。写锁是排他的,持有写锁期间,读锁获取也会被卡住。原因很简单:一个线程正在修改数据,另一个线程马上跑来读,读到的可能是一份被修改到一半的数据。Java 内存模型里,这种读写交叉执行没有任何保证可言。
所以写锁的行为用一句话总结:写锁一被持有,整扇门都关了,谁都别想进。
2.3 锁行为速查表:一张表看清六种组合
把这些组合整理成表格,是最直观的参考:
| 当前持有 | 尝试获取 | 同线程是否允许 | 其他线程是否允许 |
|---|---|---|---|
| 无 | 读锁 | 允许 | 允许 |
| 无 | 写锁 | 允许 | 允许 |
| 读锁 | 读锁 | 允许(可重入) | 允许(共享) |
| 读锁 | 写锁 | 不允许(必死锁) | 不允许(阻塞) |
| 写锁 | 写锁 | 允许(可重入) | 不允许(阻塞) |
| 写锁 | 读锁 | 允许(锁降级) | 不允许(阻塞) |
这张表里最值得研究的是最后两行。写锁可以重入,这个好理解,和 ReentrantLock 一样。但是"写锁转读锁"为什么被允许,而且还要单独起个名字叫"锁降级"?以及"读锁转写锁"为什么是绝对的死局?这两点放到下一节详细讲。
额外提一句:ReentrantReadWriteLock 的写锁支持 Condition(通过writeLock().newCondition()获取),读锁不支持 Condition。原因是条件等待需要一个排他性的所有权语义,读锁是共享锁,多个线程同时持有,没法确定由谁来响应 signal。你要是调用readLock().newCondition(),会直接抛 UnsupportedOperationException。
3. 锁降级是正规操作,锁升级是死路一条
3.1 为什么需要锁降级:缓存更新的标准姿势
锁降级的官方定义是:在持有写锁的情况下,获取读锁,然后释放写锁。转换之后,当前线程仍然持有读锁,可以继续以只读身份访问共享数据。
这个操作最常见的应用场景是懒加载缓存更新。考虑一个场景:缓存里没有数据,线程需要从远程加载并写回缓存,然后立刻使用这份数据对外提供服务。
如果先释放写锁再获取读锁,中间会有一个空窗期。在这个空窗期里,其他线程可能也发现缓存失效,各自去加载一份数据,导致同一份缓存被重复加载多次,严重时还会造成缓存击穿。
而锁降级可以把这个空窗期彻底消灭:当前线程持有写锁完成数据加载,然后不释放写锁,先去获取读锁,再释放写锁。这样从写锁到读锁的切换是原子的,其他线程根本没有机会在中间插进来重复加载数据。
3.2 降级操作的完整代码模板
下面这段代码是 Java 官方文档里的标准范式,我实际用下来非常可靠,直接抄作业就行:
class CachedData { Object data; volatile boolean cacheValid; final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock(); void processCachedData() { rwl.readLock().lock(); if (!cacheValid) { // 必须先释放读锁,才能获取写锁 rwl.readLock().unlock(); rwl.writeLock().lock(); try { // 二次检查:可能已经有其他线程先更新过了 if (!cacheValid) { data = loadFromRemote(); cacheValid = true; } // 降级:获取读锁后再释放写锁 rwl.readLock().lock(); } finally { rwl.writeLock().unlock(); } // 此时仍然持有读锁 } try { use(data); } finally { rwl.readLock().unlock(); } } }这里有几个容易写错的地方,我逐一提醒:
第一,从读锁升级到写锁之前,必须先把读锁释放。这是整个流程里最容易反直觉的一步。很多第一次接触的人会直接写"我拿着读锁,再拿写锁不行吗",不行,下一节会讲为什么。
第二,拿到写锁之后,必须二次检查 cacheValid。因为在你释放读锁、等待写锁的这段时间里,可能已经有另一个线程抢先完成了数据加载。如果不二次检查,你加载的新数据会覆盖对方加载的更新数据。
第三,降级时的顺序不能反:先readLock().lock(),再writeLock().unlock()。这样能保证从写锁释放的那一刻起,当前线程依然持有读锁,其他写线程进不来,数据的一致性得以延续。
3.3 锁升级死锁的现场还原
读锁升级为写锁,是 ReentrantReadWriteLock 用法里最经典的死局。我们还原一下现场:
假设线程 A 和线程 B 同时持有了读锁(这是合法的,读锁之间共享)。现在线程 A 想写数据,尝试获取写锁。它发现自己需要等其他所有读锁释放。可是线程 B 还在持有读锁,线程 B 也想写数据,尝试获取写锁,它也需要等线程 A 释放读锁。
两个线程互相等着对方先释放,谁都等不到,死锁。
代码写出来是这个样子:
// 不要模仿,必然死锁 public void updateWithWrongLock() { rwLock.readLock().lock(); try { if (!needUpdate()) { return; } // 这里会永远等待下去 rwLock.writeLock().lock(); rwLock.writeLock().unlock(); } finally { rwLock.readLock().unlock(); } }即使是单线程场景,自己持有读锁再去获取写锁,也同样会死锁。因为 ReentrantReadWriteLock 里,同一个线程先获取读锁再获取写锁时,写锁的获取条件包括"所有读锁都必须释放",而它自己持有的读锁还没释放。自己等自己,必然卡死。
记住一句话就行:ReadWriteLock 只能降级,不能升级。降级是同一个线程从写锁平滑过渡到读锁,升级是同一线程从读锁强行跳到写锁——前者受支持,后者直接死锁。
4. 公平与非公平模式:写线程饿死问题怎么破
4.1 非公平模式的"读者插队"机制
ReentrantReadWriteLock 默认是非公平的。非公平意味着:当锁处于可获取状态时,新来的线程可以直接抢占,不需要进入等待队列排队。
对于写锁来说,这个行为和在 ReentrantLock 里没有本质区别。问题出在读锁上。
考虑这样一个场景:线程 W 正在等待写锁,它已经在队列里排了好一阵子。这时来了线程 R1 尝试获取读锁。在非公平模式下,R1 的获取条件是"当前没有写锁被持有",它一看写锁没人持有,直接就进去了。接着 R2、R3、R4 也一路畅通地进去了。每个读线程都是瞬间完成,写完又不断有新读线程进来,而 W 只能一直等在队列里。
这就是经典的写线程饥饿问题。读操作源源不断,写操作永远排不上号,长时间无法更新数据。
读锁插队这件事,在底层实现里有很刻意的设计痕迹。ReentrantReadWriteLock 的读锁获取逻辑里专门做了一个判断:虽然非公平模式下允许插队,但如果队列头部是写线程在等待,系统也会强制读线程让路。这个保护逻辑叫"写线程优先"(writer preference),但它在面对源源不断的新读线程时依然不够,因为每个新来的读线程都有资格"插队"到写线程之前。
4.2 公平锁的正确打开方式
解决写饥饿最直接的办法,就是构造公平锁:
// 公平模式下,读锁和写锁都按照请求顺序分配 ReentrantReadWriteLock fairLock = new ReentrantReadWriteLock(true);公平模式下,锁会按照线程请求的顺序分配,先来后到,不允许插队。读线程不能再无限挤占写线程的位置,写线程最终一定能在队列里轮到它。
代价是整体吞吐量会有所下降。公平锁需要维护一个 FIFO 队列,每次获取和释放锁都要做更复杂的队列操作,竞争激烈时吞吐量可能比非公平模式下降一成到三成。对于绝大多数业务场景,这点性能损耗完全值得,因为"写线程永远无法更新数据"带来的故障成本远高于这点性能开销。
我个人在配置缓存、限流配置这类场景里,一律使用公平锁。这些场景的写操作虽然频率低,但每次写都很重要(比如紧急下架一个配置),不能容忍它被读请求无限期饿死。
4.3 比公平模式更重要的事:缩短读锁持有时间
公平锁解决了写饥饿的"顺序"问题,但有一个比公平非公平更本质的问题被很多人忽略:读锁的持有时间。
哪怕用了公平锁,如果每个读线程持有读锁的时间是毫秒级甚至更久,写线程依然要等很久。公平锁保证的是"你总会等到",但不保证"你不需要等太久"。
我曾经犯过一个错误,在一次读锁里调用了远程接口,单个读请求耗时 800 毫秒。结果就是每次写操作至少排队 800 毫秒,而读操作的并发优势也全被拖垮了。后来我强制规定:读锁保护的临界区里,绝对不允许出现网络请求、数据库查询、循环计算等耗时操作,只允许内存读、集合遍历这类微秒级操作。
这个原则比公平模式的选择重要得多。锁的持有时间越短,锁竞争越少,不管是公平还是非公平,系统整体表现都不会差。
5. 我在实战中踩过的三个坑
5.1 读锁里做了网络请求,写线程集体饿死
前面提过一次,这里展开说说。
当时一个业务方反馈,配置更新接口偶尔会超时。追查下来发现,配置更新的写锁获取平均等待时间居然高达 2 秒。而写锁等待时间长只有一个原因:读锁被持续持有,而且持有了很久。
再看代码,发现有人封装了一个"带缓存的读取方法",在readLock().lock()和unlock()之间调用了一个 HTTP 客户端去拉取远程数据。虽然缓存的初衷是避免重复拉取,但缓存 miss 的那一瞬间,请求会带着读锁去访问远程接口。远程接口一次耗时 1-3 秒不等,这段时间里所有写操作全被卡死。
排查链路是这样的:先在 JFR(Java Flight Recorder)里看锁等待时间,发现有大量线程park在 writeLock 上;再用 jstack 抓线程栈,看到持有读锁的线程栈里赫然是HttpClient.send();最后一看代码,读锁包住了网络调用。
修复方式也很直接:在进入读锁之前先判断缓存是否存在,缓存 miss 的先释放读锁,再走写锁加载的降级流程。读锁里只留下map.get()这一个操作。
5.2 锁降级时释放顺序颠倒了,结果还能跑
第二个坑是和锁可见性有关。
我最初写降级代码时,把释放顺序反了:先释放写锁,再获取读锁。
// 错误写法,存在可见性空窗期 rwl.writeLock().lock(); try { data = loadData(); cacheValid = true; rwl.writeLock().unlock(); // 先释放写锁 rwl.readLock().lock(); // 再获取读锁 } catch (...) { // 异常处理 }这段代码运行起来不会报错,在单测里也正常。但它有两个问题:
第一,释放写锁和获取读锁之间,有一段代码空窗期。虽然时间极短,但理论上另一个线程可能趁机获取了写锁,此时当前线程再去获取读锁,行为就不确定了。
第二,如果loadData()抛异常,writeLock().unlock()不会执行,写锁就泄漏了。
正确顺序应该是在写锁保护的临界区内、释放写锁之前,先获取读锁。这样从写锁释放的那一瞬间开始,当前线程已经稳稳握住了读锁,中间不存在任何空窗期。同时要用 finally 保证写锁一定会被释放。DCL 那套"二次检查"的思路,在这里同样适用。
现在我会在写锁临界区内先readLock().lock(),再writeLock().unlock(),并且两个锁的释放都放在各自的 finally 块里。
5.3 一把大锁锁整个缓存:粒度控制的取舍
第三个坑和粒度有关。
我的配置中心一开始只有一张表,用一把 ReentrantReadWriteLock 锁整个缓存。后来配置项多了,分成了 N 个 namespace,所有 namespace 仍然共用一把锁。结果就是:A 配置的写操作会把 B 配置的读锁也堵住,A 配置的读操作也会阻塞 B 配置的写操作。名义上是读写锁,实际上因为共享同一把锁,不同业务之间完全互相干扰。
解决思路是分片或者说分组锁:将配置按 namespace 分成多个独立的 cache 对象,每个对象持有自己的 ReentrantReadWriteLock。这样 A 配置的读写完全不会影响 B 配置。
不过粒度也不是越细越好。锁拆得过细,锁对象数量膨胀,内存开销和锁竞争调度的复杂度都会上升,而且跨分片的一致性事务问题会变得更难处理。对于配置缓存这种天然分级的业务,按 namespace 这个粒度刚好合适。
6. 性能实测数据:什么时候它赢,什么时候它反而拖后腿
6.1 测试环境与场景设计
只讲理论不拿出数据总觉得不踏实。我自己做过一组对比测试,环境是普通 8 核机器,JDK 17,模拟一个读多写少的典型业务:
- 100 个读线程,每个线程循环从缓存中读取一个值 20 万次
- 10 个写线程,每个线程循环更新缓存 1 万次
- 读写比例大约 95:5
- 分别使用 synchronized、ReentrantLock、ReentrantReadWriteLock(公平和非公平各测一次)
- 缓存结构就是 HashMap 包一层锁,不涉及其他 IO
6.2 三组锁的对比结果
实测下来的数据大概是这样的(单位是完成全部任务的总耗时,越低越好):
| 锁方案 | 总耗时 | 相对性能 |
|---|---|---|
| synchronized | 约 980ms | 基准 |
| ReentrantLock(非公平) | 约 940ms | 约 1.04 倍 |
| ReentrantReadWriteLock(非公平) | 约 640ms | 约 1.53 倍 |
| ReentrantReadWriteLock(公平) | 约 760ms | 约 1.29 倍 |
95:5 的读写比例下,非公平的读写锁比 synchronized 快了大约一半。公平模式因为排队开销,会比非公平模式慢一些,但依然明显优于互斥锁。
这个优势在读写比例继续拉大时会更加明显。如果把写线程降到 2 个,读写比例变成 99:1,读写锁的优势能到 2 倍以上。反过来,如果把读写比例改成 50:50,读写锁的优势几乎消失,某些场景下甚至比 synchronized 还慢。因为读锁获取和释放本身的 CAS 操作就有开销,当读锁之间也频繁竞争时,这些开销就成了纯负担。
6.3 结论:读写锁不是万能钥匙
什么时候适合用 ReentrantReadWriteLock,我总结成三条:
第一,读远多于写。如果写操作占比超过三成,互斥锁可能更合适。 第二,临界区操作耗时极短(微秒级)。一旦临界区里有耗时操作,锁的类型反而没那么重要,持有时间才是瓶颈。 第三,读线程数量多,且它们之间确实需要并行。
还有一类场景要明确避开:如果你的"读"实际上只是get一个字段,动作快得连 CAS 竞争都产生不了,那普通 ConcurrentHashMap 或者直接 volatile 变量可能更合适。读写锁带来的收益在超短临界区里很小,却要承担更复杂的锁语义和更高的死锁风险,属于杀鸡用了牛刀。
我在实际维护配置中心之后,对锁的理解慢慢变了。一开始以为"并发问题 = 加锁",后来发现"加什么锁、锁多久、锁多宽"才是真正值得琢磨的问题。ReentrantReadWriteLock 只是工具箱里的一把好用的钥匙,但用之前一定要搞清楚:你要锁的,到底是谁。