news 2026/10/3 21:15:24

ReentrantReadWriteLock 实战:读锁写锁行为、锁降级与死锁避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
ReentrantReadWriteLock 实战:读锁写锁行为、锁降级与死锁避坑指南

之前我有一个内部系统的配置中心,读请求每秒几千次,配置更新却好几分钟才一次。最初图省事,我直接在 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 只是工具箱里的一把好用的钥匙,但用之前一定要搞清楚:你要锁的,到底是谁。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/3 21:15:04

Sonnet 5.5生产接入实战:API调试、VS Code集成与Python同步调用

1. Sonnet 5.5不是“小号Opus”&#xff0c;而是Claude体系里最锋利的工程刀刚看到标题里“跑分贴脸Opus”这句&#xff0c;我第一反应是——别急着关网页&#xff0c;也别急着换模型。我上周在三个不同客户现场同时部署了Sonnet 5.5、Opus 4.6和Haiku 3.5&#xff0c;用同一套…

作者头像 李华
网站建设 2026/10/3 21:10:59

Replit:知识工作的浏览器原生操作系统

1. 这不是一场普通直播&#xff1a;Replit 正在重新定义知识工作的“操作系统”你有没有试过&#xff0c;在浏览器里点几下就跑通一个 Python 爬虫&#xff0c;再拖拽两个组件就搭出带数据库的待办清单 App&#xff0c;最后直接把整个项目链接发给同事——对方点开就能编辑、调…

作者头像 李华
网站建设 2026/10/3 21:02:22

Mandelbrot分形生长:Flutter音频映射与鸿蒙适配全解析

不要急着写代码&#xff0c;先把这期系列的定位想清楚。距离我上一次写完分形与音频的联动方案已经有一段时间了&#xff0c;这次在把整体项目往鸿蒙端迁移的时候&#xff0c;又顺便把 Mandelbrot 分形的音频映射逻辑重做了一轮。这一期是“Flutter 跨平台开发实战&#xff1a;…

作者头像 李华
网站建设 2026/10/3 21:01:16

从零到一:构建可交付AI系统的完整工程实践指南

这几年“AI工程”这个词被反复提及&#xff0c;各种“7天转行AI”“大模型实战速成”满天飞。但聊过不少准备入行或者刚转行的朋友之后&#xff0c;我发现真正卡住人的往往不是“不会调包”&#xff0c;而是对整条链路缺乏掌控力——模型在笔记本上跑得再顺&#xff0c;一进生产…

作者头像 李华
网站建设 2026/10/3 20:55:39

高级数据库查询实战:从多表连接到窗口函数的SQL备考指南

备考计算机三级&#xff08;数据库技术&#xff09;的同学&#xff0c;十有八九会在交出一套 SELECT 语句后被批错。不是你不会写查询&#xff0c;而是高级数据库查询考的不只是语法&#xff0c;它考的是你对关系模型、分组逻辑和查询优化器的理解。很多人在这一步掉链子&#…

作者头像 李华
网站建设 2026/10/3 20:54:17

分布式系统开发实战:锁、事务、缓存与分布式ID解析

1. 分布式架构&#xff1a;先摸清“团建”的底层逻辑分布式系统这个词&#xff0c;在技术圈被聊得最多&#xff0c;也最容易被聊糊。一搜“分布式”&#xff0c;出来的全是分布式锁、分布式事务、分布式缓存、分布式架构、分布式爬虫、分布式定时任务、分布式UUID……知识点碎得…

作者头像 李华