☰
Java并发读写锁ReentrantReadWriteLock原理与实战避坑指南
2026/10/11 7:59:02 网站建设 项目流程

Java面试题里只要一提到读写锁,基本就是在问 ReadWriteLock 以及它的默认实现 ReentrantReadWriteLock。这题的经典程度不亚于“HashMap 原理”,而且很多候选人能背出“读读共享、读写互斥、写写互斥”这三句话,但真到面试官追问“你项目里哪里用过?为什么不用 synchronized?读锁能不能升级成写锁?”的时候,不少人就开始露怯了。

今天这篇我用真实项目的视角来拆这道高频面试题:读写锁到底解决什么问题、内部机制是怎么回事、哪些业务场景真正值得用,以及在实战中容易踩的坑。如果你正在准备 Java 面试,或者是工作中想优化并发读写的性能,这篇应该能给你一套能直接拿去用的思路。

1. 先搞清楚读写锁到底在锁什么

1.1 从“互斥锁太粗暴”说起

在没接触读写锁之前,大部分 Java 开发者习惯用的是 synchronized 或者 ReentrantLock。这类锁有一个共同特征:同一时刻只允许一个线程进入临界区。也就是说,不管你是读数据还是写数据,都得排队。

但现实业务里,读操作和写操作的频率往往极度不对等。以我自己做过的商品配置服务为例:配置项会被几十个下游服务并发读,而真正修改配置的操作一天可能就几次。如果每次读都跟其他读互斥,那系统并发能力会被白白浪费。读操作本身不会改变数据,多个线程同时读根本不会出问题,把读操作也串行化,是对性能的极大浪费。

读写锁的核心思想就是打破“凡事必互斥”的枷锁:它把锁拆分成了读锁和写锁两把。多个线程可以同时持有读锁,但写锁是独占的,一旦有线程持有写锁,其他读和写都要阻塞等待。

1.2 三句话把读写锁规则说清楚

读读可以共存,读写不能共存,写写也不能共存。这三句话基本就是读写锁的全部规则,也是面试里最容易背、最容易答的一题。

但光会背还不够,你得能解释“为什么写写也要互斥”。道理很简单:写操作会修改共享数据,两个线程同时改,谁也不知道最终结果以谁为准。所以读锁是共享锁,写锁是排他锁。这个设计和数据库里的共享锁、排他锁是一个思路,把同步粒度从“整个资源”细分成“读与写、写与写”的冲突关系。

1.3 读写锁不万能:读多写少的“放大器”

既然读写锁让读操作可以并发,那是不是所有场景用它都更快?答案是否定的。读写锁适合的是读操作远多于写操作的场景,比例至少要在几十比一以上才有明显收益。

为什么?因为读写锁本身的实现比较复杂,获取锁、判断状态、维护 reader 计数都会比普通互斥锁多一些开销。如果你的业务是“读写频率差不多”甚至“写多于读”,读写锁的性能优势体现不出来,反而因为逻辑复杂更容易出问题。这种情况下老老实实用 ReentrantLock 或者 synchronized,代码简单,行为也更好预测。

面试的时候如果能主动说出这句“不是所有并发都适合读写锁,它更偏向读多写少”,面试官基本就会觉得你真用过,而不只是背过八股文。

2. 从源码角度理解 ReentrantReadWriteLock

2.1 一个 int 拆成高低 16 位来用

面试官一旦深入问,就喜欢问“ReentrantReadWriteLock 内部怎么实现”?其实核心秘密在那个看起来非常简单的同步状态 state 上。

学过 AQS(AbstractQueuedSynchronizer)的都知道,AQS 里有一个 volatile int state,ReentrantLock 用它来记录“被重入了几次”。ReentrantReadWriteLock 则把这个 int 拆成了两半:高 16 位表示读锁被获取的总次数,低 16 位表示写锁的重入次数。

因为 int 总共 32 位,一分为二之后,读锁最多支持 65535 次共享获取,写锁也最多重入 65535 次。正常业务完全够用,但如果哪个疯子用递归把读锁嵌套上万次,理论上确实会溢出。面试里能说出高 16 位和低 16 位这两个数字,说明你是真的翻过源码,这个细节很加分。

2.2 锁降级:写锁可以变成读锁

