开头先聊个现象:如果你处理的是“读多写少”的业务,却还在用 synchronized 或者 ReentrantLock 给读方法加互斥锁,那其实是在让所有读线程互相排队,白白损失并发能力。Java 并发包里的 ReentrantReadWriteLock 就是专门为这种场景设计的,它把“读”和“写”拆成两把锁:多个线程可以同时读,写的时候才独占全部。这篇文章是 Java 进阶篇教学里关于锁的深入内容,我会从底层状态设计、可重入规则、锁降级、公平性策略,到实际缓存封装逐个拆开讲,顺便把面试里最容易问错的几个点一起理清。
1. 读多写少才是主战场:读写锁到底帮你省了什么
很多同学刚接触并发时,脑子里只有“锁就是保证互斥”这一个概念。但真实业务里,锁要保护的资源往往有一个重要特征:读操作可以完全并行,写操作才需要排他。比如商品详情、配置信息、热点文章,99% 的请求都在读,只有后台偶尔写一次。如果所有读都用一个互斥锁串行化,就是典型的“一夫当关,万夫莫开”,但门后面明明没有写者,读者之间完全可以同时进入。
ReentrantReadWriteLock 要解决的就是这个浪费。它维护了两把锁:
- 读锁(
readLock()):共享锁,多个线程可以同时持有。 - 写锁(
writeLock()):独占锁,同一时刻只允许一个线程持有。
读锁和读锁不互斥,读锁和写锁互斥,写锁和写锁互斥。所以并发模型从“所有操作串行”变成了“读读并行、读写互斥、写写互斥”。
1.1 一个最简单的读写锁用例
先看一个最普通的计数器实现:
java复制public class ReadWriteCounter {
private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
private final Lock readLock = lock.readLock();
private final Lock writeLock = lock.writeLock();
private int count;
public int get() {
readLock.lock();
try {
return count;
} finally {
readLock.unlock();
}
}
public void increment() {
writeLock.lock();
try {
count++;
} finally {
writeLock.unlock();
}
}
}
如果用 ReentrantLock 实现同样功能,读方法 get() 也必须加互斥锁,100 个线程同时调 get() 时就会一个一个排队。而换成读写锁后,100 个读者可以同时拿到读锁进入 get()。只有当 increment() 想拿写锁时,才会等所有读者退出,之后才能修改 count。
从使用体验上看,ReentrantReadWriteLock 和 ReentrantLock 非常像,同样有 lock()、unlock()、tryLock()、lockInterruptibly(),也支持公平/非公平模式。正因为 API 相似,很多人上手时不重视它内部“一锁拆两锁”的本质,结果一旦写出锁升级的代码就直接卡死。
2. 一把 int 状态变量装下两把锁:认识 state 的高位和低位
如果让你设计读写锁,你会怎么做?最朴素的想法是设置两个独立的 int 状态:一个记录读锁数量,一个记录写锁是否被持有。但 Java 的 AQS 同步器只给子类留了一个 volatile int state 字段。ReentrantReadWriteLock 的做法非常巧妙:把一个 int 拆成高 16 位和低 16 位来用。
在 ReentrantReadWriteLock 内部:
| 位段 | 含义 | 读取方式 |
|---|---|---|
| 高 16 位 | 读锁被获取的总次数 | state >>> 16 |
| 低 16 位 | 写锁被当前线程重入的次数 | state & 0xFFFF |
也就是说,state 一个变量同时承载了两类信息。
举个例子。初始时 state = 0,表示既没有写锁也没有读锁。
线程 A 获取一次读锁,状态变成 1 << 16,也就是 65536。此时读锁获取总次数是 1。
线程 B 也获取一次读锁,状态继续加 65536,变成 131072。此时高 16 位读到的共享读锁总次数是 2。
线程 A 又重入获取一次读锁,状态再加 65536,变成 196608。读锁总次数是 3,但这里面包含了同一个线程的重入。
如果某个线程获取了写锁,状态直接在低 16 位加 1。写锁可重入时,状态会在低 16 位继续累加。所以两个状态互不干扰。
2.1 为什么说“读状态”是获取次数,而不是线程数
很多八股文里说“高 16 位表示读锁线程数”,这个说法不够严谨,准确说是读锁被获取的总次数。因为读锁支持同一个线程重入,一个线程可以连续拿多次读锁,所以高 16 位记录的是总获取次数,而不是有几个线程。
源码里读锁获取共享锁时执行的是类似这样的 CAS:
java复制compareAndSetState(c, c + SHARED_UNIT)
SHARED_UNIT = (1 << 16),每次获取读锁,状态直接加 65536。释放一次读锁就减 65536。直到减到最高 16 位为 0,才说明所有读者都已经退出。
由此也引出一个非常容易被忽略的限制:16 位能表达的最大值是 65535,所以读锁的总获取次数、写锁的重入次数,理论上限都是 65535。实际业务里不会真的重入六万多次,但面试官偶尔会拿这个点来考察你是否看过源码。
2.2 state 值的变化在锁降级时的体现
锁降级时,state 的变化更直观能看到“高位和低位同时变化”。假设当前写锁被线程 A 持有,状态是 1(低 16 位为 1)。线程 A 在释放写锁前先获取读锁,读锁获取会让 state 加 65536,变成 65537。此时高 16 位是 1,低 16 位也是 1,表示“一个读锁 + 一个写锁重入”同时存在。接着释放写锁,state 减 1,变成 65536,状态就只剩下一个读锁。这样线程 A 就从写锁持有者平滑转换成了读锁持有者。
这才是 ReentrantReadWriteLock 内部真正的状态流转逻辑。理解了这一点,后面再看锁降级、锁升级的规则就会轻松很多。
3. 可重入、锁降级、禁止升级:几道绕不过去的坎
面试里问 ReentrantReadWriteLock,一半问题都集中在“重入”“升级”“降级”这三个词上。很多人把概念背得滚瓜烂熟,但写出来的代码却会把自己卡死。
3.1 读锁和写锁各自的可重入规则
写锁的可重入逻辑和 ReentrantLock 一样:持有写锁的线程再次调用 writeLock.lock(),低 16 位计数加 1,每次释放减 1,减到 0 才算真正释放。
读锁的可重入更隐蔽。因为高 16 位只记录了总获取次数,如果多个线程各自重入,仅仅靠高 16 位无法知道每个线程到底重入了几次。所以源码里专门用了 HoldCounter 来记录每个线程当前的读锁重入次数,后面我会单独展开。
实际开发中,读锁的释放次数必须与获取次数一一对应,finally 里 unlock() 是基本操作。如果只 lock() 一次却在 finally 里调用了两次 unlock(),会直接抛 IllegalMonitorStateException。
3.2 读锁不能升级为写锁:这不是设计缺陷,而是防死锁
我第一次写缓存代码时踩过一个坑:先拿读锁查询,发现缓存为空,于是想当然地准备接着拿写锁回源,结果现场所有线程全部卡住。
看这段代码:
java复制readLock.lock();
try {
if (cache == null) {
writeLock.lock(); // 线程会一直阻塞在这里
try {
cache = loadData();
} finally {
writeLock.unlock();
}
}
return cache;
} finally {
readLock.unlock();
}
这段代码看起来逻辑顺畅,实际上是一个经典的自我死锁。线程已经持有了读锁,又去尝试获取写锁。而写锁的获取条件之一是当前不能有任何读锁存在。当前线程自己没有释放读锁,却还要等“所有读者退出”,于是只能无限等待。如果有多个线程都持有读锁,并且各自都想升级成写锁,局面更混乱:互相等对方释放读锁,谁都进不去。
所以 ReentrantReadWriteLock 明确不支持锁升级。正确的做法是:先释放读锁,再去获取写锁,拿到写锁后还需要二次检查,防止等待写锁期间其他线程已经把数据更新好了。
3.3 锁降级:写锁往读锁退,是合法且常用的操作
与升级相反,锁降级是允许的:持有写锁的线程,在释放写锁之前可以先获取读锁,然后再释放写锁。
java复制writeLock.lock();
try {
data = loadNewData();
// 降级:释放写锁前先拿读锁
readLock.lock();
} finally {
writeLock.unlock();
}
try {
// 此时当前线程只持有读锁,但可以确保读取的是刚更新的数据
consume(data);
} finally {
readLock.unlock();
}
为什么需要降级?因为不降级的话,写锁释放的瞬间,其他写线程也可能立刻抢到写锁,把数据改掉。当前线程如果还想基于“刚刚写入的数据”继续做一段读取或处理,就可能读到被其他写线程修改后的中间状态。先降级成读锁,既保证当前线程后续读操作期间数据不会被其他写线程改动,又允许其他读线程并发进入,吞吐也更好。
这是 ReentrantReadWriteLock 一个很重要也很有迷惑性的知识点:写锁可以退化成读锁,读锁不能进化成写锁。面试里问到锁降级,你说清楚这个原因和代码细节,基本就过关了。
4. 公平模式与非公平模式:读锁为什么要给写锁让路
很多讲 ReentrantReadWriteLock 的文章只会说“默认非公平,可以传 true 改成公平”,但源码里真正值得关注的是:非公平模式下的“插队”策略并不是无条件的。
ReentrantReadWriteLock 和 ReentrantLock 一样,内部基于 AQS 实现,支持公平与非公平。默认构造是非公平:
java复制public ReentrantReadWriteLock() {
this(false);
}
公平与否,核心看两个方法:writerShouldBlock() 和 readerShouldBlock()。
4.1 公平模式:字面意思就是完全排队
公平模式下:
- 写锁是否等待:调用
hasQueuedPredecessors(),只要 AQS 等待队列里有排在前面的线程,就去排队。 - 读锁是否等待:同样调用
hasQueuedPredecessors(),不插队,完全按照请求顺序来。
所以公平读写锁的吞吐通常更低,因为哪怕读者之间明明可以并行,也必须按到达顺序一个个放行,频繁上下文切换,并发优势大打折扣。没有特殊公平需求时,不建议用公平模式。
4.2 非公平模式:读写锁要防止写锁饿死
非公平模式下,两个关键方法的逻辑非常讲究。
对于写锁,NonfairSync.writerShouldBlock() 直接返回 false。意思是写锁只要发现当前没有其他线程持有锁,就可以直接尝试 CAS,不需要管队列里是否有人先来。这是典型的“后来者插队”。
但读锁不能用同样激进的插队逻辑。如果读锁也无条件插队,那么当写锁在队列里排队等待时,不断有新来的读线程插队进入,写锁可能永远等不到执行机会,产生写锁饿死问题。
所以 NonfairSync.readerShouldBlock() 不是盲目返回 false。它内部会去判断:当前 AQS 队列中第一个等待者是不是写锁。如果是,后来的读线程不应该插队,而是老老实实去排队,给写锁让路。
这个设计非常有意思:读写锁虽然默认非公平,但非公平中仍然包含了对写锁的保护机制。常见的误解是“非公平就是谁抢到算谁的”,实际在 ReentrantReadWriteLock 里,读锁在发现队列头是写锁时会让步,从而避免写锁被源源不断的读线程饿死。
真实开发中,如果写操作不多但偶尔对响应时间很敏感,可以多关注这个细节。一旦读线程过于频繁,写锁饥饿是真实会发生的,尤其在高并发低负载的读场景里。
5. HoldCounter 与 firstReader:读锁计数背后的巧妙优化
读锁是共享锁,很多同学会误以为它不需要记录“哪个线程持有几次”。但读锁是可重入的,而且每次 unlock() 必须只释放当前线程自己的一个重入计数。所以源码里必须有一个结构,按线程维度记录重入次数。
这个结构就是 HoldCounter。它本质上是一个线程私有的计数器,记录当前线程获取读锁的重入次数。实现上会配合 ThreadLocal 使用,源码里叫 readHolds。每次一个线程成功获取读锁,就从 ThreadLocal 里取出自己的 HoldCounter,然后执行 count++;每次释放读锁,执行 count--,减到 0 时清理掉。
但 ThreadLocal 的存取并不是零成本的。在读多写少的高并发场景里,大量读线程频繁获取/释放读锁,如果每次都走 ThreadLocal,性能会受很大影响。因此源码里又做了两个优化:
第一个是 firstReader 优化。当读锁的持有线程数为 0,某个线程第一个拿到读锁时,不需要创建任何 HoldCounter,直接用 firstReader 和 firstReaderHoldCount 两个普通字段记录。因为只有一个读者是很多读业务里最常见的形态,省掉 ThreadLocal 能明显提升性能。只有第二个读者进来时,才开始走 HoldCounter。
第二个是 cachedHoldCounter 缓存。它缓存最后一个获取读锁的线程的 HoldCounter。因为读锁经常是同一个线程短时间内反复获取释放,命中缓存后就不必再去 ThreadLocal 里查一遍了。
源码里读锁的释放逻辑也不是简单 state - SHARED_UNIT 就完事,它会同步维护当前线程的重入计数。比如使用 fullTryAcquireShared 去处理 CAS 失败、计数溢出、readerShouldBlock 等复杂情况。只有共享读次数归零,才会真正唤醒等待中的写锁。
这些优化在面试中属于加分项。你不一定要把源码默写出来,但理解“读锁可重入,所以必须有按线程维护的计数器”“为了并发读性能,源码做了 firstReader 和 cachedHoldCounter 缓存”,就能和只会背 API 的候选人拉开差距。
6. 完整案例:一个带锁降级的缓存回源方法
光说概念容易飘,这里写一个典型的“缓存查不到就回源”例子。它综合了读锁尝试、释放读锁、写锁二次检查、锁降级几个关键操作。
java复制public class CacheService {
private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
private final Map<String, Object> cache = new HashMap<>();
private volatile boolean cacheValid;
public void execute(String key) {
// 第一步:用读锁快速查缓存
lock.readLock().lock();
if (!cacheValid) {
// 缓存无效,但是不能直接从读锁升级到写锁,必须先释放读锁
lock.readLock().unlock();
// 第二步:获取写锁准备回源
lock.writeLock().lock();
try {
// 双检锁:等待写锁期间,其他线程可能已经完成回源
if (!cacheValid) {
cache.put(key, loadFromDB(key));
cacheValid = true;
}
// 第三步:锁降级,释放写锁前先获取读锁
lock.readLock().lock();
} finally {
lock.writeLock().unlock();
}
}
// 到这里,要么拿着最外层读锁,要么拿着降级后的读锁
try {
Object result = cache.get(key);
// 在持有读锁的保护下读取数据
System.out.println("handle
