ReentrantReadWriteLock 锁降级:面试背答案的人,写不出这种死锁
场景:缓存组件使用 ReentrantReadWriteLock 做读写分离,写锁降级读锁时所有线程卡死 路径:现象 → 复现 → 排查路径 → AQS 源码 + CLH 队列 → 代码审查标记
上篇讲了 ReentrantLock 与 synchronized 的性能选型陷阱(lock-contention-perf-myth),这篇我们来看同一个家族的读写锁——ReentrantReadWriteLock 的锁降级死锁。
现象:凌晨三点,缓存组件卡死在"降级"这两个字上
你写过这段代码吗?
一个缓存服务,用 ReentrantReadWriteLock 做读写保护。线程 A 拿到写锁,更新数据。线程 B 也想写,按规矩在队列里等。
天经地义对吧?
但 A 拿到写锁后,想先降级成读锁再返回。于是调了 readLock().lock()——然后不动了。B 在队列里等着写锁,A 在队列里等着读锁。谁也不让谁。
你以为的降级:写锁 → 读锁 → 返回。
实际上的降级:写锁 → 卡死 → 天亮还在卡。
线程 dump 现场

线程 dump 会看到 A 和 B 都在 WAITING (parking):
Thread A: park → acquireQueued → acquireShared → readLock.lock (持有 writeLock)
Thread B: park → acquireQueued → acquire → writeLock.lock
两个线程都是 WAITING,没有死锁圈——但结果就是死锁。为什么?因为这个"锁"不是你以为的那把锁——ReadLock 和 WriteLock 虽然在一个对象上,但它们在 AQS 队列里的竞争规则完全不同。
还原:3 行代码的噩梦
最小复现代码
下面是最小复现代码(JDK 8+,非公平锁模式):

复现条件:2 个以上线程同时调 readOrWrite,其中一个先拿到写锁,另一个入队等写锁。非公平模式下 100% 重现。
直觉冲击——"锁降级不是 JDK 官方的吗?"
对,JDK 文档确实给了一个锁降级示例。但那套代码的前提是:从读锁升级到写锁,再降级回读锁。而我们写的模式是直接拿写锁→想降级读锁。
这是两回事。
前者是一个"提升特权再收回"的流程,后者是"我已经是最高特权了,想降级却被拦住了"——拦住你的不是锁本身,是 AQS 队列里排在你前面的那个人。
排查路径:看到 writeLock().lock() + readLock().lock() 在一起就查
三步锁定根因

jstack <pid>
└─ 看线程堆栈——持 writeLock 的线程在等 readLock
(两把锁出现在同一个线程的堆栈里)
└─ jstack -l <pid> 看锁信息
└─ "Locked by" 指向谁
└─ 打开源码
└─ 搜索方法体内同时出现 writeLock.lock() 和 readLock.lock() 的地方
└─ 检查:writeLock 释放前,readLock 真的能拿到吗?
错误的直觉 vs 正确的排查
| 直觉 | 真相 |
|---|---|
| "有 WAITING 就是死锁" | 不是循环等待,是单点阻塞 |
| "readLock 在 writeLock 块里是标准的降级" | 标准降级是从读→写→读,不是写→读 |
| "调了 readLock.lock() 就能拿到" | 如果队列头部有写者等待,读锁获取会阻塞 |
解读:AQS 队列里的无声谈判
tryAcquireShared——读锁从哪进的 AQS
// ReentrantReadWriteLock.Sync.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) {
// CAS 获取读锁
}
return fullTryAcquireShared(current);
}
这个方法第一眼看过去好像没什么问题——当前线程已经持有了写锁(getExclusiveOwnerThread() == current),所以不会走 return -1。那为什么还会阻塞?
答案在第 7 行:readerShouldBlock()。
让面试官沉默的 apparentlyFirstQueuedIsExclusive
// NonfairSync 的 readerShouldBlock
final boolean readerShouldBlock() {
return apparentlyFirstQueuedIsExclusive();
// AQS 队首第一个等待者是等写锁 → 让读锁先等着
}
这个方法很简单:如果队列头部的第一个等待者是在等写锁,就返回 true。
为什么要有这个设计?防止写者饥饿。
如果没有这个检查,读锁可以无限插队——因为读锁是共享模式,一个读锁进来了可以再进一批。如果写锁永远排在读锁后面,它可以等到天荒地老。
所以 AQS 做了一个妥协:如果队列头部是写者在读等待,新来的读请求必须排队。这个妥协在绝大多数场景下是好的——但当你拿着写锁去抢读锁的时候,就成了死锁。
你可以看见死锁的那一刻:CLH 队列内部结构