ReentrantReadWriteLock 有一个非常实用的特性叫锁降级。指的是持有写锁的线程,可以在不释放写锁的情况下获取读锁,然后再释放写锁,这样线程就平稳地从写状态过渡到了读状态。

为什么要这么做?因为有时候你写完数据后,立刻还要读一下这个数据保证后续逻辑用的是最新值。如果你先释放写锁再获取读锁,中间就插入了一个“无锁窗口”,其他写线程可能趁虚而入修改数据,你再读到的就不一定是你刚写的内容了。写锁降级可以把“写后读”这整段操作保持在锁的保护范围内。

要注意的是,锁降级只能从写锁降成读锁,反过来不行。读锁想升级成写锁,那叫锁升级,是 ReentrantReadWriteLock 的一个大坑,我后面在避坑部分会详细讲。

2.3 公平与非公平模式的实际区别

ReentrantReadWriteLock 提供了两个构造器:默认是非公平锁,传 true 则是公平锁。没有特殊要求,一定用非公平模式。

非公平模式下的表现是:刚刚释放写锁的线程或者其他竞争线程,有可能“插队”再次获得锁,而不是老老实实排队。这样做的最大好处是吞吐量高,因为减少了线程挂起和唤醒的上下文切换开销。坏处是写线程可能长时间得不到锁,出现“写饥饿”。

公平模式则是严格按照线程到达顺序排队,哪个线程先等,哪个线程先获得。它的优点是不会饿死,缺点是性能相对低一些。在我实际做过的网关配置中心里,用的是公平模式,因为配置变更虽然频率低,但一旦发生变更,必须尽快被执行,否则下游拿到旧配置会出故障。这种场景下宁可牺牲一点吞吐,也要避免写饥饿。

3. 高频应用场景:从面试答案到项目落地

3.1 本地缓存读写:读写锁最经典的归宿

面试官问读写锁应用场景,十有八九想听到的答案是缓存。这确实是读写锁最典型的用武之地。

我自己维护过一个用户标签缓存服务:Redis 是最终数据源,但为了降低网络 IO,服务内部用本地 HashMap 做了一层缓存。标签数据的特点非常鲜明——每天被查询几百万次,而真正的标签更新可能只有几千次。如果给整个缓存对象加 synchronized,那并发读全被串行化,TPS 至少下降一半。

这段代码大家可以直接抄作业,就是读写锁的标准用法:

public class LocalCacheService { private final Map<String, UserTag> cache = new HashMap<>(); private final ReadWriteLock lock = new ReentrantReadWriteLock(); public UserTag get(String userId) { lock.readLock().lock(); try { return cache.get(userId); } finally { lock.readLock().unlock(); } } public void put(String userId, UserTag tag) { lock.writeLock().lock(); try { cache.put(userId, tag); } finally { lock.writeLock().unlock(); } } }

这里有两个细节必须强调。第一,lock/unlock 一定要放在 try/finally 里,确保锁一定能释放,不然一旦 get 操作抛出异常,读锁就永远锁住了,整个服务直接被拖死。第二,读锁虽然允许多线程共享,但它本身的 lock 和 unlock 也要成对出现,不要因为觉得“反正读不互斥”就省掉。

3.2 缓存回源场景下的双重检查与写锁降级

缓存还有一个高频场景叫回源,也就是缓存里没有数据时,需要去数据库加载。如果不做任何控制,高并发下同一个 key 可能被几百个线程同时回源,数据库瞬间被打爆。

经典解法是缓存领域常说的双重检查锁,但用读写锁实现时要注意一个关键点:你不能在读锁内直接升级成写锁。正确的做法是先拿读锁查一遍缓存,没有则释放读锁,再拿写锁回源,拿到写锁后再查一次缓存,这样做主要是防止其他线程在你释放读锁和获取写锁之间已经把数据写进去了。

直接看代码:

