Java并发读写锁ReentrantReadWriteLock原理与实战避坑指南

发布时间:2026/10/11 7:59:08
Java并发读写锁ReentrantReadWriteLock原理与实战避坑指南 Java面试题里只要一提到读写锁基本就是在问 ReadWriteLock 以及它的默认实现 ReentrantReadWriteLock。这题的经典程度不亚于“HashMap 原理”而且很多候选人能背出“读读共享、读写互斥、写写互斥”这三句话但真到面试官追问“你项目里哪里用过为什么不用 synchronized读锁能不能升级成写锁”的时候不少人就开始露怯了。今天这篇我用真实项目的视角来拆这道高频面试题读写锁到底解决什么问题、内部机制是怎么回事、哪些业务场景真正值得用以及在实战中容易踩的坑。如果你正在准备 Java 面试或者是工作中想优化并发读写的性能这篇应该能给你一套能直接拿去用的思路。1. 先搞清楚读写锁到底在锁什么1.1 从“互斥锁太粗暴”说起在没接触读写锁之前大部分 Java 开发者习惯用的是 synchronized 或者 ReentrantLock。这类锁有一个共同特征同一时刻只允许一个线程进入临界区。也就是说不管你是读数据还是写数据都得排队。但现实业务里读操作和写操作的频率往往极度不对等。以我自己做过的商品配置服务为例配置项会被几十个下游服务并发读而真正修改配置的操作一天可能就几次。如果每次读都跟其他读互斥那系统并发能力会被白白浪费。读操作本身不会改变数据多个线程同时读根本不会出问题把读操作也串行化是对性能的极大浪费。读写锁的核心思想就是打破“凡事必互斥”的枷锁它把锁拆分成了读锁和写锁两把。多个线程可以同时持有读锁但写锁是独占的一旦有线程持有写锁其他读和写都要阻塞等待。1.2 三句话把读写锁规则说清楚读读可以共存读写不能共存写写也不能共存。这三句话基本就是读写锁的全部规则也是面试里最容易背、最容易答的一题。但光会背还不够你得能解释“为什么写写也要互斥”。道理很简单写操作会修改共享数据两个线程同时改谁也不知道最终结果以谁为准。所以读锁是共享锁写锁是排他锁。这个设计和数据库里的共享锁、排他锁是一个思路把同步粒度从“整个资源”细分成“读与写、写与写”的冲突关系。1.3 读写锁不万能读多写少的“放大器”既然读写锁让读操作可以并发那是不是所有场景用它都更快答案是否定的。读写锁适合的是读操作远多于写操作的场景比例至少要在几十比一以上才有明显收益。为什么因为读写锁本身的实现比较复杂获取锁、判断状态、维护 reader 计数都会比普通互斥锁多一些开销。如果你的业务是“读写频率差不多”甚至“写多于读”读写锁的性能优势体现不出来反而因为逻辑复杂更容易出问题。这种情况下老老实实用 ReentrantLock 或者 synchronized代码简单行为也更好预测。面试的时候如果能主动说出这句“不是所有并发都适合读写锁它更偏向读多写少”面试官基本就会觉得你真用过而不只是背过八股文。2. 从源码角度理解 ReentrantReadWriteLock2.1 一个 int 拆成高低 16 位来用面试官一旦深入问就喜欢问“ReentrantReadWriteLock 内部怎么实现”其实核心秘密在那个看起来非常简单的同步状态 state 上。学过 AQSAbstractQueuedSynchronizer的都知道AQS 里有一个 volatile int stateReentrantLock 用它来记录“被重入了几次”。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 MapString, 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 的时候一眼就能检查出有没有漏释放。这个习惯帮我避免了好几次线上事故你也可以试试。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询