T1 是写锁的持有者(exclusiveOwnerThread),所以它不在队列里。T2 想拿写锁,排在队列头部,生成一个 EXCLUSIVE 节点。
T1 调用 readLock().lock() 想降级——apparentlyFirstQueuedIsExclusive() 看到 head.next 是 T2(EXCLUSIVE)→ 返回 true → T1 也必须在队列里等。
于是 T1 生成一个 SHARED 节点,排到 T2 后面。
这就是死锁链条:
T1 同时卡在两个地方:
▶ 作为 exclusiveOwnerThread,拿着 writeLock
▶ 作为 SHARED 队列节点,在等 readLock
而 T2 排在 T1 前面:
▶ 作为 EXCLUSIVE 队列节点,在等 writeLock
T1 拿不到 readLock → 不能执行到最后 → writeLock 永远不释放 → T2 永远拿不到 writeLock。
这不是循环等待——这是单线程同时扮演了狱卒和囚犯。

正确的锁降级写法
JDK 的 ReentrantReadWriteLock 文档给出的降级模式是这样的:

区别在哪?
左边的错误代码是:拿写锁 → 拿读锁(死锁点)→ 释放写锁。
右边的正确模式是:拿读锁 → 释放读锁 → 拿写锁 → 降级拿读锁 → 释放写锁 → 最后释放读锁。
关键差异:正确模式先拿了读锁再升级到写锁,这样第一步就没有写者在等——因为当前线程已经持有了读锁。
而在你的场景中,"获取写锁 → 更新 → 降级读锁返回"这个模式,如果并发量较高,永远有别的线程在等写锁——那么降级操作就会阻塞。这时候不要做写锁持有中的读锁获取,而是直接释放写锁,另起一次读锁获取。
这个行为和 JDK 版本无关
我在 JDK 8、11、17、21 上都测试过,行为一致。它不源于某个版本的 bug——它源于 AQS 防止写者饥饿的设计本身。不管你的 JDK 版本有多新,只要你的读写锁是非公平模式(默认),这个场景就存在。
标记:搜这两个模式
审查命令与清单
在项目中 grep 检查是否存在锁降级死锁隐患:

审查清单:
| 检查项 | 违规表现 | 风险等级 |
|---|---|---|
writeLock().lock() 后直接 readLock().lock()(中间无 writeLock().unlock()) |
锁降级死锁 | 🔴 高 |
读写锁 + @Transactional 混用 |
锁范围 > 事务范围,死锁风险 | 🔴 高 |
readLock().lock() 前有 writeLock().unlock() |
正确顺序,无死锁风险 | 🟢 安全 |
readLock().lock() → writeLock().lock()(升级) |
无法获取(读锁升级写锁不支持) | 🟡 中 |
金句:"锁降级不是语法糖——它是 AQS 队列里的一场无声谈判。你以为降级是通行证,其实队列头部的等待者才是仲裁者。"
下篇我们聊 AQS 源码级分析:从一次锁等待排查说起,看 AQS 队列到底是怎么管理这些等待中的线程的。
📺 公众号「Ai拆代码的曹操」 🌟 知识星球「Ai拆代码的曹操」