☰
ReentrantReadWriteLock深度拆解:读写锁原理、实战应用与避坑指南
2026/10/14 5:21:30 网站建设 项目流程

搞Java并发有一段时间的人,基本都会遇到ReentrantReadWriteLock这个类。它算是synchronized和ReentrantLock之外,另一个绕不开的“三把锁”之一。很多人看过源码,也背过面试题:读锁共享、写锁排他、支持锁降级但不支持锁升级,然而真到自己写代码时,不是遇到死锁,就是性能反而比synchronized更差。这篇博文我想从实际项目角度重新把ReentrantReadWriteLock拆一遍,不讲空理论,重点说清楚它内部到底怎么工作、什么场景该用它、哪些坑我踩过之后不想你再踩,顺带把源码里几个容易忽略的细节扒出来看。适合正在学并发编程、准备面试,或者工作中想在缓存、配置加载、热点数据读取这类“读多写少”场景里做锁优化的人。

1. ReentrantReadWriteLock整体设计与核心思路

1.1 为什么需要读写锁

先从一个非常常见的业务场景说起:内存缓存。假设你有一个配置中心,或者一个商品详情缓存,日常读写比例可能是1000:1,甚至更高。如果用synchronized或者ReentrantLock这种“独占锁”,所有读线程之间也是互斥的。读操作本来不修改数据,多个线程同时读完全安全,结果却被锁强行串行化,这显然是在浪费并发能力。

读写锁的思路是:把锁拆成两把,一把读锁,一把写锁。读锁是共享锁,多个线程可以同时持有;写锁是独占锁,一个线程持有写锁时,其他读线程和写线程都不能进入。这样在读多写少的场景下,并发读的效率会大幅提升。

这个思路听起来简单,但落到实现上要考虑很多细节。比如:读锁和写锁之间如何协调?一个线程已经持有了读锁,还能不能拿写锁?持有了写锁,能不能再拿读锁?这些设计决策,直接决定了这个锁是好用还是难用。

1.2 读写锁的三层能力:共享读、独占写、锁降级

ReentrantReadWriteLock不是一个单独的锁对象,它内部维护了两个Lock实例:readLock()返回读锁,writeLock()返回写锁。实际使用中,你通常需要分别拿到这两个锁对象来操作。

它最核心的设计能力可以概括为三点:

  • 读锁与读锁兼容:多个线程可以同时持有读锁,适合读多场景。
  • 写锁与其他所有锁互斥:写锁持有期间,读锁不能获取,写锁也不能重复获取(除了当前线程重入)。
  • 锁降级:持有写锁的线程可以继续获取读锁,然后释放写锁,从而完成“写锁→读锁”的转变,这个过程叫做锁降级。反过来,持有读锁再去获取写锁是不允许的,称为锁升级,这也是一个经典的坑点。

这三层能力不是拍脑袋定的,而是基于实际业务安全需求。锁降级在高并发缓存场景有明确意义,稍后我会用代码演示。锁升级禁止则是因为它极容易引发死锁,后面也会详细解释。

1.3 与独占锁的对比

拿ReentrantLock和ReentrantReadWriteLock对比,最能体现设计差异。ReentrantLock是排他锁,不管读还是写,同一时刻只能有一个线程持有。读写锁则是“共享读、独占写”。

维度ReentrantLockReentrantReadWriteLock
读-读互斥共享并发
写-写互斥互斥
读-写互斥互斥
重入性支持支持,但读/写分别计数
公平性可选可选
是否支持锁降级不适用(只有一个锁)支持
是否支持锁升级不适用不支持
适用场景临界区小,读写不分明读多写少,读操作耗时长

从这个表能看出,读写锁并不是银弹。如果临界区代码很短,比如就是几条赋值语句,那么读写锁在获取/释放时的额外开销(尤其是读锁的计数器维护)可能比独占锁还大,性能反而更差。所以用之前一定要先评估读写比例和临界区耗时。读多写少、读操作耗时超过几十微秒,读写锁的优势才真正体现出来。

2. 核心机制拆解:AQS状态、读锁与写锁的获取释放

2.1 一个int搞定两把锁:高低16位拆分

