ReentrantLock 和 synchronized 有个共同点:同一时刻只允许一个线程进。可大量场景里,读和读之间根本不冲突——十个线程同时读一份配置、一个缓存被反复查询,互不干扰,凭什么排队?读写锁就是为"读多写少"准备的:读和读共享,只要有写就互斥。这篇梳理读写锁的规则、锁降级,以及 JDK 8 加的进阶版 StampedLock(乐观读,连读锁都可以不加)。
锁的全景地图在《锁的分类与实现原理》,
ReentrantLock的四个进阶能力在《ReentrantLock 详解》,底层原理看《AQS 原理详解》。
一、解决什么问题:读读也互斥太浪费
先看问题的量级。假设一个系统 99% 的操作是读配置,1% 是改配置,用 ReentrantLock:
线程1 读 ──┐
线程2 读 ──┼── 全部排队,一个一个进 —— 其实它们谁也不碍着谁
线程3 读 ──┘读操作不改数据,100 个线程同时读也不会出任何问题,但排队的代价全付了。读写锁的解法是把锁拆成两把:
| 规则 | 读锁 | 写锁 |
|---|---|---|
| 读锁 | ✅ 共享(读读不互斥) | ❌ 互斥 |
| 写锁 | ❌ 互斥 | ❌ 互斥 |
一句话:只有读和读能共享,其余组合全部互斥。
二、基本用法
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class Config {
private String value;
private final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
public String get() {
rwl.readLock().lock(); // 读锁:大家共享
try {
return value;
} finally {
rwl.readLock().unlock();
}
}
public void set(String newValue) {
rwl.writeLock().lock(); // 写锁:独占
try {
value = newValue;
} finally {
rwl.writeLock().unlock();
}
}
}注意几个点:
- 读锁/写锁不是两把独立的锁,是同一把锁的两个视图,内部状态统一管理(写线程数 + 读线程数)。
- 和
ReentrantLock一样是显式锁,unlock必须放finally。 - 都可重入:读线程可以重复拿读锁,写线程可以重复拿写锁。
- 读写锁也有公平/非公平模式,默认非公平。
这些行为都实跑验证过:两个读线程 readLock 后各自 sleep 300ms,实际并发数是 2(读读不互斥);读锁持有期间 writeLock.tryLock 超时失败(读写互斥)。
三、锁降级可以,锁升级不行
这是读写锁最容易被追问的一个知识点。
锁降级(写 → 读):持有写锁时,可以直接拿读锁,然后释放写锁——从"独占"平滑过渡到"共享":
rwl.writeLock().lock(); // 1. 先拿写锁(独占,改数据)
try {
data = newData; // 2. 改完数据
rwl.readLock().lock(); // 3. 写锁内直接拿读锁(这步允许)
} finally {
rwl.writeLock().unlock(); // 4. 释放写锁,此刻手里还剩读锁
}
// 5. 之后读自己的数据,不会被其他写线程打断为什么要降级?典型场景是缓存刷新:写完最新数据后,接下来还要连续读一段时间。如果不降级、先把写锁放掉再拿读锁,中间有个"无锁空隙",别的写线程可能趁机改数据,你后面读到的就不是自己刚写的那份了。降级保证了"我写完之后、读到的一定是我写的"(这就是安全发布)。
锁升级(读 → 读+写):反过来不行。持有读锁时去拿写锁,会永远阻塞——你拿着读锁等写锁,别的读线程(也包括你自己)占着读锁不撒手,写锁永远等不到,直接死锁。实跑验证过:读锁内 writeLock.tryLock(200ms) 返回 false(本线程自己挡自己)。需要写,先把读锁放掉再拿写锁。
四、StampedLock:乐观读,读连锁都不加
ReentrantReadWriteLock 已经比普通锁高效了,但它读的时候还是要真加锁(CAS 改读计数、登记线程)。JDK 8 的 StampedLock 走得更远:读的时候可以完全不加锁,读之前拿个"戳"(stamp),读完验一下"这期间没人写过",没写过就白嫖成功,写过就重试或升级悲观读。
三种模式:
import java.util.concurrent.locks.StampedLock;
public class Point {
private double x, y;
private final StampedLock sl = new StampedLock();
// 写:独占(和写锁一样)
public void move(double deltaX, double deltaY) {
long stamp = sl.writeLock();
try {
x += deltaX;
y += deltaY;
} finally {
sl.unlockWrite(stamp);
}
}
// 乐观读:不加锁,读完验证
public double distanceFromOrigin() {
long stamp = sl.tryOptimisticRead(); // 1. 拿个戳,不阻塞任何人
double curX = x, curY = y; // 2. 脆弱读:此刻读到的值
if (!sl.validate(stamp)) { // 3. 验戳:读的时候有人写过吗?
// 4a. 验证失败 → 升级悲观读(老老实实拿读锁重读一遍)
stamp = sl.readLock();
try {
curX = x;
curY = y;
} finally {
sl.unlockRead(stamp);
}
}
// 4b. 验证成功 → curX/curY 就是一致的数据,直接用
return Math.sqrt(curX * curX + curY * curY);
}
}乐观读的流程画成图:
理解乐观读的关键:它没有"锁"的概念,只有"版本验证"。tryOptimisticRead 返回一个基于当前写状态算出的戳;读操作期间不管有没有别的线程来,validate 只检查"写状态变过没有"。没变过,说明读的这份数据和写状态是同一版本,虽然中途理论上有人可能开始写,但只要写锁还没真正释放前你完成了 validate,读到的就是完整一致的。验证失败也不慌,退回普通读锁重读——所以它最坏情况等于读写锁,最好情况零开销。
实跑验证过两个场景:无写入时 validate 通过,拿到原值;写线程持写锁期间乐观读 validate 失败,降级悲观读后拿到新值。
StampedLock 的两个坑
1. 不可重入。ReentrantLock/ReentrantReadWriteLock 同线程重复加锁没问题,StampedLock 不行——同一线程重复 readLock() 会直接死锁。这也是它换取高性能的代价之一。
2. 不能用 Condition。StampedLock 没有条件队列,需要 await/signal 配合的场景用不了它。
另外它不带"公平"概念,写少的时候读线程理论上可能一直乐观读成功、写线程插不进队(JDK 文档也承认这点),写压力大时要留意。
五、怎么选
| 场景 | 用什么 |
|---|---|
| 读写都不少,或需要 Condition | ReentrantReadWriteLock |
| 读多写多、需要条件等待 | ReentrantReadWriteLock(读锁 + Condition) |
| 读极多写极少(缓存、配置、坐标点这类),读路径是热点 | StampedLock 乐观读 |
| 同一线程要重入 | 排除 StampedLock |
一句话记忆:先想 ReentrantReadWriteLock,读路径被测出是热点、且写极少时,才上 StampedLock。 它们的底层都是 AQS(StampedLock 是自己实现的类似逻辑),排队挂起那套机制在《AQS 原理详解》。
小结
- 读写锁规则:读读共享,读写互斥,写写互斥
- 锁降级(写→读)可以:写锁内拿读锁、再放写锁,保证读到的是自己刚写的(安全发布)
- 锁升级(读→写)不行:拿着读锁等写锁,自己挡自己,死锁
StampedLock乐观读:读不加锁,tryOptimisticRead→ 读 →validate,失败降级悲观读StampedLock不可重入、无 Condition、无公平模式——高性能的代价- 选择:默认
ReentrantReadWriteLock;读路径实测是热点且写极少才用StampedLock
这些锁的排队、挂起、唤醒是怎么实现的?看《AQS 原理详解》。单线程互斥之外,多台机器之间的互斥看《Redis 分布式锁详解》。
