之前我有一个内部系统的配置中心读请求每秒几千次配置更新却好几分钟才一次。最初图省事我直接在 get 方法上加了 synchronized结果每次配置一更新所有读请求全被堵在门外高峰期接口响应时间直接飙到几百毫秒。后来换成了 ReentrantReadWriteLock读路径恢复到了微秒级。但真正让我意识到这个锁不简单的是后面排查问题的时候——读锁和写锁到底把谁锁住、把谁放进去了这里面门道远比我想象的多。这篇我就从自己实际使用和踩坑的经历出发把 ReentrantReadWriteLock 的行为完整拆一遍它解决了什么、读锁和写锁各自的拦截规则、锁降级为什么是标准操作而锁升级必然死锁、公平模式怎么选、以及在实战中那些文档里不会明说的细节。1. 先搞明白这个锁到底把临界区拆成了什么1.1 从一次缓存性能事故说起大部分团队第一次接触到 ReentrantReadWriteLock场景几乎都是同一个读多写少。配置缓存、路由表、黑白名单、字典数据都是这种特征。我那个配置中心就是一个典型。老代码长这样public class ConfigCache { private final MapString, 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 MapString, 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 秒不等这段时间里所有写操作全被卡死。排查链路是这样的先在 JFRJava 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 包一层锁不涉及其他 IO6.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 只是工具箱里的一把好用的钥匙但用之前一定要搞清楚你要锁的到底是谁。
阅读完成 · 觉得有帮助?