ReentrantReadWriteLock是基于AbstractQueuedSynchronizer(AQS)实现的,AQS 里有一个核心属性state,是个int。读写锁巧妙地把这 32 位整数切成两部分:高16位表示读锁持有数量,低16位表示写锁持有数量。

这种拆分的意义在于:

  • 读锁是共享锁,需要记录当前有多少个线程同时持有读锁,数值可能很大。
  • 写锁是独占锁,理论上只需要表示0/1,但写锁支持可重入,所以低16位实际保存的是写锁的重入次数。
  • 用一个int而不是两个int,是为了保持AQS底层CLH队列、条件队列等逻辑的通用性,同时最大限度减少状态判断的复杂度。

具体计算时,读锁数量通过state >>> 16获取,写锁数量通过state & 0xffff获取。每次获取读锁,state加1 << 16;每次获取写锁,state加 1。源码里到处能看到这两个位运算,理解了高低位拆分,看tryAcquireShared和tryAcquire就轻松多了。

2.2 写锁的获取与释放:独占但不孤独

写锁对应的tryAcquire逻辑是:先取当前state的完整值c,再用w = c & 0xffff取出写锁计数。这里有几种判断分支:

  1. 如果c != 0且w == 0,说明存在读锁,写锁不能被获取。因为只要有任意读线程持有读锁,写锁就必须等待。
  2. 如果c != 0且w != 0,说明写锁已经被某个线程持有,需要判断持有者是否是当前线程。是,则重入计数加一;不是,则返回 false,进队列等待。
  3. 如果c == 0,说明没有任何锁,此时需要看当前锁是否支持公平性,再决定是否允许直接获取。

写锁的释放则是对应的逆运算:将低16位的重入计数减一,减到0,写锁完全释放,唤醒队列中等待的读锁线程。

这里特别容易忽略一点:写锁获取条件中的“没有任何读锁”这一条。它不要求读锁数量等于某个固定值,而是只要high16 != 0就不行。所以写锁等待的真正条件是“读锁完全归零”,而不是“没有新读请求”。这也是后面要讨论写锁饥饿问题的源头。

2.3 读锁的获取与释放:共享但有序

读锁的获取走tryAcquireShared。逻辑里有个一眼看上去有点绕的地方:如果持有写锁的线程不是当前线程,直接失败;如果有写锁但持有者是当前线程,允许继续获取读锁——这就是锁降级的底层入口。

接着要处理的是“第二个读线程之前”的检查,源码中有一个firstReader字段,专门记录第一个获取读锁的线程,以及它的重入次数。这个设计是为了优化“单线程多次重入读锁”的场景,避免每次重入都去遍历一个HoldCounter列表。如果当前线程不是第一个读者,就会通过readHolds这个ThreadLocal来记录每个线程的读锁重入次数。

读锁释放时,同样分两种情况:如果当前线程是firstReader,直接更新它的重入计数;否则从readHolds中取出当前线程的计数并递减。当读锁数量减到0,读锁就彻底释放,才有机会唤醒等待的写锁线程。

读锁还有一个隐藏的边界:因为读锁数量用高16位表示,如果读锁获取次数超过65535次,state就会溢出到写锁的低16位,造成灾难。虽然现代应用不太可能达到这个值,但源码里确实加了sharedCount == MAX_COUNT的校验,超过就抛异常,属于防御性设计。

3. 实操:用ReentrantReadWriteLock写一个高并发缓存

3.1 代码模型:模拟读多写少的内存缓存

下面我用一个典型的“本地缓存 + 热点数据”结构,带你完整写一遍读写锁的应用,同时把锁降级过程融入代码中。

