它解决什么问题
有个缓存,99% 的时间在读,1% 的时间在写。
用 synchronized?读操作互相阻塞,吞吐量上不去。
用 Semaphore?Semaphore 是计数信号量,不是锁,没法区分读写。
ReentrantReadWriteLock 就是来解决这个的:读多写少场景下的高性能锁。
读锁是共享的,多个线程可以同时读。写锁是互斥的,一次只能有一个线程写。
核心原理
读写锁分离的核心思想:
读操作:可以多个线程同时读(读读并发)
写操作:只能一个线程写,并且读写互斥(写写互斥、读写互斥)
一个缓存的数据结构:
java
class Cache {
private final Map<String, Object> map = new HashMap<>();
private final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
private final Lock readLock = rwl.readLock();
private final Lock writeLock = rwl.writeLock();
Object get(String key) {
readLock.lock();
try {
return map.get(key);
} finally {
readLock.unlock();
}
}
void put(String key, Object value) {
writeLock.lock();
try {
map.put(key, value);
} finally {
writeLock.unlock();
}
}
}
源码解读
构造方法
java
public ReentrantReadWriteLock() {
this(false); // 默认非公平
}
public ReentrantReadWriteLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
readerLock = new ReadLock(this);
writerLock = new WriteLock(this);
}
公平和非公平的区别跟 ReentrantLock 一样:公平模式会检查等待队列。
Sync 内部结构
java
static final int SHARED_SHIFT = 16;
static final int SHARED_UNIT = (1 << SHARED_SHIFT); // 0x00010000
static final int MAX_COUNT = (1 << SHARED_SHIFT) - 1; // 65535
static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1; // 0x0000FFFF
// state 被拆成两半
// 高16位:读锁计数(共享模式)
// 低16位:写锁计数(独占模式)
JDK 用一个 int 的 state,分成两半来存读和写:
32位 state = [读锁计数:16位] [写锁计数:16位]
读锁获取
java
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 尝试加读锁
if (compareAndSetState(c, c + SHARED_UNIT)) {
// 成功,标记第一个获取读锁的线程
if (r == 0) {
firstReader = current;
firstReaderHoldCount = 1;
} else {
// 不是第一个,更新计数
cachedHoldCounter = readHolds.get();
readHolds.set(3);
}
return 1;
}
}
return fullTryAcquireShared(current);
}
核心:CAS 加 SHARED_UNIT(65536),高16位加一。
写锁获取
java
protected 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");
}
}
// CAS 尝试加写锁
if (compareAndSetState(c, c + acquires)) {
setExclusiveOwnerThread(current);
return true;
}
return false;
}
关键点:写锁和读锁互斥,只要有读锁存在,写锁就无法获取(除非是同一个线程重入)。
锁降级
java
public void processData() {
readLock.lock(); // 先拿读锁
try {
// 读取数据
if (needUpdate) {
// 要更新了,先拿写锁
writeLock.lock(); // 写锁降级:读锁还在手里
try {
// 更新数据
needUpdate = false;
} finally {
writeLock.unlock(); // 释放写锁,但读锁还在
}
}
// 用新数据
} finally {
readLock.unlock(); // 最后释放读锁
}
}
这个场景很有意思:读锁不释放的情况下获取写锁,再释放写锁。这就是"锁降级",防止其他线程在更新期间读取到不一致的数据。
踩坑经历
坑1:读锁情况下获取写锁会死锁
java
readLock.lock();
try {
writeLock.lock(); // ❌ 这里会一直阻塞!
// 永远不会走到这里
} finally {
writeLock.unlock();
readLock.unlock();
}
ReentrantReadWriteLock 不支持锁升级。读锁还没释放,又去拿写锁,写锁和读锁互斥,所以写锁永远拿不到,程序卡死。
坑2:忘记读多写少的前提
我之前在写多读少的场景下用了读写锁,结果性能反而更差。原因是读写锁的开销比普通互斥锁大,如果写操作占比高,读写分离的优势体现不出来。
教训:
- 读多写少 → 用读写锁
- 写多读少 → 用普通互斥锁
- 读写各半 → 随便哪个都行,读写锁优势不明显
常见面试题
Q1:ReentrantReadWriteLock 的实现原理?
用一个 int 的 state 分成两半:高16位存读锁计数,低16位存写锁计数。读锁是共享模式,多线程 CAS 加;写锁是独占模式。
Q2:读锁和写锁是如何实现互斥的?
tryAcquireShared 里会检查是否有写锁被占用,如果有就直接返回失败。tryAcquire 里检查是否有读锁或写锁被占用,有的话也返回失败。
Q3:什么是锁降级?
持有写锁的情况下获取读锁称为锁降级。作用是保证在更新数据时不会有其他线程读取到旧数据,更新完成后释放写锁降级为读锁。
Q4:锁升级可以吗?
不行。持有读锁时获取写锁会死锁,因为写锁需要等其他所有读锁释放,但读锁持有者又不会主动释放。
Q5:StampedLock 和 ReentrantReadWriteLock 的区别?
StampedLock 使用戳(stamp)而不是计数,性能更好。支持乐观读模式:先读,发现冲突再升级为悲观读。ReentrantReadWriteLock 不支持乐观读。
总结
记住三点:
- state 分两半:高16位读锁计数,低16位写锁计数
- 读写互斥:读锁和写锁不能同时存在
- 锁降级 OK,锁升级死锁:常见踩坑点
面试问到这,直接把 state 分段设计的源码逻辑讲出来,面试官会对你刮目相看。