
写这系列教程的时候我一直想找一种看起来简单、用起来顺手、但深挖全是坑的Java并发工具来细讲。ReentrantReadWriteLock恰好就是这样的存在很多初级开发第一次看到它觉得不就是把锁分成了读和写两种吗等真正在项目里落地才发现锁降级、锁升级、公平性、饥饿问题每一个都能把人折腾到怀疑人生。这篇我用一篇完整的内容把它的设计原理、代码写法、面试考点、常见坑全部串一遍希望能帮你在写并发代码的时候少走弯路。无论你是正在准备Java面试还是已经在公司里负责高并发模块的开发和维护只要你需要处理读多写少这类共享资源访问问题ReentrantReadWriteLock都是你绕不开的基础知识点。它和synchronized、ReentrantLock最大的区别在于它不再把对共享资源的每一次访问都视为互斥而是按照操作类型将锁拆成读锁和写锁让多个读线程可以同时进入临界区从而大幅提升读密集场景下的吞吐量。我一直强调一个观点Java并发不是记住多少个类名而是理解锁在竞争条件下到底做了什么决策。这篇文章就从ReadWriteLock的最核心设计出发配合可直接上手的代码、实际生产环境中常见的诡异问题以及我在写并发组件时总结的排错方法帮你一次性把这门课吃透。1. 从互斥锁说起为什么读多写少的场景不能只靠synchronized1.1 互斥锁的代价到底高在哪很多初学者会问我直接用synchronized或者ReentrantLock包住整个方法不就行了为什么还要整出个读写锁这个问题问得非常好因为它直接触及了并发性能优化的核心。互斥锁Mutex模型的核心是同一时刻只允许一个线程进入临界区。不管是读操作还是写操作通通排队。这在写操作极少、读操作极多的场景下就会造成严重的性能浪费。想象一下一个内存缓存十个线程同时来查数据本来完全可以并行查询但被synchronized挡住变成了一个查完另一个再查。每个读操作本身不修改任何共享状态理论上它们是绝对安全的却被锁强行串行化。我在之前做的一个商品详情聚合服务里就遇到了类似问题热点商品的详情数据被Redis挡住之后还有大量请求会回源到本地缓存而本地缓存使用的是ConcurrentHashMap加synchronized保护结果压测时发现读操作的吞吐量始终上不去。后来换成ReentrantReadWriteLock之后读操作不再互相阻塞性能数据几乎是直线提升。这里要理清一个概念互斥不是错的互斥是保证安全的底线但在读多写少的场景下互斥是杀鸡用牛刀——你用一把独占锁保护了那些根本不需要独占的操作。读操作之间天然兼容真正需要独占的只有写操作。读写锁正是抓住了这个差异。1.2 读写锁的核心思想共享锁与独占锁的组合ReentrantReadWriteLock维护了两把锁一把是读锁共享锁一把是写锁独占锁。读锁之间不互斥读锁与写锁互斥写锁之间互斥。这个语义用矩阵表示可能是最清楚的当前线程持有锁请求读锁请求写锁未持有锁允许允许读锁允许阻塞写锁允许阻塞读锁写锁锁降级允许阻塞注意上看表格里最后一行的特例写锁持有期间可以继续获取读锁这就是锁降级的基础。为什么允许这个因为写锁是独占锁持有写锁时没有任何别的线程在读或写所以此时获取读锁是绝对安全的。反过来如果你持有读锁再去获取写锁就存在死锁风险多个线程都持有读锁都在等写锁而写锁要等所有读锁释放谁也等不到谁。这既是设计上的取舍也是大家在写代码时必须刻在脑子里的红线锁升级不允许锁降级允许。后面我会专门演示锁降级的标准写法这个是面试高频考点也是实际项目中保证数据可见性的关键技巧。2. ReentrantReadWriteLock的底层设计AQS与状态位拆分2.1 一个整型字段如何同时表达两种锁状态在看ReentrantReadWriteLock源码的时候很多人会被内部实现绕晕因为它并不是像表面看起来那样维护了读锁对象和写锁对象两个互不相关的状态。实际上读写锁内部是通过一个AQSAbstractQueuedSynchronizer同步器来管理的AQS里有一个核心字段state类型是int。正常情况下AQS的state用来表示锁的占有次数比如ReentrantLock就是state从0变1再变2表示重入次数。到了ReentrantReadWriteLock这里一个int被一分为二高16位表示读锁的占有数量低16位表示写锁的重入次数。这样设计的好处非常明显一次CAS操作就能同时修改读锁和写锁的状态不需要引入两个字段做复杂的复合原子操作缺点则是锁的数量有上限读锁最多65535个写锁也是65535个。写锁获取时会判断state的低16位是否为0如果为0且高16位也是0或持有者是当前线程则允许获取。读锁获取时会对state的高16位加1同时影响低16位的写锁判断。这些位运算细节对日常开发不一定用得上但对排查一些诡异的并发问题非常有帮助尤其是你看到线程dump里出现ReadLock和WriteLock相互等待的时候。2.2 公平模式与非公平模式的差异ReentrantReadWriteLock的构造函数支持传入一个fairness参数默认false也就是非公平模式。非公平模式意味着后来的读线程可能插队到等待的写线程之前。这可能造成写线程饥饿问题如果读线程源源不断地进来写线程会在队列里一直等不到锁。很多人写代码时图省事直接用new ReentrantReadWriteLock()结果在高并发读场景下发现写操作延迟极高甚至出现写超时。这时候不要急着加超时时间先想一想你的锁是不是非公平的读线程是不是一直在插队把fairness参数改为true虽然会牺牲一部分读吞吐量但保证了写线程最终一定能拿到锁。不过公平模式也不是银弹。公平锁会减少吞吐量因为每个线程都必须走排队流程不能直接抢锁。如果业务场景对写操作延迟不敏感读操作又特别多那么非公平锁配合写锁优先的变通方案会更合适。比如你可以在写锁获取前短暂阻塞读入口或者容忍一定程度的饥饿只在检测到写线程等待时暂停读操作。这属于工程权衡没有绝对正确的配置。2.3 可重入性如何运作ReentrantReadWriteLock的可重入性同样值得留意。写锁是重入的同一个线程可以多次获取写锁每获取一次state低16位加1释放时减1直到减为0才算真正释放。读锁也是重入的同一个线程可以多次获取读锁但AQS的state高16位记录的是总获取次数而不是持有者计数为了支持同一线程重复获取读锁内部实现里维护了ThreadLocal来记录每个线程的重入次数。这里有一个容易被忽略的点读锁的重入并不是你释放一次state就减一这么简单。你必须保证lock和unlock的次数严格配对否则state高16位的计数会残留在AQS中导致读锁永远无法完全释放进而阻塞所有写线程。真实项目中我见过不少因为异常路径上忘了unlock导致读锁泄漏的问题表现就是运行一段时间后写操作突然全部阻塞。这种问题非常难排查因为它不是必现的而是累积到一定程度才爆发。3. 手把手实操从零写一个带读写锁的本地缓存3.1 最基础的读写锁用法保护HashMap接下来进入实战环节。我会用最典型的一个例子——本地缓存带你从零把ReentrantReadWriteLock用起来。假设我们有一个共享的HashMap读操作根据key获取value写操作往map里放数据。synchronized的写法我就不演示了直接看读写锁版本public class SimpleCacheK, V { private final MapK, V map new HashMap(); private final ReentrantReadWriteLock rwLock new ReentrantReadWriteLock(); public V get(K key) { rwLock.readLock().lock(); try { return map.get(key); } finally { rwLock.readLock().unlock(); } } public void put(K key, V value) { rwLock.writeLock().lock(); try { map.put(key, value); } finally { rwLock.writeLock().unlock(); } } }这里有几个关键写法值得强调第一lock之后必须紧跟try-finally在finally里释放锁。这不是可选项而是唯一正确姿势。很多新手在写业务代码时喜欢提前return同时把unlock放在方法末尾看起来好像能正常执行但一旦中间抛异常unlock根本不会执行锁就再也释放不了了。第二读锁和写锁操作的对象最好是完全隔离的或者你确保数据已处于安全发布状态。读写锁保护的是引用级别的安全也就是只要线程拿到map对象读取内容是安全的但它不保证读到的是最新数据。第二次获取读锁时由于写锁已经释放另一个线程可能已经再次修改了map。所以读写锁更强调临界区的完整性和可见性而不是业务数据绝对一致。3.2 锁降级的标准写法与真正用途接下来是我认为ReentrantReadWriteLock中最有含金量的部分——锁降级。所谓锁降级就是先持有写锁然后获取读锁最后释放写锁的过程。这样操作之后线程仍然持有读锁但它读到的数据是自己在写锁临界区里刚修改过的。这个手法主要用于读取-修改-写入-再读取这类需要保证数据一致性的场景。一个最典型的例子是缓存中获取到过期数据后重新加载public V getWithReload(K key) { rwLock.readLock().lock(); try { V value cache.get(key); if (isFresh(value)) { return value; } } finally { rwLock.readLock().unlock(); } // 走到这里说明缓存过期了需要重新加载 rwLock.writeLock().lock(); try { // 此处再次检查防止多个线程同时穿透进来重复加载 V value cache.get(key); if (isFresh(value)) { return value; } V newValue loadFromDb(key); cache.put(key, newValue); // 锁降级在写锁释放之前获取读锁 rwLock.readLock().lock(); } finally { rwLock.writeLock().unlock(); } try { // 现在持有读锁拿到的是最新写入的数据 return cache.get(key); } finally { rwLock.readLock().unlock(); } }这个例子实操中非常常见它在并发缓存界被称为check-then-act模式但加上锁降级之后就变成了双检锁加强版。我特别强调一下锁降级的目的如果没有降级你在写锁释放之后再重新获取读锁中间可能插入别的写线程读到的就不是你刚写入的值。有了降级写锁释放那个瞬间你已经持有了读锁其他写线程无法在gap期间篡改数据因此你读到的一定是你自己刚写入的最新值。可能有人会说这种场景用synchronized包住整个方法不也行吗确实行但代价就是所有读线程都会在缓存命中的情况下互相等待性能差很多。读写锁加降级本质上是把全互斥变成了局部串行精准控制临界区范围。3.3 为什么锁升级是禁区和锁降级相对的是锁升级线程先持有读锁再尝试获取写锁。这个操作在ReentrantReadWriteLock里是不支持的并且极端危险因为很容易造成死锁。假设线程A和线程B都持有读锁然后它们同时尝试获取写锁。写锁要求没有任何线程持有读锁才能获取成功可此时A和B相互占着对方的读锁释放途径A想获取写锁但B还持有读锁不释放B想获取写锁但A还持有读锁不释放。两边都在等待对方释放读锁于是永久阻塞。在很多项目里我看到过类似这样的错误代码rwLock.readLock().lock(); try { if (needUpdate) { // 千万别这样写会死锁 rwLock.writeLock().lock(); try { map.put(key, value); } finally { rwLock.writeLock().unlock(); } } } finally { rwLock.readLock().unlock(); }在当前版本的JDK中ReentrantReadWriteLock本身并不直接抛异常阻止你这么做但它会默默把你阻塞在writeLock.lock()这一行上。整个过程没有报错也没有日志线程dump出来才能看到阻塞原因。真的是那种运行得好好的突然某个线程就不动了的经典问题。正确的做法是如果你有读着读着发现需要写的需求应该先释放读锁再获取写锁然后在写锁下二次检查数据是否发生变化。这一段逻辑跟前面讲的缓存重载是统一的。只记住一句话就好读锁只能降级为写锁的下游永远不能升级成写锁的上游。4. 高频坑位盘点饥饿、泄漏与线程dump分析4.1 写线程饥饿读锁太多导致写操作永远等不到在生产环境里读写锁最常遇到的第一个坑是写线程饥饿。前面提到非公平模式下读线程可以反复插队当读流量极大、读临界区时间又长的时候写线程很可能长时间获取不到写锁。这会让系统出现一种特别诡异的表象数据明明该更新了却迟迟没有更新等到读线程稍微少一点写线程才终于拿到锁完成写入。针对这个问题我建议在代码里加入写锁获取的等待时间监控。ReentrantReadWriteLock提供了getQueueLength()、getReadLockCount()、getWriteHoldCount()这些方法可以在系统内部定时采样观察读锁计数是否长时间不下落、写等待队列长度是否持续增长。如果发现写线程等待时间超过了业务容忍阈值就要考虑调整为公平模式或者在代码层面强制写优先让新的读线程在检测到写线程等待时直接让出CPU。还有一个工程层面的技巧减小读锁临界区的粒度。很多读线程之所以长时间持有读锁是因为它们把耗时的业务操作全放在了临界区内。读写锁并不负责你的临界区应该多长只有你能控制。我见过有人在读锁里做远程调用一个读操作要50毫秒几十个读线程排着队写线程直接饿死。这种问题不是锁的错是你代码里临界区设计的问题。记住锁只保护共享变量的临时一致性不保护你那昂贵的业务计算。能挪出去的代码尽量不要放在锁里。4.2 读锁泄漏异常的路径上忘了unlock读锁泄漏这个问题比饥饿更隐蔽。由于读锁在AQS的state里只是计数累加如果某个线程获取了读锁但没释放state高16位的值就会一直比实际活跃读线程数量多。等到所有读线程结束、想获取写锁时写锁会一直判断有线程还在持有读锁从而永远拿不到锁。我记得有一次排查线上故障服务运行了两天之后突然所有写操作全部超时。翻线程dump发现好几十个线程都卡在writeLock.lock()上而持有读锁的线程早已执行完毕。最后定位下来的原因非常气人某个方法在try块里先lock了读锁然后在catch分支里直接returnfinally里unlock的代码写在了try的最后一行——这种情况下return走catchfinally确实会被执行理论上不会泄漏但那个方法有多个return分支其中一个分支的return语句没有经过finally实际上是编译器的异常表问题导致漏掉了unlock。所以我把话说得再重一点不要在临界区里写复杂的条件分支尽量保证unlock在finally块里执行。如果你嫌弃这种方式啰嗦可以考虑使用try-with-resources风格的封装但无论用哪种方式原则只有一个——lock和unlock必须成对出现而且要覆盖所有能离开作用域的路径。我在团队里定的规矩是凡是涉及锁的代码必须有代码评审评审重点就是lock/unlock配对。4.3 线程dump实操如何定位死锁与阻塞当你怀疑读写锁出了问题第一件事不是改代码而是抓现场。用jstack 进程号可以直接输出线程dump重点关注两类信息线程状态是WAITING还是BLOCKED以及锁对象的等待链路。ReentrantReadWriteLock发生死锁时典型的dump信息长这样pool-1-thread-1 waiting to lock 0x000000074c2f0e20 (a java.util.concurrent.locks.ReentrantReadWriteLock$WriteLock) waiting for 0x000000074c2f0dc8 (a java.util.concurrent.locks.ReentrantReadWriteLock$NonfairSync) held by pool-1-thread-2 pool-1-thread-2 waiting to lock 0x000000074c2f0dc8 (a java.util.concurrent.locks.ReentrantReadWriteLock$NonfairSync) waiting for 0x000000074c2f0e20 (a java.util.concurrent.locks.ReentrantReadWriteLock$WriteLock) held by pool-1-thread-1出现这种相互持有的信息基本上可以断定是死锁。你可以用jstack的-l参数打印额外的锁信息也可以用jcmd这种更现代的工具做线程分析。我个人习惯是一旦怀疑锁问题先连续抓三次dump间隔10秒左右因为单次dump只能反映瞬时状态连续抓取可以排除某个线程刚好在锁等待中的正常场景。4.4 与StampedLock的取舍提到ReentrantReadWriteLock很多人会顺带问一句Java 8引入的StampedLock不是更厉害吗确实StampedLock提供了乐观读tryOptimisticRead在读多写少的极端场景下性能更好。但它的代价是不支持重入、不支持Condition而且使用门槛更高需要你显式校验版本号stamp并处理读锁升级。我的建议是如果你的业务逻辑相对传统团队并发经验不是特别深ReentrantReadWriteLock仍然是更稳妥的选择。StampedLock更像是给那些确定读操作是绝对热点、且能接受复杂代码的场景准备的。举个具体例子如果读操作只是读取一个long型字段用volatile就够了如果读操作要读取一个不可变对象用StampedLock的乐观读可以做到无锁化但如果读操作内部会访问多个字段而且这些字段之间存在一致性约束那么乐观读带来的正确性风险就会直线上升反而不如老老实实用ReentrantReadWriteLock来得安全。5. 面试题视角ReentrantReadWriteLock怎么答才加分5.1 高频面试题与作答思路每次讲完锁都会有人问我类似的问题面试时如果被问ReentrantReadWriteLock到底该答到什么程度我的答案很简单不要只背读读共享、写写互斥、读写互斥这三句话一定要展现出你对设计细节的理解。刚开始答的时候可以快速给出定义ReentrantReadWriteLock是AQS框架下的一种读写分离锁读锁是共享锁写锁是独占锁读锁之间不互斥读写、写写之间互斥。然后顺势抛出锁降级的概念并举一个缓存重载的例子。能把这个例子讲清楚面试官就知道你不只是看过文档。接下来可以聊公平性的选择说明默认非公平会带来写饥饿问题以及为什么公平模式下吞吐量下降。最后可以提到state高低16位的设计说明作者为什么用一个字段而不是两个字段为了保证读锁和写锁状态修改的原子性同时把重入和锁状态整合在一个CAS操作里。这一层深度已经超过大多数候选人的水平如果能表达得流畅非常加分。下面我整理了一些面试中常见的问题和参考回答方向常见问题回答关键点ReentrantReadWriteLock和ReentrantLock有什么区别读写分离、读共享、写独占、锁降级为什么读锁不能升级为写锁多个读线程同时持有读锁会导致相互等待死锁写锁降级读锁有什么意义保证自己在写锁释放后读到最新修改值避免gap期写入覆盖默认是公平还是非公平有什么影响非公平吞吐量高但写线程可能饥饿读锁是可重入的吗是通过ThreadLocal记录重入次数多个读线程同时读一个可变对象是否安全读写锁保证临界区互斥但读锁不保证业务一致性需要结合业务同步策略和StampedLock比怎么选读多写少极致性能用StampedLock但要注意乐观读校验和不可重入限制5.2 从进程内锁到分布式锁的思维升华很多同学在面试或项目里还会遇到一个进阶问题ReentrantReadWriteLock能不能用在分布式系统中答案是不能。这是典型的JVM进程内锁它只对同一个进程内的线程生效。微服务架构下多个服务实例访问同一个共享资源比如数据库记录、Redis里的某个key单靠JVM里的锁根本挡不住其他服务实例的并发访问。遇到这种需求需要用到分布式锁常见方案有Redis的SETNX命令配合过期时间或者基于ZooKeeper的临时顺序节点实现。分布式锁同样有共享锁和独占锁的概念Redis那边可以用Redisson提供的RReadWriteLockZooKeeper也可以自定义读锁和写锁的节点协调逻辑。我在这里想强调的并不是具体分布式锁API而是思维方式的迁移无论是进程内锁还是分布式锁共享锁和独占锁的语义是通用的。你理解了ReadWriteLock为什么允许读读共享就能理解分布式场景下为什么多个服务只在写操作时抢占锁你理解了锁降级保证数据一致性的原理就能更敏锐地发现分布式环境下重复加载数据这种问题带来的数据覆盖风险。并发问题的分析能力从来不局限于某个具体工具。5.3 实际生产中的设计建议最后分享几条我在实际生产环境里总结的经验供你们参考第一能不用锁就不用锁。如果数据是只读的优先考虑final不可变对象或者volatile如果数据结构本身支持并发操作比如ConcurrentHashMap直接用它的并发容器方法至少比手动加锁要安全得多。ReentrantReadWriteLock适合的场景是有一个共享可变对象读多写少而且对数据一致性要求高到必须用锁来约束。第二如果确定要用读写锁把读锁持有的时间压到最短。读操作只做必要的变量访问和判断不要在临界区里做IO、网络调用、复杂计算。你每缩短一毫秒的临界区时间写线程的等待时间就能减少一大截。第三重视监控。代码上线前至少加入锁等待时间和锁持有时间的统计日志。很多项目出事的时候主管第一句话都是看监控如果没有锁维度的监控数据你只能靠猜。我是用AOP拦截读写锁的lock和unlock方法在两边记录时间戳和线程名来收集数据的效果非常好。第四也是我个人认为最重要的一条写并发代码的时候先画清楚哪些操作是读、哪些操作是写再决定要不要用读写锁。很多人一上来就用锁锁住了之后又不知道怎么降级结果变成了看似用了高级锁、实际和synchronized没区别的局面。锁的使用一定是围绕业务场景设计的而不是为了在简历上多写一个类名。就说这些。ReentrantReadWriteLock这个东西用好了是性能利器用不好是隐蔽定时炸弹。希望这篇内容能帮你把它的脾气摸透以后在真实项目和面试场上都游刃有余。