public class ReadWriteCache<K, V> { private final Map<K, V> cache = new HashMap<>(); private final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock(); private final Lock readLock = rwl.readLock(); private final Lock writeLock = rwl.writeLock(); // 读数据:读锁共享,支持并发读 public V get(K key) { readLock.lock(); try { return cache.get(key); } finally { readLock.unlock(); } } // 写数据:写锁独占 public void put(K key, V value) { writeLock.lock(); try { cache.put(key, value); } finally { writeLock.unlock(); } } }

这个模型看起来很简单,但它已经具备了基本的并发安全能力。读方法支持多个线程同时调用,写方法严格互斥。要注意每个锁都必须在finally中释放,否则一旦临界区抛异常,读锁或写锁就永远不会释放,其他线程会全部卡死。我见过不止一次线上事故,就是因为少了finally导致锁泄漏。

3.2 锁降级的实战写法与验证

锁降级最常见的场景是:“先写后读,并且保证读到的数据是刚写入的”。比如下面这个getOrUpdate方法,如果发现缓存里没有数据,就加写锁去加载并写入缓存,随后不急着释放写锁,而是先获取读锁,再释放写锁,以完成降级。

public V getOrUpdate(K key, Function<K, V> loader) { readLock.lock(); V value; try { value = cache.get(key); if (value != null) { return value; } } finally { readLock.unlock(); } // 缓存未命中,需要写 writeLock.lock(); try { // 双重检查,防止其他线程已经写入了 value = cache.get(key); if (value != null) { return value; } value = loader.apply(key); cache.put(key, value); // 锁降级:在持有写锁的情况下获取读锁 readLock.lock(); // 此时的value已经写入,读锁可以保证后面的读一致性 return value; } finally { writeLock.unlock(); // 注意:这里不能用finally直接释放读锁,因为只有降级发生时才需要释放读锁 // 所以上面要在降级路径中lock后,在这里根据条件释放 } }

上面这个写法有个问题:如果缓存未命中,在写锁临界区末尾获取了读锁,但释放写锁时,finally不能无脑释放读锁,否则可能多释放一次。更规范的写法是:

public V getOrUpdate(K key, Function<K, V> loader) { readLock.lock(); try { V value = cache.get(key); if (value != null) { return value; } } finally { readLock.unlock(); } writeLock.lock(); try { V value = cache.get(key); if (value != null) { return value; } value = loader.apply(key); cache.put(key, value); // 降级:先获取读锁 readLock.lock(); return value; } finally { writeLock.unlock(); } // 注意:这里返回后,读锁还持有着,调用方需要负责释放? }

如果这样写,读锁就在方法返回后由调用方无法释放,变得很奇怪。所以实际项目中,通常不会在getOrUpdate里直接降级并带着读锁返回,而是利用锁降级在同一个临界区内完成操作后,立即释放读锁。正确的完整降级案例是这样:

public void updateAndRead(K key, V newValue, Consumer<V> afterUpdate) { writeLock.lock(); try { cache.put(key, newValue); // 获取读锁,实现锁降级 readLock.lock(); // 写锁释放前,先让当前线程持有读锁 writeLock.unlock(); // 此时当前线程只持有读锁,其他线程读不受影响,但写锁已经释放 // 这里可以使用newValue做后续逻辑,并且保证一致性 afterUpdate.accept(newValue); } finally { // 释放读锁 readLock.unlock(); } }

具体来说,锁降级的意义在于:当某个线程完成写操作后,如果需要基于最新数据继续做一段较长耗时的读取,那么持有写锁期间做这些读取会阻塞所有其他读线程,代价太高。如果先释放写锁再获取读锁,中间会有空档期,其他写线程可能插入,导致当前线程后续读取到的数据已经不是它刚写入的数据了。锁降级则保证了“这个线程看到的数据一定是它刚写入的数据”,同时又不让写锁阻塞其他读线程太久。

3.3 可重入性验证

读写锁的可重入性和ReentrantLock类似,但分两种情况:

  • 写锁可以重入写锁:同一个线程持有写锁后,可以再次获取写锁,低16位计数累加。
  • 写锁可以重入读锁:也就是锁降级过程。
  • 读锁不能重入写锁:一个线程持有读锁,再去获取写锁会死锁。因为它自己阻塞等待写锁,而写锁需要等所有读锁释放。

我用一个简单的测试代码验证锁降级:

ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); lock.writeLock().lock(); System.out.println("持有写锁"); lock.readLock().lock(); System.out.println("降级获取读锁成功"); lock.writeLock().unlock(); System.out.println("释放写锁,仍持有读锁"); lock.readLock().unlock(); System.out.println("释放读锁,全部释放");

这段代码能顺利执行完,不会死锁。但如果你调换顺序,先获取读锁再获取写锁:

lock.readLock().lock(); lock.writeLock().lock(); // 这里会死锁

这行代码会永久阻塞。我建议你在本地跑一下,看线程卡住时的线程转储,这会比任何理论解释都更深刻。

4. 源码实战中的坑:锁升级、饥饿、公平模式与调优

4.1 为什么读锁不能升级为写锁

前面说了,读锁升级写锁会死锁。原因可以从条件分析:读锁是共享锁,如果有多个线程都持有读锁,它们都在等写锁,而写锁需要等所有读锁释放才能获得,这些读线程互相等待,形成循环等待,死锁必然发生。

即使只有当前线程一个读线程,JVM 也不会专门做判断去允许它升级,因为“只有一个读者”这个状态难以效且容易变化:另一个线程可能在你检查完之后立刻获取了读锁,这样升级还是死锁。为了避免这种不可控性,读写锁从设计上直接禁止读锁升级。这属于“用确定性设计杜绝死锁”的思路。

实际编码时,如果你有“读后写”的需求,正确做法是:先释放读锁,再获取写锁,获取成功后要做双重检查,防止在释放读锁到获取写锁的窗口期,其他线程已经修改了数据。

4.2 公平模式与非公平模式下的写锁饥饿

ReentrantReadWriteLock默认是非公平锁。非公平模式下,写锁可能被源源不断的读锁饿死。因为读锁获取时不排队,只要state中的读锁数量大于0,新的读请求可以一直进入。如果某一时刻读线程特别多,写线程在队列里排队,但不断有新的读线程插队,写线程就可能很长时间得不到执行。

这就是读写锁著名的“写饥饿”问题。要缓解它,有两个手段:

  • 使用公平模式:new ReentrantReadWriteLock(true),让线程按照先来后到的顺序排队。公平模式下,如果队列头部是写线程,后面的读线程会阻塞,直到写线程完成。这能有效防止写饥饿,但代价是吞吐量下降。
  • 使用StampedLock:这是 JDK 8 提供的锁,它更激进,读锁不阻塞写锁,后面专门讲。

我自己的经验是:如果读写比例极端(读远远多于写),同时写操作的及时性要求不高,可以短暂容忍写等待,那么默认非公平模式足够。但如果写操作很频繁且需要及时生效,比如配置热更新,建议开启公平模式,避免写线程长时间排队导致信息滞后。

4.3 reentrantReadWriteLock不支持Condition?写锁可以用,读锁不可以

ReadLock不支持条件变量(Condition),只有WriteLock支持。为什么?因为条件变量通常和互斥锁配合使用,等待和唤醒依赖独占性。读锁是共享的,如果多个读线程都在同一个条件变量上等待,某个线程调用signal时无法决定唤醒哪个线程,而且容易破坏锁状态。所以源码中ReadLock.newCondition()直接抛UnsupportedOperationException。

如果你的业务需要“多个读者之间互相通知”的场景,建议重新设计,把通知逻辑放到写锁对应的条件变量里,或者改用其他并发工具如Semaphore、CountDownLatch。

4.4 性能调优与替代方案:StampedLock值不值得换

StampedLock是 Java 8 引入的新锁,它有三种模式:写锁、悲观读锁和乐观读。乐观读不真正加锁,而是在读操作前获取一个stamp,读操作后检查这个stamp是否变化,如果没变化说明没有写操作发生,读到的数据是安全有效的。

乐观读的好处是:读操作完全不阻塞写操作,吞吐量更高。但它有一个必须注意的问题:不可重入,而且获取悲观读锁/写锁时都可能导致线程阻塞,甚至出现死锁。更关键的是,StampedLock不支持条件变量,中断语义也比较特殊。

所以我的建议是:不要盲目从ReentrantReadWriteLock换到StampedLock。如果当前读写锁的吞吐量已经满足需求,就没必要引入更复杂的机制。只有当你通过测试确认读写锁是瓶颈,而且读操作确实非常频繁、临界区很短时,才值得尝试StampedLock。换之前要在压测环境验证,不要凭感觉做决定。

5. 常见问题与排查技巧实录

5.1 常见问题速查表

我把平时开发和线上排查中遇到的典型问题整理成了表格,方便按症状检索。

症状可能原因解决办法
程序卡死,CPU占用不高读锁升级写锁导致死锁检查是否在读锁临界区内获取写锁;改用释放读锁后获取写锁,并双重检查
写线程迟迟不执行写锁饥饿启用公平模式;或使用tryWriteLock超时控制
缓存数据偶尔不一致读数据时未加读锁,或写数据时未加写锁所有读路径和写路径必须使用同一个锁实例
锁释放时抛异常finally中释放锁时当前线程并非锁持有者检查是否在finally中重复释放;确保加锁和释放配对
多个写线程串行执行,但读也变慢临界区代码过长,或锁粒度太大缩小临界区,将非关键操作移出锁
线程转储显示很多线程在park锁竞争激烈分析读写比例;考虑使用ConcurrentHashMap分段或StampedLock优化
获取读锁后想用条件变量读锁不支持Condition换用写锁的条件变量;或重新设计线程协作方式

5.2 死锁定位经验

死锁是读写锁最头疼的问题。遇到卡死时,我一般先用jstack打线程转储,查找栈信息中同时出现ReentrantReadWriteLock$ReadLock.lock()和ReentrantReadWriteLock$WriteLock.lock()的线程。典型死锁特征是两个线程互相等待对方持有的锁,转储里能清楚地看到线程A持有读锁在等写锁,线程B持有写锁在等读锁——虽然读锁是共享的,但如果线程A持有的读锁没有释放,写锁就会一直被阻塞。

还有一种隐蔽的死锁是“循环等待”:线程A持有写锁后尝试获取读锁(合法降级),但中间忘了释放写锁就调用了某个方法,该方法又尝试获取另一个对象上的写锁;另一个线程持有那个对象的写锁,回头又要获取第一个对象的写锁。这种跨锁依赖很难直接看出来,建议每次加锁时都设定超时策略,比如使用tryLock或tryWriteLock(3, TimeUnit.SECONDS),超时后打日志,能快速暴露问题链路。

5.3 性能调优与实战心得体会

最后聊一下读写锁调优的个人心得。我踩过几次坑之后,总结出几条实战经验,供参考。

第一,锁对象的引用必须稳定。缓存类里ReentrantReadWriteLock字段一定要是final的,一旦被错误赋值或者被重新初始化,读线程和写线程可能拿到两把不同的锁,加锁就形同虚设。我有一次就遇到过配置中心动态刷新时,把整个缓存对象重新new了,结果所有请求全部并发穿过,数据错乱,排查了很久才发现锁对象换了。

第二,不要滥用锁降级。很多文章把锁降级说得特别高级,但实际业务里真正需要降级的场景没那么多。如果只是为了“顺便读一下刚写的数据”,直接在写锁临界区里读就行,写锁本来就是独占的,没必要降级。只有当写锁临界区内还有一段耗时较长的只读操作,且你不想阻塞其他读者时才值得用降级。

第三,用ReadWriteLock之前先问自己:能不能用ConcurrentHashMap?如果只是简单的 key-value 读写,ConcurrentHashMap在大多数情况下性能更好、代码更简单,因为它内部使用分段锁或 CAS 优化,而且读操作几乎无锁。读写锁更适合需要在多个数据项之间保持一致性、或者单个实体内部状态互相约束的场景。

第四,压测时一定要模拟真实读多写少的比例,比如10:1、100:1。如果压测工具里读写比例接近1:1,读写锁的优势会非常不明显,甚至不如独占锁。所以评估性能不能只看场景名称,要看真实流量模型。

根据我个人经验,ReentrantReadWriteLock最舒服的使用场景是“配置缓存、权限元数据、路由表”这类数据量小但读取频繁、更新频率低的模块。我曾经维护过一个规则引擎,规则内容大概几百KB,每天更新几次,但每次请求都要读取并匹配规则。用synchronized读规则时并发吞吐不到5000,换成读写锁后直接翻到5万以上,而写锁只有在规则更新时占用,系统整体响应时间和CPU开销都显著改善。不过要注意,如果规则数据达到MB级别且读操作内部有大循环,那么读锁临界区耗时太长,写锁仍然会被阻塞过久,这时候就应该考虑把规则拆分成不可变对象,利用volatile引用发布,读线程直接拿快照,完全不需要锁。这里面的取舍逻辑需要你在实际业务中去体会,但无论选哪条路,把锁的语义先理解透彻,才是避免踩坑的根本。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询