public Object getData(String key) { Object value = cache.get(key); if (value != null) { return value; } lock.readLock().lock(); try { value = cache.get(key); if (value != null) { return value; } } finally { lock.readLock().unlock(); } lock.writeLock().lock(); try { value = cache.get(key); if (value != null) { return value; } value = loadFromDatabase(key); cache.put(key, value); // 锁降级:先获取读锁,再释放写锁 lock.readLock().lock(); return value; } finally { lock.writeLock().unlock(); } }

写锁降级在这里的意义是:当线程完成数据库加载并把值写入缓存后,它希望带着这个刚写入的值继续做后续业务处理,比如组合返回结果。如果不降级,直接释放写锁,那其他写线程可能立刻改了缓存,当前线程再读到的就不是自己刚写入的数据了。

这里我给一个提醒:上面的代码为了简洁,我用了最朴素的写法。实际生产环境最好再给缓存加一个失效时间,避免“缓存里一直存着旧值”的情况。另外回源操作强烈建议加一个分布式锁或者在数据库层做限流,否则单机读写锁只能解决本地线程竞争,解决不了多实例同时回源的问题。

3.3 配置中心或规则引擎的快照更新

另一个我非常推荐用来回答面试题的场景是配置热更新。很多微服务框架都有动态配置功能,比如 nacos、apollo,它们会定期从远端拉取配置并更新本地快照。

这个本地快照就是典型的“读多写少”结构:业务线程每次处理请求都要读配置,但配置更新的频率很低。在有些系统里,我甚至会直接用读写锁保护一个 volatile 的配置对象引用。读写锁在这里不是为了防止“读到半截数据”,而是为了保证配置切换的原子性:要么读者一直读旧配置,要么切换之后读到完整的新配置。

使用方式跟缓存几乎一样,但有个额外价值:你可以把多个配置项打包成一个配置对象,更新时只更换对象引用,这样就算是读锁不互斥,也不会出现一个线程读了一半新配置一半旧配置的混乱局面。

3.4 对象级别的读写状态维护

还有一种场景技术上很巧妙,就是用一个自定义类同时维护“读统计数据”和“写操作频率”,比如一个限流器或者监控计数器。

假设有个组件要记录接口被调用的总次数和最近一分钟的成功次数。写操作是高并发计数,读操作是定时拉取快照。如果全用 synchronized,拉快照的时候计数线程全被挡在外面,数据准确性没问题,但性能受损。用读写锁以后,计数线程之间完全没有互相阻塞,拉快照的线程只需要获取读锁读一次当前值。

这类场景在小流量的业务里可能看不出差别,但一旦流量上到每秒几万,或者在链路里处于热路径,读写锁带来的吞吐提升非常明显。面试时把这个场景讲出来,比泛泛而谈“缓存”要更能证明你的实战能力。

4. 高频问答与避坑实战记录

4.1 面试必问:读锁能不能升级成写锁?

绝对不能。这是 ReentrantReadWriteLock 最大的坑。

读锁升级成写锁会造成死锁。原因其实不复杂:当前线程持有读锁,其他线程也可能持有读锁,读写锁的设计不允许一个读线程直接跨过其他读线程去拿写锁。如果当前线程傻等写锁,而其他持有读锁的线程也在等某个资源释放,就可能形成循环等待。即使没有其他线程,单线程自己持有读锁再等写锁,也永远等不到,直接把自己锁死了。

很多刚接触读写锁的人都会犯这个错。我见过线上事故:一个同事在缓存回源代码里先读后写,图省事没有释放读锁,直接在读锁里面调 writeLock().lock(),结果服务线程全部卡死在锁上,CPU 不高但线程池被打满,最终靠重启才恢复。

正确的方式就是我在 3.2 节写的:先释放读锁,再获取写锁,然后在写锁里做“二次检查”。

4.2 写锁降级到底怎么用才安全

写锁降级虽然叫“降级”,但它的本质不是自动发生的,而是代码逻辑里特意去做的。标准写法是:在当前线程持有写锁的情况下,主动调用 readLock().lock(),然后再调用 writeLock().unlock()。

降级之后,当前线程仍然持有读锁,可以继续读取共享数据。一定要记住,降级完成后,最后要再调用一次 readLock().unlock(),否则读锁会一直不释放,其他线程的写操作永远进不来。

这个特性比较隐蔽,面试官如果追问“你项目里真的用过锁降级吗”,你可以把缓存回源场景中的用法讲一遍:先更新缓存,再获取读锁,再释放写锁,这样既保证了最新值立即可读,又避免了无锁窗口期的数据错乱。

4.3 为什么非公平模式下写线程可能饿死

前面提到公平锁和非公平锁,这个考点也是面试常客。默认的 ReentrantReadWriteLock 是非公平锁,也就是说,当读线程非常多的时候,写线程可能会长时间抢不到锁,这种现象叫写饥饿。

原因很好理解:读锁是共享锁,一个读线程获取读锁后,其他读线程也能立刻获取读锁。如果读线程源源不断到来,写线程就会一直排队。就好比一家咖啡店,工作人员一直在给顾客试喝,但真正要买单的顾客始终插不进去。

解决写饥饿的办法主要有两个:一是使用公平锁构造器,让所有线程按顺序排队;二是使用 JDK 8 以后提供的 StampedLock,它支持乐观读,在读线程占绝对多数的时候性能更好,而且它内部对写线程比较友好。

不过 StampedLock 也有自己的问题:它不可重入,而且使用乐观读的时候,你必须自己验证版本号来判断读期间有没有发生写操作。我的建议是,面试时把它当拓展点提一下,实际生产环境中大部分场景用 ReentrantReadWriteLock 就够了。

4.4 读写锁与 synchronized 的选择

还有人会问:既然 synchronized 在 JDK 1.6 之后做了锁升级优化,为什么还需要读写锁?答案是因为 synchronized 是互斥锁,它的优化方向是减少锁开销,但并没有改变“同一时刻只有一个线程进入”的本质。读操作即使再多,它也要一个一个来。

对比一下:

锁类型读读关系读写关系写写关系典型性能
synchronized互斥互斥互斥读多写少时吞吐差
ReentrantLock互斥互斥互斥比 synchronized 灵活
ReentrantReadWriteLock共享互斥互斥读多写少时吞吐极高
StampedLock乐观读/共享互斥互斥极限场景的进阶选项

如果业务里读操作占比极高,而且你确认没有复杂的重入需求,读写锁基本是最优解。但如果读写比例不明确,或者代码逻辑已经比较复杂,我还是建议先用 ReentrantLock,避免引入更多心智负担。

4.5 排查线上锁问题时用到的小技巧

真实项目中,读写锁一旦出问题,排查起来比互斥锁更麻烦,因为你把锁的状态分成了读和写两类。我最常用的排查手段是两个。

第一个是 jstack 抓线程栈。杀死不可能的进程之后,看线程栈里是否出现下面这样的信息:

parking to wait for <0x000000076e17cfb0> (a java.util.concurrent.locks.ReentrantReadWriteLock$NonfairSync)

出现这个说明线程正在等待获取锁,结合业务 log 里的调用链,能快速定位是哪个点在等锁。

第二个是利用锁自身的监控方法。ReentrantReadWriteLock 内置了 getReadLockCount()、getWriteHoldCount()、hasQueuedThreads() 等方法,可以在线上临时开启一个监控接口,把当前锁的持有情况暴露出来。如果发现读锁数量一直很高且不下降,优先检查是不是有线程在持有读锁时抛了异常但没走 finally 释放锁。记住这句话:读写锁出问题,绝大多数不是锁设计错了,而是解锁路径漏了。

5. 一些真正好用的判断方法与实践心得

如果让我总结一下“什么情况下我才应该用读写锁”,我的判断顺序是这样的:先看读写比例,读的次数远大于写,再继续看下一步;然后看能不能接受偶尔的阻塞,如果读操作要求极低延迟且不能等待,那甚至可以考虑无锁方案;最后看数据的修改方式,如果是复合结构,比如一个对象里有多个字段要组合更新,那读写锁是必要的,单字段变更可以直接用 volatile。

我在实际项目中用读写锁最顺手的地方,还是本地缓存和配置快照。后来接触到分布式环境,才发现单机读写锁只能解决本进程内的并发问题,跨节点场景还是得引入分布式缓存或者分布式锁。所以你在面试回答里,最好加上一句“单机场景用读写锁,分布式场景会用 Redis 等外部组件保证一致性”,这句话会让面试官觉得你有全局观。

最后再分享一个小技巧:写读写锁代码的时候,不要自己手动写 lock 和 unlock,更不要在多个方法里分散加锁。我会把读写锁的操作封装到独立的仓储类或者 DAO 类里,对外只暴露 get 和 put 之类的方法,这样锁的获取和释放在同一个类里,代码review 的时候一眼就能检查出有没有漏释放。这个习惯帮我避免了好几次线上事故,你也可以试试。

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

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

立即咨询