ReentrantReadWriteLock 读写锁
2025/12/20大约 6 分钟约 1930 字
基于 JDK 1.8 源码的读写锁核心原理分析。
一、为什么要造这个轮子?
1.1 ReentrantLock 的痛点
假设你有一个缓存,100 个线程读,1 个线程偶尔写。如果用 ReentrantLock:
// 悲剧:读也要排队!
lock.lock();
try {
return cache.get(key); // 明明只是读,也要等锁
} finally {
lock.unlock();
}问题:读操作之间本不冲突,却被迫互斥。100 个读线程排队,严重浪费性能。
1.2 读写锁的核心目标
读-读:可以并发 ✓
读-写:互斥 ✗
写-写:互斥 ✗本质:读操作共享,写操作独占。读多写少场景下,性能大幅提升。
二、核心黑科技:State 的"一刀切"
2.1 一个 int 存两个状态
AQS 只有一个 int state,但读写锁需要记录:
- 写锁状态(独占)
- 读锁状态(共享)
解决方案:把 32 位一刀切成两半!
|<-------- 32 位 int -------->|
| 高 16 位 | 低 16 位 |
| 读锁计数 | 写锁计数 |2.2 源码:位运算的骚操作
abstract static class Sync extends AbstractQueuedSynchronizer {
// 偏移量:16 位
static final int SHARED_SHIFT = 16;
// 读锁单位:每次 +1 实际是 state + 65536 (即 1 << 16)
static final int SHARED_UNIT = (1 << SHARED_SHIFT);
// 最大持有数:65535 (16位能表示的最大值)
static final int MAX_COUNT = (1 << SHARED_SHIFT) - 1;
// 写锁掩码:低 16 位全是 1
static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1;
/** 获取读锁计数:state 右移 16 位 */
static int sharedCount(int c) {
return c >>> SHARED_SHIFT; // 无符号右移,取高 16 位
}
/** 获取写锁计数:state 与掩码做 AND 运算 */
static int exclusiveCount(int c) {
return c & EXCLUSIVE_MASK; // 取低 16 位
}
}图解:
假设 state = 0x00020003 (十六进制)
二进制:0000 0000 0000 0010 | 0000 0000 0000 0011
|<--- 高 16 位 --->| |<--- 低 16 位 --->|
| 读锁计数 = 2 | | 写锁计数 = 3 |
sharedCount(state) = state >>> 16 = 2 (有 2 个线程持有读锁)
exclusiveCount(state) = state & 0xFFFF = 3 (写锁重入 3 次)为什么是 16 位?
- 32 位平分,简单高效
- 65535 次重入足够用(谁会重入这么多次?)
三、写锁的实现:霸道总裁
性格:独占、霸道,必须一个人独享。
3.1 获取写锁源码
protected final boolean tryAcquire(int acquires) {
Thread current = Thread.currentThread();
int c = getState();
int w = exclusiveCount(c); // 取低 16 位:写锁计数
// 情况1:state != 0,说明有人在用锁(读或写)
if (c != 0) {
// w == 0 说明低 16 位是 0,但 c != 0
// 那肯定是高 16 位不为 0,即:有人持有读锁!
// 读写互斥,直接失败
// w != 0 说明有写锁,但不是我(current != owner)
// 写写互斥,直接失败
if (w == 0 || current != getExclusiveOwnerThread())
return false;
// 走到这里:w != 0 且是我自己持有
// 检查重入次数是否溢出
if (w + exclusiveCount(acquires) > MAX_COUNT)
throw new Error("Maximum lock count exceeded");
// 写锁重入:低 16 位 +1
setState(c + acquires);
return true;
}
// 情况2:state == 0,锁完全空闲
// writerShouldBlock:公平锁检查队列,非公平锁直接返回 false
if (writerShouldBlock() ||
!compareAndSetState(c, c + acquires))
return false;
// CAS 成功,设置 owner
setExclusiveOwnerThread(current);
return true;
}3.2 核心逻辑拆解
if (c != 0) {
if (w == 0 || current != getExclusiveOwnerThread())
return false;
}| 条件 | 含义 | 结果 |
|---|---|---|
c != 0 且 w == 0 | 有读锁,没写锁 | 读写互斥,失败 |
c != 0 且 w != 0 且 current != owner | 别人持有写锁 | 写写互斥,失败 |
c != 0 且 w != 0 且 current == owner | 自己持有写锁 | 重入成功 |
c == 0 | 完全空闲 | CAS 抢锁 |
四、读锁的实现与记账:ThreadLocal 的极致优化
难点:读锁是共享的,可能多个线程同时持有。那每个线程持有几次(重入),怎么记?
4.1 记账本:HoldCounter
/**
* 每个线程的读锁持有计数器
* 类似于:{ 线程ID -> 重入次数 }
*/
static final class HoldCounter {
int count = 0; // 该线程持有读锁的次数
final long tid = getThreadId(Thread.currentThread()); // 线程 ID
}
/**
* ThreadLocal 的子类,初始值是一个新的 HoldCounter
*/
static final class ThreadLocalHoldCounter
extends ThreadLocal<HoldCounter> {
public HoldCounter initialValue() {
return new HoldCounter();
}
}4.2 三级缓存优化
直接用 ThreadLocal.get() 太慢!JDK 做了优化:
abstract static class Sync extends AbstractQueuedSynchronizer {
// 一级缓存:第一个获取读锁的线程(直接记,最快)
private transient Thread firstReader = null;
private transient int firstReaderHoldCount;
// 二级缓存:最后一个成功获取读锁的线程(大概率下次还是它)
private transient HoldCounter cachedHoldCounter;
// 三级缓存:兜底 ThreadLocal
private transient ThreadLocalHoldCounter readHolds;
}为什么这么做?
场景:读多写少,且往往是同一个线程反复读
线程A 第一次读:存入 firstReader(一级缓存)
线程A 第二次读:命中一级缓存,直接 +1
线程B 第一次读:存入 cachedHoldCounter(二级缓存)
线程B 第二次读:命中二级缓存
只有缓存都没命中时,才去查 ThreadLocal4.3 获取读锁源码(核心逻辑)
protected final int tryAcquireShared(int unused) {
Thread current = Thread.currentThread();
int c = getState();
// 检查1:有写锁,且不是自己持有 → 失败
if (exclusiveCount(c) != 0 &&
getExclusiveOwnerThread() != current)
return -1; // 读写互斥
int r = sharedCount(c); // 读锁计数
// 检查2:读锁是否需要阻塞(公平锁看队列)
// 检查3:读锁计数是否溢出
// 检查4:CAS 更新 state
if (!readerShouldBlock() &&
r < MAX_COUNT &&
compareAndSetState(c, c + SHARED_UNIT)) {
// CAS 成功,更新记账
if (r == 0) {
// 情况A:我是第一个读者 → 存一级缓存
firstReader = current;
firstReaderHoldCount = 1;
} else if (firstReader == current) {
// 情况B:我就是第一个读者(重入) → 一级缓存 +1
firstReaderHoldCount++;
} else {
// 情况C:不是第一个读者,查二级缓存
HoldCounter rh = cachedHoldCounter;
if (rh == null || rh.tid != getThreadId(current))
// 二级缓存没中,查 ThreadLocal 并更新缓存
cachedHoldCounter = rh = readHolds.get();
else if (rh.count == 0)
// 防止 remove 后再重入导致问题
readHolds.set(rh);
rh.count++;
}
return 1; // 成功
}
// 走自旋重试逻辑(省略)
return fullTryAcquireShared(current);
}4.4 缓存命中流程
线程来了要读锁
│
▼
是第一个读者?──是──> 存 firstReader(一级缓存)
│
否
│
▼
我就是 firstReader?──是──> firstReaderHoldCount++(一级缓存)
│
否
│
▼
cachedHoldCounter 是我?──是──> rh.count++(二级缓存)
│
否
│
▼
readHolds.get()(三级缓存,查 ThreadLocal)五、锁降级与死锁
5.1 什么是锁降级?
写锁 → 读锁:在持有写锁期间,先获取读锁,再释放写锁。
ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
Lock readLock = rwl.readLock();
Lock writeLock = rwl.writeLock();
// 正确姿势:锁降级
writeLock.lock();
try {
// 1. 修改数据
data = newValue;
// 2. 降级:先获取读锁(还持有写锁)
readLock.lock();
} finally {
// 3. 释放写锁(此时仍持有读锁)
writeLock.unlock();
}
try {
// 4. 以读锁身份继续访问数据
return data;
} finally {
readLock.unlock();
}为什么要锁降级?
如果直接释放写锁再获取读锁,中间可能被其他写线程插入,导致读到的数据不是自己刚写的。
5.2 为什么不能锁升级(读 → 写)?
// 危险操作!可能死锁!
readLock.lock();
try {
// 我想升级成写锁
writeLock.lock(); // 💀 死锁!
} finally {
readLock.unlock();
}死锁分析:
线程A:持有读锁,想获取写锁
线程B:持有读锁,想获取写锁
写锁要求:没有任何读锁
结果:A 等 B 释放读锁,B 等 A 释放读锁 → 死锁结论:
- ✓ 锁降级(写 → 读):安全
- ✗ 锁升级(读 → 写):不支持,可能死锁
六、总结
6.1 核心要点表
| 知识点 | 说明 |
|---|---|
| State 拆分 | 高 16 位读锁,低 16 位写锁 |
| 读写互斥 | 有读锁时,写锁失败;有写锁时,读锁失败 |
| 读锁共享 | 多线程可同时持有读锁 |
| 写锁独占 | 同时只能一个线程持有 |
| 记账优化 | firstReader → cachedHoldCounter → ThreadLocal |
| 锁降级 | 写 → 读 ✓,读 → 写 ✗ |
6.2 避坑指南
- 别忘了 unlock:读写锁都要在 finally 中释放
- 别锁升级:想从读锁升级到写锁?先释放读锁再获取写锁
- 读多写少才有优势:如果写操作频繁,读写锁反而增加开销
- 注意饥饿:非公平模式下,写线程可能被大量读线程饿死
6.3 什么时候用?
✓ 缓存读取
✓ 配置文件读取
✓ 数据库连接池配置
✗ 写操作频繁的场景