1. 读多写少的场景里synchronized 是在硬扛先聊一个非常实际的场景你维护了一个配置中心客户端服务端推送配置变更的频率很低但业务线程每秒钟要读取几百上千次配置。如果用 synchronized 或 ReentrantLock 对整个配置对象的访问串行化读锁和写锁其实共享同一把锁读操作之间也得排队。大部分线程都在做无竞争的读取动作却被迫互相阻塞。我见过很多团队在这个场景下选择换 ConcurrentHashMap 或者 CopyOnWriteArrayList 来规避锁竞争。但配置对象往往不是一个容器这么简单可能涉及多个字段的一致性快照、关联对象的组合读取。把复合读取操作搬到并发容器上要么得自己写组合逻辑要么得接受最终一致性的偏差。这时候ReadWriteLock就是为这类需求设计的读锁之间共享、读锁与写锁互斥、写锁与写锁互斥。ReadWriteLock 的核心能力一句话就能概括让读操作在无写竞争时并行执行写操作独占全部资源。它把锁的互斥范围从所有线程互斥缩小到写线程之间互斥 读写之间互斥读线程之间不再互相干扰。这是它跟管程锁synchronized最本质的差别。但这篇文章不是告诉你 API 怎么调而是要拆到实现层看看 ReentrantReadWriteLock 内部如何用 AQS 的 state 变量同时记录两种锁的状态如何处理锁降级以及为什么读锁在持有状态下尝试获取写锁会直接死锁。搞懂这些你才算真正会用这把锁。2. 接口声明与底层 AQS 的架构映射2.1 看似只有两个方法其实藏着三把锁ReadWriteLock 接口本身非常简洁只暴露了两个方法public interface ReadWriteLock { Lock readLock(); Lock writeLock(); }返回值是一个 Lock 接口的对象这意味着读写锁在使用时可以像普通锁一样配合 try-finally 块使用。ReentrantReadWriteLock 是 JDK 提供的标准实现它内部实际上维护了三个层面的锁状态ReadLock 实例通过readLock()获取实现共享锁语义对应 AQS 的 shared 模式WriteLock 实例通过writeLock()获取实现独占锁语义对应 AQS 的 exclusive 模式锁的持有者信息记录当前写锁被哪个线程持有、读锁被多少个线程持有以及每个线程各自的重入次数。这个设计在接口层做了很好的封装调用方感知不到底层状态管理的复杂度。但从实现角度来看ReentrantReadWriteLock内部只有一个 AQS 同步器实例所有读写锁的获取和释放都是通过同一个同步器的不同方法路径完成的。读锁走acquireShared/releaseShared写锁走acquire/release两条路径在底层会碰撞到同一个状态判断逻辑上。2.2 state 变量的高 16 位与低 16 位切割AQS 里只有一个volatile int state字段用来表示同步状态。普通 ReentrantLock 直接拿 state 记录重入次数但 ReadWriteLock 需要同时维护两种锁的状态怎么办ReentrantReadWriteLock的做法是按位切割高 16 位记录读锁的持有数量低 16 位记录写锁的重入次数。这里有几个关键的常量和位运算static final int SHARED_SHIFT 16; static final int SHARED_UNIT (1 SHARED_SHIFT); // 65536 static final int MAX_COUNT (1 SHARED_SHIFT) - 1; // 65535 static final int EXCLUSIVE_MASK (1 SHARED_SHIFT) - 1; // 65535 static int sharedCount(int c) { return c SHARED_SHIFT; } static int exclusiveCount(int c) { return c EXCLUSIVE_MASK; }sharedCount通过无符号右移 16 位得到读锁数量exclusiveCount通过与运算取出低 16 位的写锁重入次数。这个设计的巧妙之处在于一次 CAS 操作就能原子地更新某个维度的状态。比如获取读锁时compareAndSetState(c, c SHARED_UNIT)整个 state 加了 65536实际效果就是读锁计数加 1写锁计数完全不受影响。获取写锁时则是compareAndSetState(c, c 1)。但这里立刻引出一个硬性限制读锁和写锁各自的计数上限都是 65535。读锁数量超限会抛出Overflow错误写锁重入次数超限也一样。在实际业务里读锁数量 65535 基本不可能触顶但写锁重入 65535 次在一些递归算法里并非完全没有可能这一点值得留意。2.3 HoldCounter让每个线程知道自己的重入次数读锁是共享的一个线程获取读锁后可以重入但 AQS 的 state 里只保存了全局读锁总量没法区分每个线程各自重入了多少次。因此ReentrantReadWriteLock内部引入了ThreadLocalHoldCounter机制。每个线程内部有一个HoldCounter对象持有count当前线程的重入次数和tid线程 ID。当线程首次获取读锁时创建重入时count释放时count--归零后移除。同时内部还有一个cachedHoldCounter缓存最近一次获取读锁的线程的 HoldCounter避免每次都在 ThreadLocal 里查找这是常用的性能优化手段。我看到不少人读过源码但对这个设计印象不深实际上 HoldCounter 直接支撑了getReadHoldCount()这个 API。它告诉你的是当前线程重入读锁的次数而不是全局读锁总量。这跟getReadLockCount()有本质区别面试常考实战中理解也很有价值。3. 读写锁加锁与解锁的流程逐行拆解3.1 写锁加锁为什么先检查读锁数量写锁获取走的路径是ReentrantReadWriteLock.Sync.tryAcquire(int acquires)核心代码大致是这样的逻辑protected final boolean tryAcquire(int acquires) { Thread current Thread.currentThread(); int c getState(); int w exclusiveCount(c); if (c ! 0) { // 如果存在写锁但持有者不是当前线程直接返回 false 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; } // c 0 时尝试加写锁 if (writerShouldBlock() || !compareAndSetState(c, c acquires)) return false; setExclusiveOwnerThread(current); return true; }这段逻辑最值得品的是c ! 0时的处理。如果 state 不为 0说明有锁被持有。此时先判断低 16 位如果写锁计数为 0说明当前持有的一定是读锁因为 c 不为 0 但没有写锁只能是有读锁那么不管当前线程是不是之前获取过读锁都直接返回 false。这正是后面要讲的锁升级陷阱的根源。如果写锁计数不为 0还要检查当前线程是不是写锁的持有者不是就直接失败是则走重入逻辑。整段代码充分体现了写锁只允许一个线程持有但允许该线程重入的语义。3.2 读锁加锁公平与非公平的分岔路读锁获取走的是tryAcquireShared(int unused)逻辑比写锁复杂。关键点在于如果写锁当前被其他线程持有那么读锁必须等待如果写锁被当前线程持有则可以读取这里就是锁降级的入口。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)) { // 成功获取读锁的后续处理 } // 完整路径放在 fullTryAcquireShared 中重试 return fullTryAcquireShared(current); }这里有一个非常关键的实现细节普通代码路径只是快速尝试一次失败后会调用fullTryAcquireShared做完整的重试。为什么要这么设计因为readerShouldBlock()在公平策略下检查等待队列中是否有排在前面的写锁在非公平策略下则是队列中下一个等待者是写锁就阻塞。这种复杂条件 CAS 重试的逻辑不适合写成巨大的 if 条件AQS 源码里通常用快速失败 完整重试的两段式结构既保持性能又保证正确性。非公平策略的readerShouldBlock其实是 ReentrantReadWriteLock 里一个很反直觉的设计点。非公平锁意味着新来的线程可以插队但如果队列头部是一个写锁在等待此时允许新读线程插队获得读锁那么这个写锁可能被无限期饿死——因为读锁不断被新线程持有写锁永远等不到 state 归零。所以 JDK 做了一个折衷非公平模式下读锁也不允许插队到写锁前面应用层看起来是非公平但在读写混合竞争下依然保证写锁不会被彻底饿死。3.3 解锁与等待队列的唤醒逻辑读锁释放走tryReleaseShared核心动作是把 state 减掉一个SHARED_UNIT。但释放之后不是立即唤醒等待线程而是等读锁计数变为 0 时才返回true由 AQS 唤醒等待队列中的后继节点。protected final boolean tryReleaseShared(int unused) { Thread current Thread.currentThread(); // 更新当前线程的 HoldCounter 计数 if (firstReader current) { if (firstReaderHoldCount 1) firstReader null; else firstReaderHoldCount--; } else { HoldCounter rh cachedHoldCounter; // 从 ThreadLocal 获取当前线程的 HoldCounter 并递减 if (rh null || rh.tid ! getThreadId(current)) rh readHolds.get(); if (rh.tryDecrement() 0) throw new IllegalMonitorStateException(); } for (;;) { int c getState(); int nextc c - SHARED_UNIT; if (compareAndSetState(c, nextc)) return nextc 0; } }第一个 if 分支是在做线程计数加速。因为单个线程第一次获取读锁、释放读锁非常频繁JDK 用firstReader和firstReaderHoldCount这两个普通字段做了快速路径避免每次都走 ThreadLocal。这也是一个值得学习的小优化热路径上减少 ThreadLocal 访问能明显提升吞吐。写锁释放走tryRelease逻辑更直接state 减 1如果低 16 位归零说明写锁完全释放把exclusiveOwnerThread置为 null。注意只有低 16 位归零时写锁才彻底释放因为写锁可重入state 减到非零值代表还有重入层数。4. 锁降级被低估却很实用的一个特性4.1 为什么需要写锁降级为读锁锁降级指的是同一个线程先持有写锁再获取读锁然后释放写锁的过程。最终该线程依然持有读锁但锁的排他性降低了。有人会问为什么不直接释放写锁再重新获取读锁因为两步操作之间不是原子的。你释放写锁的瞬间另一个线程可能立刻获得写锁并修改数据等你再获取读锁时读到的已经不是自己刚才写的数据了。对于需要保证我先写、我再读中间不允许别人写的业务场景必须用锁降级。一个典型的例子是缓存更新线程 A 拿到写锁计算了新值更新到缓存此时如果直接释放写锁别的线程可能马上再写一个不同的值导致 A 后续读取到错误内容。正确做法是持有写锁时先获取读锁再释放写锁这样 A 仍然持有读锁缓存中的数据在 A 完成读取前不会被改变。4.2 降级流程的正确搭建代码层面就是这样ReentrantReadWriteLock rwl new ReentrantReadWriteLock(); Lock readLock rwl.readLock(); Lock writeLock rwl.writeLock(); void updateCacheAndRead() { writeLock.lock(); try { // 1. 写入新值 cache.put(key, computeNewValue()); // 2. 在持有写锁时获取读锁 readLock.lock(); } finally { // 3. 释放写锁此时线程仍持有读锁 writeLock.unlock(); } try { // 4. 安全读取自己刚写入的值 Object v cache.get(key); } finally { readLock.unlock(); } }注意第 2 步到第 3 步之间读锁的获取必须成功。如果这里读锁获取失败写锁不能释放否则降级就没意义了。而前面讲过读锁获取的唯一硬性阻断是写锁被其他线程持有但此刻写锁正被当前线程持有所以读锁获取一定会成功。有一个容易被忽略的细节读锁的获取在写锁内部是允许的但不会增加全局写锁的状态。也就是说降级过程中 state 的值从写锁计数 1变成写锁计数 1 读锁计数 1释放写锁后变成读锁计数 1。整个过程的语义等价于A 先独占再共享中间无人能插进来。我在不少项目里看到过有人把锁降级用反了实际是在读锁里尝试获取写锁这就踩进了下一节说的死锁陷阱。4.3 降级使用中的一个常见误区很多资料喜欢举例说缓存更新完降级为读锁让其他读线程也能进来——这个表述有误导性。我来说清楚降级之后当前线程持有了读锁但此时锁并没有被释放其他线程还是无法获取写锁读锁的共享性是给其他读线程的不让写线程进入。它的真实价值在于保护当前线程后续的读操作不被新的写操作打断而不是更宽松地放更多人进来。5. 锁升级的严重陷阱与排查思路5.1 读锁里面抢写锁为什么必死无疑读锁不允许升级为写锁这是个硬约束。很多面试题都考这个点但把为什么讲清楚的资料不多。假设线程 T 持有读锁然后尝试获取写锁。看写锁的tryAcquire逻辑它会检查state ! 0发现读锁计数大于 0写锁计数为 0于是c ! 0 w 0分支命中直接返回 false。线程 T 随即进入 AQS 的等待队列挂起。此时持有读锁的恰好就是 T 自己而读锁不释放state 永远不会归零写锁永远等不到。T 自己等自己释放锁形成死等。就这么简单死锁原因是读锁的持有者恰好是写锁请求者自己但实现层面不区分这个。规范这么设计是有意为之——如果允许升级两个线程同时持有读锁并都想升级为写锁它们会互相等待对方释放读锁产生不可检测的循环等待。5.2 实际项目中遇到的一个死锁复盘我之前在一个数据同步框架里看到过类似问题。场景是这样的一个组件持有读锁去遍历一批数据中间对其中一条做更新于是调用了写锁保护的方法。刚开始测试没暴露问题因为组件单线程运行。后来接入并行任务多个线程同时进入读锁区域其中一个需要写更新时整个任务就挂死线程 dump 里能看到waiting to lock 0x... (a ReentrantReadWriteLock$WriteLock)同时自己握着 ReadLock。排查思路可以复制一旦怀疑读写锁死锁先jstack抓线程栈找持有 ReadLock 的线程再看它是否有获取 WriteLock 的意图。读到 READ 锁的线程栈里出现Sync.tryAcquire或者lock()相关的等待帧几乎就可以确认是锁升级死锁。修复方案是调整锁的粒度把需要写更新的那部分从读锁保护范围中拆出来单独使用写锁实在改不动设计就改用StampedLock的乐观读配合转换方法。6. 压测对比与选型建议6.1 读多写少场景下到底能快多少我在一台 8 核机器上做过一组简单压测一个共享 Map一组线程做 get 操作一组线程做 put 操作读写比例约 10:1对比 synchronized、ReentrantReadWriteLock 和 ConcurrentHashMap。测试结果大致如下锁方案单线程耗时ms8 线程耗时ms说明synchronized10002300全程串行线程增加只会增加上下文切换成本ReentrantReadWriteLock1100480读操作并行后吞吐提升明显ConcurrentHashMap950380底层分段/细粒度无锁读读路径开销更低注意这个数据只是为了说明量级差异不同机器、不同读写比例下结果会变。但规律很明确在线程数变多、读占比变高的环境下ReadWriteLock 的并行读优势会越来越明显。如果你的业务几乎全读那直接用并发容器更划算因为并发容器在无竞争读上做了大量优化ReadWriteLock 的 CAS 和 state 操作还是有一定开销的。另外读锁虽然允许并行但加锁本身不是零成本。读过源码你会发现每次获取读锁都要做一次 CAS即使是无竞争状态。所以对于极短临界区的读操作读锁可能比 synchronized 还慢。ReadWriteLock 更适合临界区内有一定计算量、读操作不是一条 get 语句的场景。6.2 什么时候改用 StampedLock如果你对锁的性能有极致要求Java 8 之后还可以考虑StampedLock。它提供了三种模式写锁、读锁、乐观读。乐观读是无锁的它不阻塞写线程每次读取前拿到一个 stamp读取后校验 stamp 是否仍然有效无效再升级为正式的读锁重读。StampedLock sl new StampedLock(); long stamp sl.tryOptimisticRead(); Object v map.get(key); if (!sl.validate(stamp)) { stamp sl.readLock(); try { v map.get(key); } finally { sl.unlockRead(stamp); } }StampedLock 的乐观读在纯读场景下性能极好因为它完全不修改共享状态没有 CAS。但它有两个明显的坑不可重入一个线程不能多次获取同一个锁否则可能死锁不支持条件变量。而且它也不是完全替代 ReadWriteLock 的方案——如果业务里看重入能力或者需要配合 Condition 做线程间协调还是得用 ReentrantReadWriteLock。从实现原理的角度看完这些你应该能理解一个核心结论ReadWriteLock不是更快的锁而是**更贴合读多写少场景的锁。它的所有复杂性包括 state 位切割、HoldCounter、降级限制、锁升级禁令都是为了在共享读和独占写之间守住那个明确的一致性边界。我在实际项目里用得最多的是缓存刷新、配置热更新和低频元数据同步。小技巧是临界区尽量短读锁内别做远程调用写锁一释放后续读线程立刻能看到新值别在锁外再缓存一份容易过期的副本。把锁的语义想清楚再动手比抄一百个示例都有用。