写这系列教程的时候,我一直想找一种"看起来简单、用起来顺手、但深挖全是坑"的Java并发工具来细讲。ReentrantReadWriteLock恰好就是这样的存在:很多初级开发第一次看到它,觉得不就是把锁分成了读和写两种吗?等真正在项目里落地,才发现锁降级、锁升级、公平性、饥饿问题每一个都能把人折腾到怀疑人生。这篇我用一篇完整的内容,把它的设计原理、代码写法、面试考点、常见坑全部串一遍,希望能帮你在写并发代码的时候少走弯路。
无论你是正在准备Java面试,还是已经在公司里负责高并发模块的开发和维护,只要你需要处理"读多写少"这类共享资源访问问题,ReentrantReadWriteLock都是你绕不开的基础知识点。它和synchronized、ReentrantLock最大的区别在于:它不再把"对共享资源的每一次访问"都视为互斥,而是按照操作类型将锁拆成读锁和写锁,让多个读线程可以同时进入临界区,从而大幅提升读密集场景下的吞吐量。
我一直强调一个观点:Java并发不是记住多少个类名,而是理解锁在竞争条件下到底做了什么决策。这篇文章就从ReadWriteLock的最核心设计出发,配合可直接上手的代码、实际生产环境中常见的诡异问题,以及我在写并发组件时总结的排错方法,帮你一次性把这门课吃透。
1. 从互斥锁说起:为什么读多写少的场景不能只靠synchronized
1.1 互斥锁的代价到底高在哪
很多初学者会问:我直接用synchronized或者ReentrantLock包住整个方法不就行了?为什么还要整出个读写锁?这个问题问得非常好,因为它直接触及了并发性能优化的核心。
互斥锁(Mutex)模型的核心是:同一时刻只允许一个线程进入临界区。不管是读操作还是写操作,通通排队。这在"写操作极少、读操作极多"的场景下,就会造成严重的性能浪费。想象一下一个内存缓存,十个线程同时来查数据,本来完全可以并行查询,但被synchronized挡住,变成了一个查完另一个再查。每个读操作本身不修改任何共享状态,理论上它们是绝对安全的,却被锁强行串行化。
我在之前做的一个商品详情聚合服务里就遇到了类似问题:热点商品的详情数据被Redis挡住之后,还有大量请求会回源到本地缓存,而本地缓存使用的是ConcurrentHashMap加synchronized保护,结果压测时发现读操作的吞吐量始终上不去。后来换成ReentrantReadWriteLock之后,读操作不再互相阻塞,性能数据几乎是直线提升。
这里要理清一个概念:互斥不是错的,互斥是保证安全的底线;但在读多写少的场景下,互斥是"杀鸡用牛刀"——你用一把独占锁,保护了那些根本不需要独占的操作。读操作之间天然兼容,真正需要独占的只有写操作。读写锁正是抓住了这个差异。
1.2 读写锁的核心思想:共享锁与独占锁的组合
ReentrantReadWriteLock维护了两把锁:一把是读锁(共享锁),一把是写锁(独占锁)。读锁之间不互斥,读锁与写锁互斥,写锁之间互斥。这个语义用矩阵表示可能是最清楚的:
| 当前线程持有锁 | 请求读锁 | 请求写锁 |
|---|---|---|
| 未持有锁 | 允许 | 允许 |
| 读锁 | 允许 | 阻塞 |
| 写锁 | 允许 | 阻塞 |
| 读锁+写锁(锁降级) | 允许 | 阻塞 |
注意上看表格里最后一行的特例:写锁持有期间可以继续获取读锁,这就是锁降级的基础。为什么允许这个?因为写锁是独占锁,持有写锁时没有任何别的线程在读或写,所以此时获取读锁是绝对安全的。反过来,如果你持有读锁再去获取写锁,就存在死锁风险:多个线程都持有读锁,都在等写锁,而写锁要等所有读锁释放,谁也等不到谁。
这既是设计上的取舍,也是大家在写代码时必须刻在脑子里的红线:锁升级不允许,锁降级允许。后面我会专门演示锁降级的标准写法,这个是面试高频考点,也是实际项目中保证"数据可见性"的关键技巧。
2. ReentrantReadWriteLock的底层设计:AQS与状态位拆分
2.1 一个整型字段如何同时表达两种锁状态
在看ReentrantReadWriteLock源码的时候,很多人会被内部实现绕晕,因为它并不是像表面看起来那样维护了"读锁对象"和"写锁对象"两个互不相关的状态。实际上,读写锁内部是通过一个AQS(AbstractQueuedSynchronizer)同步器来管理的,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 SimpleCache<K, V> { private final Map<K, 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分支里直接return,finally里unlock的代码写在了try的最后一行——这种情况下return走catch,finally确实会被执行,理论上不会泄漏,但那个方法有多个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提供的RReadWriteLock,ZooKeeper也可以自定义读锁和写锁的节点协调逻辑。
我在这里想强调的并不是具体分布式锁API,而是思维方式的迁移:无论是进程内锁还是分布式锁,共享锁和独占锁的语义是通用的。你理解了ReadWriteLock为什么允许读读共享,就能理解分布式场景下为什么多个服务只在写操作时抢占锁;你理解了锁降级保证数据一致性的原理,就能更敏锐地发现分布式环境下"重复加载数据"这种问题带来的数据覆盖风险。并发问题的分析能力,从来不局限于某个具体工具。
5.3 实际生产中的设计建议
最后分享几条我在实际生产环境里总结的经验,供你们参考:
第一,能不用锁就不用锁。如果数据是只读的,优先考虑final不可变对象或者volatile;如果数据结构本身支持并发操作,比如ConcurrentHashMap,直接用它的并发容器方法,至少比手动加锁要安全得多。ReentrantReadWriteLock适合的场景是"有一个共享可变对象,读多写少,而且对数据一致性要求高到必须用锁来约束"。
第二,如果确定要用读写锁,把读锁持有的时间压到最短。读操作只做必要的变量访问和判断,不要在临界区里做IO、网络调用、复杂计算。你每缩短一毫秒的临界区时间,写线程的等待时间就能减少一大截。
第三,重视监控。代码上线前,至少加入锁等待时间和锁持有时间的统计日志。很多项目出事的时候,主管第一句话都是"看监控",如果没有锁维度的监控数据,你只能靠猜。我是用AOP拦截读写锁的lock和unlock方法,在两边记录时间戳和线程名来收集数据的,效果非常好。
第四,也是我个人认为最重要的一条:写并发代码的时候,先画清楚"哪些操作是读、哪些操作是写",再决定要不要用读写锁。很多人一上来就用锁,锁住了之后又不知道怎么降级,结果变成了"看似用了高级锁、实际和synchronized没区别"的局面。锁的使用一定是围绕业务场景设计的,而不是为了在简历上多写一个类名。
就说这些。ReentrantReadWriteLock这个东西,用好了是性能利器,用不好是隐蔽定时炸弹。希望这篇内容能帮你把它的脾气摸透,以后在真实项目和面试场上都游刃有余。