尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

读多写少场景的并发救星:ReentrantReadWriteLock 源码级解析

读多写少场景的并发救星:ReentrantReadWriteLock 源码级解析 做Java开发的兄弟对synchronized和ReentrantLock肯定都不陌生。这两个锁能解决大多数并发问题但有一个场景它们天然不合适读多写少。比如配置中心、本地缓存、权限元数据这类数据可能是几万次读才碰上一次写你用独占锁把所有读请求串行化CPU利用率上不去接口RT被拉高系统的整体吞吐直接被打折。ReentrantReadWriteLock就是专门为这种场景设计的读写锁读锁之间共享、读锁与写锁互斥、写锁与写锁互斥同一时刻允许多个线程同时读只有写入时才独占。这篇内容我会从锁的设计原理、源码级别的实现细节、生产可用代码、常见坑和面试角度逐一拆解适合已经会用synchronized、想深入理解Java并发锁进阶工具的开发者也适合准备Java面试时需要把读写锁讲透的朋友。1. ReentrantReadWriteLock是什么为什么要用它1.1 从“读多写少”这个高频业务场景说起先看一个非常常见的场景一个商品详情接口每次请求都要读取配置信息、营销规则、库存状态等热点数据。这些数据的变化频率极低可能一天才更新几次但读取量非常大每秒可能有上千次。如果直接用synchronized或ReentrantLock守护整个读取过程会发生什么所有读线程会排队抢同一把锁。读操作本身不修改共享数据却因为互斥锁的机制被强制串行化。一个读操作可能只要1毫秒但1000个读操作排在一起最后一个请求的等待时间就是1秒。这在接口层是致命的。读写锁的解决思路非常直接把“读”和“写”分开看待。读操作之间不会破坏数据一致性所以多个读线程可以同时持锁进入临界区只有写操作才需要独占资源。ReentrantReadWriteLock内部维护了两把锁readLock()和writeLock()。调用readLock().lock()进入的是共享模式调用writeLock().lock()进入的是独占模式。这样一来读密集场景下几乎不会有锁竞争系统吞吐量自然就上去了。这个设计思想其实和数据库隔离级别里共享锁、排他锁的分类一脉相承。MySQL里的LOCK IN SHARE MODE是共享锁多个事务能同时持有FOR UPDATE是排他锁只允许一个事务操作。Java的ReentrantReadWriteLock就是把这个思想搬到了JVM进程内。1.2 读锁与写锁的四条核心规则ReentrantReadWriteLock的行为可以归类为四条规则理解这四条规则是使用它的基础读读共享多个线程可以同时持有读锁谁也不会阻塞谁。因为在只读访问时数据不会发生变化多个线程并发读取是安全的。读写互斥读锁和写锁不能同时被持有。写线程持锁时读线程必须等待读线程持锁时写线程也必须等待。这条规则保证了“读线程不会读到写了一半的数据”。写写互斥写锁是独占锁同一时刻只允许一个线程持有写锁。锁降级一个线程在持有写锁的情况下可以继续获取读锁然后释放写锁最终停留在“持有读锁”的状态。这个过程叫锁降级是读写锁比较高级的玩法。这里特别提醒一下规则里没有“锁升级”。对ReentrantReadWriteLock来说一个线程持有读锁时再去获取写锁是必死路一条的。听我一句千万不要写这种代码。至于为什么会死锁、怎么避坑后面“常见问题与排查实录”部分会专门展开。从实际业务选型的角度看什么时候值得用读写锁最简单的判断标准是共享数据的读操作比例明显高于写操作而且读操作的临界区持续时间不短。如果读操作只是对一个变量做一次内存读取那读写锁带来的收益可能不明显直接用volatile或AtomicXXX就够了。一旦读操作涉及集合遍历、多个字段组合、缓存加载这类相对耗时的逻辑读写锁的价值才会真正体现出来。2. 从AQS源码看读写锁的设计精髓2.1 一个int怎么装下两把锁ReentrantReadWriteLock的底层依然是AQSAbstractQueuedSynchronizer。AQS内部维护了一个volatile int state在ReentrantLock里这个state表示持锁线程的重入次数。在ReentrantReadWriteLock里这个state被拆成了两部分高16位表示写锁的重入次数低16位表示读锁的持有数量注意是“持有数量”不是“线程数”因为一个线程可能重入多次读锁。static final int SHARED_SHIFT 16; static final int SHARED_UNIT (1 SHARED_SHIFT); static final int MAX_COUNT (1 SHARED_SHIFT) - 1; static final int EXCLUSIVE_MASK (1 SHARED_SHIFT) - 1; static int exclusiveCount(int c) { return c EXCLUSIVE_MASK; } static int sharedCount(int c) { return c SHARED_SHIFT; }写锁的重入次数是c 0xFFFF读锁的持有数量是c 16。这里有个很关键的工程决策为什么用一个int字段同时管理两把锁因为AQS的state是CAS操作的唯一变量把两把锁的状态合并到一个变量里就可以用一次CAS完成锁状态的原子更新不需要引入两把独立的锁再加一层外部锁来控制次序。这是读写锁性能的基础也直接决定了读写互斥的实现方式。不过这种设计带来了一个隐蔽的限制无论读锁还是写锁重入次数上限都是6553516位无符号整数的最大值。对于正常业务来说一个线程重入同一把锁几万次几乎不可能但你在写循环或递归加锁代码时要留意别在特殊情况下触发溢出。2.2 写锁的获取与释放流程写锁走的是AQS的独占模式逻辑跟ReentrantLock很像但多了一个“读锁是否存在”的判断。获取写锁时核心方法是tryAcquireprotected final boolean tryAcquire(int acquires) { Thread current Thread.currentThread(); int c getState(); int w exclusiveCount(c); if (c ! 0) { if (w 0 || current ! getExclusiveOwnerThread()) return false; if (w exclusiveCount(acquires) MAX_COUNT) throw new Error(Maximum lock count exceeded); setState(c acquires); return true; } if (writerShouldBlock() || !compareAndSetState(c, c acquires)) return false; setExclusiveOwnerThread(current); return true; }这段逻辑拆开看分三种情况state 0说明当前没有任何锁直接尝试CAS获取写锁。state ! 0且写锁计数 0说明当前有线程持有读锁读锁和写锁互斥直接返回false写线程进入AQS等待队列。state ! 0且写锁计数 ! 0说明写锁已被持有。如果持有者是当前线程可以重入否则返回false进入等待队列。写锁释放走的是tryRelease逻辑比较简单把state减去重入数量当写锁计数归零时说明写锁完全释放。注意释放时有一个细节先判断持有者是否是当前线程不是就直接抛IllegalMonitorStateException。这个异常很多人会踩原因就是“持有锁的线程和释放锁的线程不一致”在跨线程传递锁对象时特别容易触发。2.3 读锁的获取与释放流程读锁的获取要复杂得多因为它走的是AQS的共享模式允许多个线程同时持有。tryAcquireShared的核心逻辑是先判断写锁是否被独占protected final int tryAcquireShared(int unused) { Thread current Thread.currentThread(); int c getState(); if (exclusiveCount(c) ! 0 getExclusiveOwnerThread() ! current) return -1; int r sharedCount(c); if (!readerShouldBlock() r MAX_COUNT compareAndSetState(c, c SHARED_UNIT)) { // 记录当前线程的读锁重入次数 ... return 1; } return fullTryAcquireShared(current); }当写锁计数不为0且持锁者不是当前线程时读锁获取直接失败读线程进入等待队列。这就是“读写互斥”的代码级实现。如果读锁获取成功state的低16位会增加一个SHARED_UNIT也就是1 16。这里有一个Java版本演进的小坑在JDK 8之后每个线程的读锁重入次数不再直接存在ThreadLocalHoldCounter里统一管理而是用了一个优化过的HoldCounter链表来减少内存占用。你不需要记这个内部实现细节但面试如果问到“读锁总数量记录在哪里”你要能回答出两层结构state低16位负责统计“所有线程累计持有的读锁次数”ThreadLocalHoldCounter负责统计“单个线程的重入次数”。读锁释放时先取出当前线程的HoldCounter把重入次数减一然后再对state做一次CAS减SHARED_UNIT。只有当线程的重入次数归零时它才算真正释放了读锁。这里有个常见的误解读锁释放不需要判断持有者身份。因为读锁是共享的任何线程都可以释放读锁tryReleaseShared里不会校验owner。但如果你在A线程拿读锁、B线程去释放语义上虽然允许实际业务里却基本是逻辑错误很容易导致状态混乱。2.4 锁降级不降级会怎样锁降级是读写锁最容易讲不清楚的知识点也是面试官喜欢追问的一个点。所谓锁降级指的是“持写锁 - 获取读锁 - 释放写锁”的完整链路。执行成功后线程状态从持写锁变更为持读锁但始终没有释放锁。这样做的目的是保证数据在降级期间的可见性。举一个缓存回源的例子。线程从数据库拿到数据后要更新本地缓存如果直接释放写锁再获取读锁中间会产生一个空窗期在写锁释放、读锁还没获取的瞬间另一个写线程抢到了写锁并修改了数据。此时当前线程再去获取读锁读到的是被其他线程更新后的新数据但当前线程接下来要基于自己刚拿到的旧数据继续执行逻辑就会出现“自己写的数据被别人覆盖、但自己还在用旧值”的问题。通过锁降级当前线程在释放写锁之前先持有读锁就控制了锁的连续性确保自己后续读取的数据是自己刚刚写入的版本。这也是我特别不建议新手绕开这个地方的原因。面试时讲锁降级如果只是背一句“可以降级”基本拿不到分。你把上面的场景讲清楚面试官会知道你真正理解它的价值。后面第3章我会给出一段可以直接运行和参考的完整代码。3. 一个可以在生产落地的本地缓存实现3.1 环境准备与核心代码我用最经典的“本地缓存 读写锁”组合来演示。这个模式在中小型服务里非常常见用来缓存数据库配置、字典数据、白名单等变更频率低的热点数据。环境方面只需要JDK 8以上不需要引入任何第三方依赖。下面是一段可以直接复制的核心代码import java.util.HashMap; import java.util.Map; import java.util.concurrent.locks.ReentrantReadWriteLock; public class LocalCacheDemo { private final MapString, Object cache new HashMap(); private final ReentrantReadWriteLock rwl new ReentrantReadWriteLock(); private final ReentrantReadWriteLock.ReadLock readLock rwl.readLock(); private final ReentrantReadWriteLock.WriteLock writeLock rwl.writeLock(); public Object get(String key) { readLock.lock(); try { return cache.get(key); } finally { readLock.unlock(); } } public void put(String key, Object value) { writeLock.lock(); try { cache.put(key, value); } finally { writeLock.unlock(); } } }代码本身不复杂但有两个细节值得说。第一读锁和写锁必须从同一个ReentrantReadWriteLock实例中获取否则两把锁之间没有任何互斥关系读写锁会完全失效。第二lock()和unlock()之间要用try/finally保证解锁这是雷打不动的规则。读锁持有期间抛出异常如果不在finally里解锁这个线程就会一直占着读锁后续所有写锁请求全部被阻塞整个服务在极端情况下可能雪崩。3.2 读写锁的标准加锁模板从上面的代码可以看到读写锁的加锁模板和ReentrantLock非常相似。我习惯把它固化成一个固定套路对应业务路径上先取对应锁读操作拿readLock()写操作拿writeLock()。执行lock()方法获取锁。用try包裹全部业务逻辑。在finally中调用unlock()。这里要特别强调一个容易被忽略的点读操作虽然允许并发但读锁内的代码要尽量短。很多人以为读锁是“共享的所以无所谓”在持有读锁时执行了耗时的远程调用这会直接拖住后续所有写线程因为写锁会等待所有读锁释放。一旦读锁临界区内出现慢调用写线程就会长时间阻塞进而引发线程池堆积。我自己在项目里曾经遇到过一个问题一个查询接口在持有读锁的情况下调用了远程RPC结果RPC耗时从50毫秒波动到了2秒导致每隔几秒就有一个更新配置的写锁被卡住最终触发接口告警。后来把远程调用挪出读锁临界区问题立刻消失。这是一条很实用的经验锁的范围要尽可能小能锁200毫秒绝不要锁2秒。3.3 缓存加载中的锁降级操作如果缓存接口是“缓存未命中时直接回源数据库”你就需要一个带锁降级的经典写法。直接看代码public Object getWithLoad(String key) { readLock.lock(); try { Object val cache.get(key); if (val ! null) { return val; } } finally { readLock.unlock(); } writeLock.lock(); try { Object val cache.get(key); if (val ! null) { return val; } Object loaded loadFromDB(key); cache.put(key, loaded); // 锁降级在持有写锁的前提下获取读锁 readLock.lock(); try { return loaded; } finally { // 注意这里不能释放读锁要等业务逻辑完全结束后再释放 } } finally { writeLock.unlock(); } }注意上面的写法有一个小坑readLock.lock()之后没有立刻释放而是在外层函数返回前才释放。这个降级的读锁由调用方负责在合适时机释放。实际代码里如果这个函数就是终结方法通常要把这个读锁作为字段或者回调传给后续逻辑否则降级就失去了意义。再强调一次释放顺序必须是“先释放写锁再释放读锁”。如果你先释放读锁再释放写锁那就不叫降级而是单纯的“先释放全部锁再获取读锁”完全无法保证数据可见性。锁降级要解决的就是“获取新锁之前旧锁已经被释放”这个空窗问题。3.4 公平模式参数怎么选ReentrantReadWriteLock有两个构造函数默认是非公平模式public ReentrantReadWriteLock() { this(false); } public ReentrantReadWriteLock(boolean fair) { // ... }fair true时锁会按照等待时间的长短来分配避免写线程被源源不断的读线程饿死。非公平模式下读线程可以插队因此在读操作非常密集的场景下写线程可能长期拿不到锁这是要特别警惕的。从我实际项目经验来看公平模式不是必选但以下两种情况建议开启系统里有定时刷新任务需要定期写入缓存而读流量又持续处于高位。写操作本身对时效性敏感不能无限等待。开启公平模式的代价是吞吐量下降因为每次锁竞争都要检查队列状态。如果写操作的频率很低而且能接受偶尔的延迟我一般先默认用非公平模式再通过监控观察写锁等待时间。一旦发现写锁等待时间中位数持续上升再切换成公平模式。这样能在性能和公平性之间找到一个相对合理的平衡点。4. 常见的坑与排查实录4.1 写锁饿死的排查写锁饿死是读写锁最出名的问题之一。现象是写线程长时间阻塞在writeLock().lock()上迟迟拿不到锁但读线程始终通畅。从线程栈里看写线程处于WAITING状态而大量读线程在持锁执行。我遇到过一次典型的案例某个服务在非公平模式下使用读写锁保护了一个热点Map高峰期读QPS到了2万写线程偶尔需要更新Map。结果更新线程延迟从几毫秒涨到了十几秒服务整体体验下降。用jstack一看更新线程全部堆在写锁的等待队列上。排查过程其实不复杂。先通过jstack确认阻塞位置再确认锁模式。如果确认是非公平模式而且读锁持有时间又比较长基本可以断定是写饥饿。解决的方案有两个一是把锁切换成公平模式让读线程不能无限插队二是缩短读锁临界区减少读线程持锁时间。前一个治标见效快后一个才是根本优化方向。4.2 读锁误用导致的死锁这个坑必须单独说。很多新手写代码在持有读锁的临界区内尝试获取写锁然后程序就卡死了。readLock.lock(); try { // 业务逻辑 writeLock.lock(); // 这里会死锁 try { cache.put(key, value); } finally { writeLock.unlock(); } } finally { readLock.unlock(); }为什么必死读锁是共享的线程A持有了读锁线程B也可能持有读锁。如果线程A试图获取写锁由于存在其他读锁持有者甚至包括自己因为持有读锁再申请写锁这是锁升级A会进入等待队列。而其他读锁持有者不会释放锁因为它们在等待写锁的持有者释放锁其实它们可能在等待业务完成反正就是互相等形成一个不可解除的环。结论很简单ReentrantReadWriteLock不支持锁升级。如果你需要“先读后写”的语义就必须先释放读锁再获取写锁。但这种先释放再获取的设计也有风险释放读锁后、获取写锁前的间隙里数据可能已经被其他线程改掉了。这种场景要么接受这个中间的并发窗口要么改用其他机制比如StampedLock的乐观读或者干脆用ConcurrentHashMap的复合操作加自旋校验。4.3 锁降级和重入的细节关于锁降级有几个细节经常被忽略同一个线程已经持有读锁再次获取读锁是可以的重入次数会累加。同一个线程已经持有写锁再次获取写锁也可以重入次数会累加。同一个线程持有写锁后获取读锁也就是降级允许。同一个线程持有读锁后获取写锁也就是升级不允许会死锁。重入计数对状态计算影响很大。state高16位记录写锁重入次数低16位记录总读锁持有数量。如果对同一个key反复嵌套加读锁释放时也要对应释放相同次数。释放次数少于获取次数锁就泄漏了释放次数多于获取次数会抛IllegalMonitorStateException。4.4 常见问题速查表我把平时被问得最多的几个问题整理成一张表方便对照问题现象原因与解法写线程长时间阻塞更新接口RT飙升非公平模式下读线程插队写锁饥饿。解法使用fairtrue或缩短读锁临界区持有读锁时获取写锁程序卡死锁升级不允许需先释放读锁再抢写锁或改用StampedLock乐观读解锁时抛IllegalMonitorStateException异常中断解锁线程不是锁持有者。检查是否在释放读锁时误用了写锁unlock读锁临界区执行慢调用写线程被拖死读锁内只放内存操作远程调用必须移出临界区锁降级后读锁没人释放读锁一直不释放降级获取的读锁必须保证在后续逻辑中释放通常用finally包裹5. 与其他锁的分工与取舍5.1 与synchronized和ReentrantLock怎么选synchronized和ReentrantLock都是独占锁适合临界区短、写操作频繁、代码逻辑不太复杂的场景。它们的优点是没有读写锁这种“读读共享”的复杂度逻辑简单、不容易写错。但缺点是读操作也必须排队读密集场景性能较差。ReentrantReadWriteLock则是在“读多写少”场景下的专门优化。它用了一套更复杂的锁状态管理机制换取了并发读能力。你要付出的代价是代码理解难度上升、锁升级陷阱多、写线程有饥饿风险。所以选型时不要为了“高级”而用读写锁。如果业务里读写比接近1:1或者写操作本身非常频繁读写锁很可能比独占锁还慢因为读锁和写锁状态计算本身也有开销。5.2 进程内锁与分布式锁的边界很多面试题会混着问ReentrantReadWriteLock和分布式锁需要把边界说清楚。ReentrantReadWriteLock是JVM进程内的锁只能协调同一个进程内的线程。一旦你的服务部署了多个实例每个实例都有自己的一份锁状态进程内锁就完全失效了。这时候需要分布式锁比如基于Redis的分布式锁或数据库锁来协调跨JVM的资源访问。但分布式锁的粒度通常是“独占”它没有读写锁这种精细的读读共享能力。如果你在多个实例之间需要“多个实例同时读、只允许一个实例写”的语义用现成分布式锁一般只能做到完全互斥。想实现类读写锁的效果就得在分布式锁基础上自己做状态管理比如维护一个共享计数器和Lease机制复杂度会明显上升。所以在系统设计时要想清楚到底需要进程内的读写并发还是跨进程的排他控制两者的工具完全不同。5.3 StampedLock能不能替代它JDK 8引入了StampedLock它提供了三种模式写锁、读锁和乐观读。乐观读不阻塞写锁在没有写竞争时会非常快性能往往优于ReentrantReadWriteLock。但StampedLock有两个硬伤不可重入且不支持条件变量。如果你需要重入语义直接排除StampedLock。StampedLock的乐观读适合“读操作非常频繁、写操作非常稀疏、数据不一致时可以接受短暂旧值”的场景。注意乐观读不持有真正的锁在读的过程中如果发生了写操作需要用validate验证版本号验证失败再升级为普通读锁或重新读取。它比读写锁更灵活但使用门槛更高。如果团队平均经验一般我建议先用ReentrantReadWriteLock它逻辑清晰踩坑成本可控。6. 一些实战中的个人经验从实际调优的角度说几句掏心窝的话。第一ReentrantReadWriteLock最大的价值不是“高级感”而是解决特定比例的读写混合访问。落地前先量化你的业务读写比例最好用日志或监控把读QPS和写QPS统计一下。如果写比例超过20%读写锁的收益就不明显了还不如直接用独占锁。第二排查锁问题别靠猜用工具看。jstack能看到线程状态配合锁对象的等待队列信息基本能锁定是写饥饿还是死锁。我排查线上问题时通常先抓三份线程快照间隔5秒对比阻塞线程是否变化就能快速判断是否真的死锁。第三写缓存场景时我个人的经验是优先用ConcurrentHashMap加版本号方式简化设计。只有当临界区里需要“复合逻辑”比如“读多个字段再组合返回”或者需要保证更新期间读请求不能看到中间状态时才值得引入读写锁。能用无锁容器解决的就别上锁这是并发编程的基本原则。第四所有加锁的代码都建议做一次code review重点检查三点锁是否从同一个ReentrantReadWriteLock实例获取、是否用finally释放锁、是否出现了读锁内嵌套写锁。这三条是读写锁使用中最高频的错误来源。最后分享一个小技巧如果你需要在读锁临界区里判断是否需要回源更新可以先在锁外快速检查一次缓存命中就直接返回这样可以大幅减少进入写锁的概率。这个“先读后锁”的优化配合合适的缓存过期策略可以把写锁竞争的频率降低一个数量级非常实用。
返回列表