JAVA · Vol.II · DAY 17 · 并发编程
读写锁 ReentrantReadWriteLock 与 StampedLock
读多写少:什么锁才是正解?
先看一个真实的线上场景:订单服务的配置中心,99% 的流量是「读配置」,只有 1% 是「更新配置」。某次大促前的压测里,负责人发现:读请求的 P99 从 2ms 一路涨到 200ms,CPU 没打满,线程却全在排队。
根因很典型——代码里用 ReentrantLock 保护配置对象。它的锁是互斥锁:两个线程只是看看数据、根本没修改,也得排队拿锁。这就像高速公路只有一个 ETC 通道,所有车都得停下来刷卡——明明是「读多写少」,却把读请求全部串行化了。
有,这就是读写锁(Read-Write Lock),是今天这篇的主角之一;而 JDK 8 又带来了一把更激进的 StampedLock,用乐观读(Optimistic Read)把读路径的锁开销压到接近零。先给你一个 30 秒版答案,后面每一站都是在给这句话「上细节」:
读多写少场景,用 ReentrantReadWriteLock:读锁是共享锁(Shared Lock),多个读者并发;写锁是排他锁(Exclusive Lock),写者独占。若读流量压倒性占优(读:写 > 10:1)且不需要可重入,用 StampedLock 的乐观读,读路径连 CAS 都省了。写多为王时,两者都别用,老老实实 ReentrantLock。
在正式开始之前,先抛几个面试官最爱挖的坑,带着问题读效率更高:
- 读写锁的读锁到底能不能「同时被多线程持有」?写锁拿到后,别的线程还能读吗?
- 「锁降级」是什么?为什么允许写锁转读锁,却绝对禁止读锁转写锁?
- 读写锁会不会把写者饿死?非公平模式真的「完全不看队列」吗?
- StampedLock 的
stamp到底是个什么东西?为什么它不支持重入?
带着这些问题,我们从 ReadWriteLock 的规则讲起,一路拆到 StampedLock 的源码。
读写锁的三条规则与 ReadWriteLock 接口
把锁拆成两把——读锁(共享锁,Shared Lock)和写锁(排他锁,Exclusive Lock)——规则其实只有三条:
| 组合 | 是否互斥 | 说明 |
|---|---|---|
| 读 — 读 | 不互斥 | 多个读者可同时持有读锁,读锁计数 +1 |
| 读 — 写 | 互斥 | 有读者时写者阻塞;有写者时读者阻塞 |
| 写 — 写 | 互斥 | 同一时刻只有一个写者 |
用图书馆类比记这三条规则:读者们各自看书互不打扰(读读共享);管理员要整理书架时,所有读者都得先让开(读写互斥),而且一次只能有一个管理员在整理(写写互斥)。只要把「整理书架」想成「写数据」,这三条规则就永远忘不掉。
ReadWriteLock 是 JDK 5 就有的接口,极其简单,只有两个方法:
public interface ReadWriteLock {
Lock readLock(); // 返回读锁(共享锁)
Lock writeLock(); // 返回写锁(排他锁)
}
注意:锁是由同一个 ReadWriteLock 实例绑定的。A 线程拿着 readLock() 的锁、B 线程拿着 writeLock() 的锁,它们之间互斥与否,取决于这两个 Lock 对象是否出自同一个 ReadWriteLock。就像同一扇门的两把钥匙,只有配套的那把才能开——不同实例的锁互不干涉。
因为读操作之间天然无冲突。互斥锁把「读读」也挡在门外,是拿吞吐量换简单。读写锁把冲突矩阵从「全互斥」放宽成「读读共享」,在读多写少的场景下,吞吐量可以提升一个数量级——这是它存在的全部意义。面试答到这一层,面试官就知道你理解的是「为什么」,而不是「是什么」。
读写锁最经典的实现是 ReentrantReadWriteLock,它也是 AQS 家族的一员。下一站看它如何用一个 int 同时管理两把锁。
ReentrantReadWriteLock:一个 int 拆成两把锁
上一站讲过 AQS 用一个 int state 表示锁状态。ReentrantReadWriteLock 更巧妙——把同一个 int 拆成高 16 位和低 16 位:高 16 位存读锁计数,低 16 位存写锁计数:
来看源码中的位运算常量和提取方法:
// 位运算常量
static final int SHARED_SHIFT = 16;
static final int SHARED_UNIT = 1 << SHARED_SHIFT; // 0x00010000 = 65536
static final int MAX_COUNT = (1 << SHARED_SHIFT) - 1; // 0x0000FFFF = 65535
static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1; // 同上,低16位掩码
// 从 state 中提取读锁计数(高 16 位无符号右移)
static int sharedCount(int c) {
return c >>> SHARED_SHIFT;
}
// 从 state 中提取写锁计数(低 16 位 & 掩码)
static int exclusiveCount(int c) {
return c & EXCLUSIVE_MASK;
}
从这里能读出两个硬数字,面试常考:
- 最大并发读者 65535:高 16 位最多表示 2^16 - 1 个读者,再想进会抛
Error("Maximum lock count exceeded"); - 写锁最大重入 65535 次:低 16 位同理,同一个线程反复拿写锁最多 65535 次。
因为 AQS 的 compareAndSetState() 只能原子更新一个 int。如果把读计数和写计数放在两个变量里,就需要两次 CAS 才能同时更新——两次 CAS 之间就有竞态窗口,读者写者同时改各自变量,锁的一致性就破了。塞进一个 int 的两个半区,一次 CAS 就能原子地更新读写两个状态。
写锁获取:tryAcquire 逐行拆解
写锁是排他锁,走 AQS 的独占模式。看 tryAcquire():
protected final boolean tryAcquire(int acquires) {
Thread current = Thread.currentThread();
int c = getState();
int w = exclusiveCount(c); // 提取低 16 位:写锁计数
if (c != 0) { // 有人持锁(读或写)
// w == 0 说明写锁计数为 0 → 那就是有读者 → 写者必须等
// current != getExclusiveOwnerThread() → 不是自己重入 → 也等
if (w == 0 || current != getExclusiveOwnerThread())
return false;
if (w + exclusiveCount(acquires) > MAX_COUNT)
throw new Error("Maximum lock count exceeded");
setState(c + acquires); // 写锁重入:state +1(低 16 位计数 +1)
return true;
}
// c == 0 → 无人持锁,尝试抢占写锁
if (writerShouldBlock() || !compareAndSetState(c, c + acquires))
return false;
setExclusiveOwnerThread(current);
return true;
}
四个分支对应四种情况:
- c != 0 且 w == 0:有人持有读锁 → 写者必须等待。这是「读写互斥」在代码里的落点;
- c != 0 且写锁属于自己:写锁重入,低 16 位计数 +1(最多 65535 次);
- c == 0 且 writerShouldBlock() 为 false:无人持锁,非公平模式下直接 CAS 抢锁(插队);
- CAS 失败:有人抢先一步,返回 false 走 AQS 排队。
非公平(默认)的 writerShouldBlock() 直接返回 false——写者来了先 CAS 抢一次,抢不到再排队(这和 ReentrantLock 非公平锁「先抢一次」是同一个套路);公平模式的 writerShouldBlock() 返回 hasQueuedPredecessors()——只要队列里有人排在我前面,就老老实实排队。读锁那边的对应判断在下一站。
读锁获取:tryAcquireShared 与读者插队
读锁是共享锁,走 AQS 的共享模式 tryAcquireShared()。核心逻辑是把 state 的高 16 位加 1:
protected final int tryAcquireShared(int unused) {
Thread current = Thread.currentThread();
for (;;) {
int c = getState();
// ① 写锁被别的线程持有时,读者必须阻塞(读写互斥)
if (exclusiveCount(c) != 0 && getExclusiveOwnerThread() != current)
return -1;
// ② 读者数量达到上限 65535 → 抛错
int r = sharedCount(c);
if (r == MAX_COUNT)
throw new Error("Maximum lock count exceeded");
// ③ 读者要不要让路?readerShouldBlock 决定(第 7 站细讲)
if (readerShouldBlock() ||
!compareAndSetState(c, c + SHARED_UNIT)) // 高 16 位 +1
return -1;
// ④ 记录持有读锁的线程(可重入读锁统计用,见第 8 站)
...
return 1; // >0 表示获取成功
}
}
注意 ① 的判断是「别的线程的写锁」——如果是自己持有写锁,当前线程依然可以获取读锁。这正是第 6 站「锁降级」得以成立的前提。
这里的 SHARED_UNIT = 1 << 16 = 65536:读锁每次 +65536,正好在 state 的高 16 位上进一位;释放时 -65536。「1 个读者」在 state 里就是 65536,和写锁的 1 完美错开——这就是位拆分的优雅之处,读和写各占各的位段,互不干扰。
- 写锁走 AQS 独占模式(tryAcquire),读锁走共享模式(tryAcquireShared),两条路径共用一个 int state;
- 「读写互斥」「读读共享」的本质:读者只要发现写锁位不为 0(且不是自己持有)就让路,否则直接 CAS 累加读计数;
- 读锁最大并发读者 65535,写锁最大重入 65535,超出抛 Error。
锁降级:写锁 → 读锁 → 释放写锁
面试高频追问来了:「持有写锁的线程,能不能不释放写锁就直接获取读锁?」
答案是:可以。这叫锁降级(Lock Downgrade)——从权限更高的写锁降级到权限更低的读锁。ReentrantReadWriteLock 的 Javadoc 白纸黑字允许这么做,还给了标准示例。它解决的是缓存更新里最经典的问题:「写完数据,我还要用一下刚写入的值」。看 Javadoc 的 CachedData 示例:
class CachedData {
Object data;
volatile boolean cacheValid;
final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
void processCachedData() {
rwl.readLock().lock();
if (!cacheValid) {
// 必须先把读锁释放掉,才能获取写锁(锁升级被禁止)
rwl.readLock().unlock();
rwl.writeLock().lock();
try {
// 双检:可能别的线程已经抢先更新了
if (!cacheValid) {
data = loadFromDB(); // 写数据
cacheValid = true;
}
// 降级:在释放写锁之前,先拿到读锁
rwl.readLock().lock();
} finally {
rwl.writeLock().unlock(); // 释放写锁,但还持着读锁
}
}
try {
use(data); // 以只读方式使用刚写入的数据
} finally {
rwl.readLock().unlock(); // 最后释放读锁
}
}
}
关键就是中间那两行:先获取读锁,再释放写锁。这个顺序不能反,为什么?
先释放写锁、后拿读锁的版本:释放写锁和获取读锁之间有一个无锁窗口。另一个写线程 W2 可能在这个窗口里抢到写锁、改掉数据——当前线程随后拿到读锁读到的,就是被别人改过的数据,不是自己刚写入的值,「写完立即用」的语义就破了。
先拿读锁、再释放写锁的版本:拿读锁时自己还持有写锁,任何其它线程既不能读也不能写,读锁必然获取成功;释放写锁后,当前线程依然持有读锁,保证「自己刚写入的数据」在接下来的读临界区里不会被任何写者改动。这也回答了「锁降级为什么能保证可见性」:数据在写锁临界区内写入、在读锁临界区内读取,两个临界区由同一个线程无缝衔接,中间没有其它写者插入,读到的必然是最新值。
为什么锁升级(读锁 → 写锁)是绝对禁止的?
假设允许升级,两个线程 T1、T2 都持有读锁,然后同时想升级成写锁:T1 等 T2 释放读锁,T2 等 T1 释放读锁——互相等待,死锁。即使只有一个读者,升级也要求先释放读锁,释放与获取之间的窗口期照样会被其它写者插足。所以 JDK 的设计是一刀切:想从读变写?先释放读锁,再重新竞争写锁(CachedData 示例里就是这么干的:unlock 读锁 → lock 写锁 → 双检)。
顺便记一个反直觉的事实:写锁可以降级为读锁,读锁永远不能升级为写锁。这是读写锁设计里最常考的考点,也是它与 StampedLock 的一大差异(StampedLock 支持「尝试式」升级,见第 12 站)。
写者饥饿:读者源源不断,写者永远排队?
读写锁引入了一个新问题——写者饥饿(Writer Starvation)。
设想一个缓存系统:每秒 1000 个读请求,每 10 秒才来一个写请求。读者们发现读锁空闲就蜂拥而入,写者却迟迟拿不到写锁——因为只要有读者持锁,写者就必须等;而读者来得太快,读锁几乎从未空下来。
先纠正一个流传很广的误解:JDK 8 的非公平模式并不是「完全不看队列」。看源码:
final boolean writerShouldBlock() {
return false; // 写者:先 CAS 抢一次再说(允许插队)
}
final boolean readerShouldBlock() {
// 启发式:如果等待队列头部的线程是写者,读者就让路
// 目的:缓解(但无法根治)写者饥饿
return apparentlyFirstQueuedIsExclusive();
}
apparentlyFirstQueuedIsExclusive() 的意思是:队列头部若已有一个写者在排队,新来的读者就不许插队,乖乖排队。这比「完全不看队列」好——写者一旦入队,后面的读者会让路,写者最终能拿到锁。
但饥饿仍然会发生,因为存在时间差:读者 A 拿到读锁的瞬间,写者 W 还没入队(它还在自旋 / 唤醒的路上);读者 B、C 趁这个窗口接连拿锁,每个新读者都把读锁的「空窗期」无限后移……极端情况下,写者可能被延迟很久。所以:
- 想要彻底不饿死写者 → 用公平模式
new ReentrantReadWriteLock(true):readerShouldBlock()变成hasQueuedPredecessors(),只要队列里有任何线程排队,读者和写者都让路; - 想要吞吐优先 → 默认非公平,接受「写者可能被延迟」的风险;
- 对写延迟零容忍 → 上 StampedLock(它内部是写优先策略,见第 13 站),或干脆用 ReentrantLock。
| 模式 | 读者行为 | 写者饥饿 | 吞吐量 |
|---|---|---|---|
| 非公平(默认) | 队列头部无写者时直接插队 | 可能延迟(有启发式缓解) | 高(减少线程切换) |
| 公平 | 队列中有人排队就让路 | 不会饥饿 | 低(频繁线程切换) |
如果业务里写操作「不能无限延迟」(比如配置必须及时下发),公平模式是兜底方案,但代价是吞吐下降——读者/写者频繁切换,AQS 队列频繁进出。这就是「公平」与「吞吐」的经典二选一,和 ReentrantLock 的选择逻辑完全一致。
完整 API 与可重入:读锁写锁各有一份
读写锁继承了 Lock 的全部能力:lock()、lockInterruptibly()、tryLock()、tryLock(timeout)、unlock()——读锁和写锁各有一份。先看可重入:
ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
// 写锁可重入:同一个线程可以连续拿写锁,计数累加
rwl.writeLock().lock();
rwl.writeLock().lock(); // 低 16 位计数 1 → 2
rwl.writeLock().unlock(); // 2 → 1(还没释放干净)
rwl.writeLock().unlock(); // 1 → 0,锁才真正释放
// 读锁可重入:同一个线程可以连续拿读锁,计数也累加
rwl.readLock().lock();
rwl.readLock().lock(); // 高 16 位计数 +2
rwl.readLock().unlock();
rwl.readLock().unlock();
这里有个读者容易踩的坑:可重入 ≠ 自动配平。每次 lock() 都要配一次 unlock(),少放一次,计数就滞留一次——在长事务、递归方法里尤其容易漏,建议一律用 try/finally 包裹。
再看锁降级后的计数叠加。CachedData 里同一线程在释放写锁前拿了读锁,此时 state 的两个半区同时非零(写计数 1 + 读计数 1)——这是读写锁里唯一的「读写并存」合法状态,且只可能发生在同一线程身上。释放时按 Javadoc 的惯例:先释放写锁,读锁留到最后,让数据在尽可能长的读保护期内不被其它写者触碰。
最后把读写锁的 API 全景列全,注意一个容易被忽略的差异:
| API | 读锁 | 写锁 | 说明 |
|---|---|---|---|
lock() | ✅ | ✅ | 获取锁,拿不到就阻塞 |
lockInterruptibly() | ✅ | ✅ | 阻塞期间响应中断 |
tryLock() | ✅ | ✅ | 立即返回,拿不到返回 false |
tryLock(timeout) | ✅ | ✅ | 限时等待,超时返回 false |
unlock() | ✅ | ✅ | 释放,注意与 lock 配平 |
newCondition() | ❌ | ✅ | 只有写锁支持条件队列,读锁会抛 UnsupportedOperationException |
工作中读锁不支持 newCondition() 是很多人第一次用就踩的坑:想在读路径里等一个条件(比如缓存未就绪时等待),读锁给不了,只能拆成写锁的 Condition 或换 ReentrantLock。API 越全,用起来越要小心。
还不够:读锁也要 CAS,于是 StampedLock 登场
ReentrantReadWriteLock 已经让读者们「并行读书」了,为什么还要 StampedLock?因为读锁本身仍有锁开销:每来一个读者,都要对 state 做一次 CAS(c, c + SHARED_UNIT)——CAS 是原子指令,要写内存、要竞争缓存行,并发读者一多,读者的 CAS 之间也会互相争抢。读路径的竞争只是从「抢整把锁」变成了「抢一个计数器」,并没有消失。
JDK 8 引入的 StampedLock 想得更激进:读的时候干脆不加锁,只记一个「版本号」,读完之后验证版本号有没有变——没变说明读到的数据是一致的,变了再回头加锁重读。这就是乐观读(Optimistic Read),与之相对的普通加锁读叫悲观读(Pessimistic Read)。
想象你在事故现场拍照取证:先「咔」拍一张(tryOptimisticRead() 拿到一个戳),然后放心大胆地记录现场情况(不加锁直接读数据),记录完再核对一眼:现场有没有在我拍照之后被破坏过?(validate(stamp))。没动过 → 我记的东西完全可信;动过了 → 照片作废,回去重拍(升级成悲观读锁重读)。拍照本身不占用现场,谁来拍都行——这就是乐观读「零锁开销」的本质。
另一个角度:乐观读就是乐观锁思想在锁实现上的应用——先干活、后验证,冲突了再重来;悲观读则是悲观锁思想——先拿锁、再干活,彻底排除冲突。数据库的 MVCC 快照读、Redis 的 WATCH、Git 的提交哈希,全是同一套「版本号验证」思想。
StampedLock 三种模式:写锁 / 悲观读 / 乐观读
StampedLock 把锁拆成三种模式,比读写锁多了一个「乐观读」:
| 模式 | 获取 API | 释放 API | 互斥性 | 读路径开销 |
|---|---|---|---|---|
| 写锁(Write) | writeLock() | unlockWrite(stamp) | 与读、写都互斥 | — |
| 悲观读锁(Read) | readLock() | unlockRead(stamp) | 与写互斥,读之间不互斥 | CAS + 共享 |
| 乐观读(Optimistic) | tryOptimisticRead() | 无需释放,用 validate(stamp) 验证 | 不加锁,仅事后验证 | 零锁开销(成功时) |
三个细节值得注意:
- 所有获取方法都返回一个
long stamp,释放时必须把当初那个 stamp 原样传回(unlockWrite(stamp)/unlockRead(stamp))。因为 StampedLock 没有重入、没有「当前线程」的持有记录,stamp 就是锁的唯一凭证——丢了 stamp,锁就放不掉了; - 乐观读不需要释放,它压根没占锁,只留了一个 stamp 待会儿验证;
- 写锁 / 悲观读锁拿不到时同样会阻塞排队,这点和读写锁一致;区别在读路径的默认选择:读写锁的读是「必须加锁」,StampedLock 的读是「能不加就不加」。
再强调一次 stamp 的语义:stamp 不是锁,是「快照版本号」。拿写锁 / 读锁时,它是这把锁的凭证;拿乐观读时,它是一张「拍照时的版本戳」。三种模式的完整用法,下一站用官方示例讲透。
乐观读标准用法:Point 示例逐行拆解
先看乐观读的完整流程:
这是 StampedLock Javadoc 里的标准示例(略有精简)——一个二维坐标点,写操作 move(),读操作 distanceFromOrigin():
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(); // ① 拍照:拿版本戳,不加锁
double currentX = x, currentY = y; // ② 放心读(此刻无锁)
if (!sl.validate(stamp)) { // ③ 验证:期间有写操作吗?
stamp = sl.readLock(); // ④ 有 → 升级为悲观读锁
try {
currentX = x; // ⑤ 加锁状态下重读
currentY = y;
} finally {
sl.unlockRead(stamp); // ⑥ 释放读锁
}
}
return Math.sqrt(currentX * currentX + currentY * currentY);
}
}
执行顺序 ①→⑥,理解三个关键点:
- 为什么要「先读后验证」而不是「先验证后读」:验证必须覆盖整个读取窗口。先拍照(①)再读(②)再验证(③),只要 ② 期间有任何写锁发生,③ 就能发现;如果先验证再读,验证之后、读取之前照样可能有写者插入,验证就白做了;
- validate(stamp) 什么时候返回 false:从拍照到验证之间,只要发生过一次写锁的获取或释放,stamp 对应的版本号就变了,验证失败。所以乐观读的一致性是事后确认的——要么证明没被写过,要么重读;
- 双字段的一致性:x、y 是两个独立字段,乐观读读它们「可能读到一半被写」——这正是 validate 要兜底的:写者一旦动过任何一个字段,整个快照作废重读。这也是为什么乐观读要求读操作快、写操作少,否则重读频繁,反而更慢。
会。tryOptimisticRead() 在「当前有写锁被持有」时会返回 0(0 是无效戳),此时 validate(0) 一定返回 false,代码自然走悲观读分支——这是设计好的兜底,不是 bug。所以标准写法里不需要单独判断 stamp 是否为 0,直接交给 validate 即可。
锁模式转换:tryConvert 三兄弟
StampedLock 比读写锁多了一大能力:三种模式之间可以互相「尝试转换」,而且是原子操作。三个方法:
| 方法 | 语义 | 成功 | 失败 |
|---|---|---|---|
tryConvertToWriteLock(stamp) | 读锁 / 乐观读 → 写锁 | 返回新 stamp | 返回 0 |
tryConvertToReadLock(stamp) | 写锁 / 乐观读 → 读锁(降级) | 返回新 stamp | 返回 0 |
tryConvertToOptimisticRead(stamp) | 写锁 / 读锁 → 乐观读 | 返回新 stamp | 返回 0 |
重点来了:tryConvertToWriteLock 让「读 → 写」升级成为可能,而 ReentrantReadWriteLock 是绝对禁止升级的。为什么 StampedLock 敢?因为它是「尝试式」的,绝不阻塞:
- 当前线程持有写锁,想转读锁:必然成功(降级本来就是写锁的权限);
- 当前线程持有读锁,想升级写锁:只有此刻没有其它读者(读计数恰好只有自己)才成功,否则返回 0;
- 返回 0 意味着转换失败,锁还维持原状态,调用方自己决定下一步(比如释放旧锁后重新获取)。
它天然避开了升级死锁:升级不成功就返回 0,绝不阻塞等待——不会出现「两个读者互相等对方释放读锁」的场面。
private final StampedLock sl = new StampedLock();
private String config = "v1";
public String getConfig() {
long stamp = sl.tryOptimisticRead();
String cur = config;
if (!sl.validate(stamp)) {
// 乐观读失败:尝试直接把「乐观读」升级成「写锁」
stamp = sl.tryConvertToWriteLock(stamp);
if (stamp != 0L) {
// 升级成功:此刻没有其它读者,安全重算
try {
config = reloadFromRemote(); // 重新加载
return config;
} finally {
sl.unlockWrite(stamp);
}
} else {
// 升级失败:有其它读者,退回标准「读锁重读」流程
stamp = sl.readLock();
try { return config; }
finally { sl.unlockRead(stamp); }
}
}
return cur;
}
这个模式把「读 → 写」的升级成本降到了最低:绝大多数请求乐观读一把梭,只有版本失效且恰好无竞争时才升级成写锁重算。对照读写锁:它只能「释放读锁 → 竞争写锁 → 双检」,多一步释放和竞争,还可能在窗口期被别人抢先。
StampedLock 的转换方法很适合「读为主、偶尔需要就地升级写」的缓存 / 配置场景。但要记住:转换是「尝试」,必须检查返回值是否为 0,并准备好降级路径——这是 StampedLock 比读写锁 API 复杂、容易写错的地方。
StampedLock 内部原理:一个 long 里装版本号
StampedLock 没有复用 AQS(它自己实现了一套 CLH 队列),核心是一个 64 位的 long state。JDK 8 的实现把 state 拆成三部分:
为什么乐观读验证这么快?因为 validate() 里就是一次 volatile 读 + 位与比较,没有任何原子指令、没有锁。对比一下三种读路径的成本:
| 读路径 | 核心操作 | 开销量级 |
|---|---|---|
| 乐观读(成功) | volatile 读 state + 位比较 | 最轻(几纳秒) |
| 悲观读锁 | CAS 修改 state + 排队检查 | 中等(原子指令 + 缓存行竞争) |
| ReentrantReadWriteLock 读锁 | CAS 修改 state 高 16 位 | 与悲观读锁同量级 |
另外 StampedLock 的排队策略是写优先(writer preference):新来的读者如果发现等待队列头部是写者,会让路排队,而不是插队——这是它比 ReentrantReadWriteLock 非公平模式「对写者更友好」的原因,也是官方文档建议优先考虑它的底气之一。
低 7 位只能数到 127,所以读锁计数到 126(RFULL)后,再多出来的读者会进入一个单独的 readerOverflow 字段——它用一个小自旋锁保护,只在极端并发读者下才被触达。面试提到这个细节,说明你真的读过源码。
StampedLock 的四大坑:为什么它「不好用」
性能这么香,为什么生产里 StampedLock 用得不算多?因为它有一堆限制,每一个都是坑:
坑 1:不可重入——重入就是死锁
StampedLock sl = new StampedLock();
long stamp = sl.writeLock();
try {
helper(); // helper 内部又调用了 sl.writeLock()
// → 同一个线程第二次拿写锁,直接阻塞 → 死锁!
} finally {
sl.unlockWrite(stamp);
}
void helper() {
long s = sl.writeLock(); // 同一线程再次获取 → 永远等不到自己释放
try { ... } finally { sl.unlockWrite(s); }
}
ReentrantReadWriteLock 同一线程可以反复拿锁、计数累加;StampedLock 完全没有重入计数,第二次获取就把自己阻塞住了。这要求写代码时必须保证「锁内不再调用需要同一把锁的方法」——方法嵌套深了非常容易踩。
坑 2:不支持 Condition
StampedLock 没有 newCondition()。需要「条件等待 / 唤醒」的场景(比如队列空时等待生产者),它直接出局,只能用 ReentrantLock 或 ReentrantReadWriteLock(注意后者也只有写锁支持 Condition,读锁不支持,见第 8 站)。
坑 3:不支持公平模式,且 lock() 不可中断
StampedLock 只有一种排队策略(写优先),无法像 ReentrantReadWriteLock 那样构造时选公平 / 非公平。另外 readLock() / writeLock() 是不可中断的——阻塞期间响应不了中断;要可中断必须显式用 readLockInterruptibly() / writeLockInterruptibly()。
坑 4:乐观读不是万能的——它读的是「快照一致性」
乐观读保证的是「读取窗口内没有写锁发生过」,但有两个前提你得自己把握:
- 多字段快照:Point 例子里 x、y 一起读没问题(validate 兜底);但如果读的是引用对象的内部可变状态(比如一个 HashMap 里的多个条目),乐观读只保护「引用本身」,不保护对象内部——这种场景要么用悲观读锁,要么把对象设计成不可变快照;
- 写频繁时乐观读反而更慢:validate 频繁失败 → 频繁升级悲观读 → 锁开销 + 重读开销双重叠加。乐观读的甜区是读:写 > 10:1 且读操作快;写多场景老老实实用悲观锁。
StampedLock 适合:本地缓存、配置中心、统计值等读压倒性多于写、不需要重入和 Condition 的场合;不适合:方法嵌套深的业务代码、需要条件等待的队列场景、写频繁的热点数据。用之前先问自己三句话:需要重入吗?需要 Condition 吗?写流量够少吗?
四种锁大对比:一张表说清全部差异
把 Java 里最常见的四把锁放一起对比——synchronized、ReentrantLock、ReentrantReadWriteLock、StampedLock:
| 维度 | synchronized | ReentrantLock | ReentrantReadWriteLock | StampedLock |
|---|---|---|---|---|
| 出现版本 | JDK 1.0 | JDK 5 | JDK 5 | JDK 8 |
| 锁模式 | 互斥(Monitor) | 互斥(AQS) | 读共享 + 写互斥 | 写互斥 + 读共享 + 乐观读 |
| 读并发度 | 串行 | 串行 | 多读者并行 | 多读者并行,乐观读无锁 |
| 可重入 | ✅ 是 | ✅ 是 | ✅ 是 | ❌ 否 |
| 公平模式 | ❌ 否 | ✅ 可选 | ✅ 可选 | ❌ 否(写优先) |
| 锁降级(写→读) | — | — | ✅ 支持 | ✅ 支持(tryConvertToReadLock) |
| 锁升级(读→写) | — | — | ❌ 禁止(会死锁) | ⚠️ 尝试式(tryConvertToWriteLock,失败返 0) |
| Condition | wait/notify(单个) | ✅ 多个 | ✅ 仅写锁 | ❌ 无 |
| 可中断 / 超时 | ❌ 否 | ✅ 是 | ✅ 是 | ⚠️ 需用 Interruptibly 变体 |
| 读路径开销 | 阻塞 | 阻塞 | CAS | 乐观读 ≈ volatile 读 |
| 适用场景 | 简单互斥、代码块级 | 需要公平 / 超时 / 多条件 | 读多写少且需可重入 | 读极多写极少、极致读性能 |
几个容易记错的重点:
- synchronized 也能「读写分离」?不能。它只有互斥,没有共享读;
- 只有 ReentrantReadWriteLock 和 StampedLock 支持锁降级,且只有 StampedLock 提供尝试式升级;
- 「公平」开关只在 ReentrantLock / ReentrantReadWriteLock 上可选,synchronized 与 StampedLock 都没有(StampedLock 用写优先近似公平)。
写多 → synchronized / ReentrantLock
读多写少 + 要重入/条件 → ReentrantReadWriteLock
读极多写极少 + 极致读性能 → StampedLock(乐观读)
生产实战:配置中心的本地缓存怎么上锁
回到开头的场景:配置中心,99% 读、1% 写。落地一个完整可运行的「本地配置缓存」——配一把 StampedLock:
import java.util.concurrent.locks.StampedLock;
public class LocalConfig {
private String version = "v0";
private String data = "{}";
private final StampedLock sl = new StampedLock();
// 写:配置更新(低频)
public void update(String newData) {
long stamp = sl.writeLock();
try {
data = newData;
version = "v" + (Integer.parseInt(version.substring(1)) + 1);
} finally {
sl.unlockWrite(stamp);
}
}
// 读:乐观读,失败升级悲观读(高频路径)
public String[] get() {
long stamp = sl.tryOptimisticRead();
String v = version, d = data; // 快照
if (!sl.validate(stamp)) {
stamp = sl.readLock(); // 升级
try { v = version; d = data; } finally { sl.unlockRead(stamp); }
}
return new String[]{ v, d };
}
public static void main(String[] args) throws Exception {
final LocalConfig cfg = new LocalConfig();
// 10 个读者线程狂读 + 1 个写者线程偶尔更新
for (int i = 0; i < 10; i++) {
new Thread(() -> {
for (int j = 0; j < 1_000_000; j++) cfg.get();
}, "reader-" + i).start();
}
new Thread(() -> {
for (int j = 0; j < 100; j++) { cfg.update("{n:" + j + "}"); }
}, "writer").start();
System.out.println("demo started");
}
}
这个示例可以直接跑:10 个读者线程各读 100 万次,1 个写者线程更新 100 次。把 StampedLock 换成 ReentrantReadWriteLock 再跑一遍,读路径的耗时差异就是乐观读的价值(读者越多、写者越少,差距越明显)。
生产里还有几条实打实的经验:
- 「读」也要快:乐观读适合「读一个短小快照」;如果一次读要遍历一个大集合,validate 失败重读的成本高,不如直接悲观读锁;
- 写操作不要长时间持锁:StampedLock 内部有自旋,写锁持有过久会拖累所有读者和 CPU;
- 宁可多一个 validate,也不要少一个:乐观读后忘记 validate,等于把「未确认一致的数据」直接返回——这是最常见的低级 bug;
- 需要重入 / 条件 / 公平时,别硬上 StampedLock:ReentrantReadWriteLock 或 ReentrantLock 才是正解;
- 「版本号 + 乐观读」是配置 / 元数据缓存的黄金组合:读无锁、写低频,性能与一致性兼得。
面试追问拆解:六连问一次答穿
追问 1:读写锁的读锁会饿死写者吗?
非公平模式下可能延迟(JDK 8 有 apparentlyFirstQueuedIsExclusive() 启发式缓解:队列头部有写者时读者让路,但仍有窗口期);公平模式完全不会;StampedLock 则是写优先策略,对写者最友好。答完再补一句「写操作不能无限延迟的场景,用公平模式或 StampedLock」就完整了。
追问 2:stamp 到底是什么?
stamp 是 StampedLock 的「版本戳 / 凭证」:拿写锁、读锁时,它是释放锁必须回传的凭证;乐观读时,它是拍照时刻的版本号,validate(stamp) 就是比较高 57 位版本号有没有变。它不是一个锁,锁的状态在内部的 long state 里。
追问 3:为什么乐观读比读写锁的读快?
读写锁的读锁要 CAS 修改 state(原子指令 + 缓存行竞争);乐观读只是 volatile 读一次 state 再位比较,没有原子指令、没有写内存。读者越多,CAS 竞争越激烈,差距越明显。但乐观读快的前提是 validate 大概率成功——写越少越快。
追问 4:乐观读一定比悲观读好吗?
不是。写频繁时 validate 频繁失败,每次失败都要升级悲观读 + 重读,双重开销,反而比直接用悲观读锁慢。乐观读的甜区是「读:写 > 10:1 且读操作快」。面试主动说出这个反面,比只会背优点加分得多。
追问 5:为什么 ReentrantReadWriteLock 禁止锁升级?
两个读者都想升级写锁时,互相等待对方释放读锁——死锁。即使单个读者升级,释放读锁到获取写锁之间也有窗口期。所以设计上一刀切禁止;StampedLock 用「尝试式转换」绕开了死锁——升级不成功就返回 0,绝不阻塞。
追问 6:ReentrantReadWriteLock 最多支持多少并发读者?
65535。因为读锁计数占 state 高 16 位,最多 2^16 - 1。这也是位拆分方案的天花板。
被问到「读多写少用什么锁」,按这个顺序讲:① 规则(读读共享、读写互斥、写写互斥,这是 ReadWriteLock 的思想)→ ② 实现(ReentrantReadWriteLock 用 state 高 16 位 / 低 16 位管理读写计数,支持锁降级、禁止锁升级;StampedLock 用 long state 做版本号,乐观读先读后验)→ ③ 选型(写多用 ReentrantLock;读多写少要可重入用 ReentrantReadWriteLock;读极多写极少用 StampedLock 乐观读,注意它不可重入、无 Condition)。三句话把「是什么、为什么、怎么选」全讲完,就是 30 秒满分答案。
这一篇你掌握了什么
核心知识点回顾
- 读写锁规则:读读共享、读写互斥、写写互斥;ReadWriteLock 接口只有 readLock() / writeLock(),读锁共享、写锁排他;
- ReentrantReadWriteLock 实现:AQS state 高 16 位存读锁计数、低 16 位存写锁计数,一次 CAS 管理两把锁;最大并发读者 / 写锁重入都是 65535;
- 锁降级:先获取读锁、再释放写锁,中间不留窗口期,保证读到自己的写入;读 → 写升级被禁止(会死锁);
- 写者饥饿:非公平模式读者可能挤占写者(JDK 8 有 apparentlyFirstQueuedIsExclusive 启发式缓解),公平模式根治但吞吐下降;
- StampedLock 三种模式:写锁 / 悲观读锁 / 乐观读;乐观读 tryOptimisticRead() + validate(stamp) 先读后验,成功时零锁开销;
- StampedLock 特性:long state 做版本号(低 7 位读计数、bit7 写锁位、高 56 位版本号);写优先;支持 tryConvert 系列尝试式转换(含读 → 写升级);
- StampedLock 限制:不可重入、无 Condition、无公平模式、lock() 不可中断、写频繁时乐观读反而慢;
- 选型:写多用 ReentrantLock,读多写少要可重入用 ReentrantReadWriteLock,读极多写极少的缓存 / 配置中心用 StampedLock 乐观读。
一句话总结
读多写少的本质是「把读操作从互斥中解放出来」:ReentrantReadWriteLock 用位拆分实现读写分离,StampedLock 用版本号实现「读不加锁」。前者换来了读并发,后者换来了读零开销——代价分别是禁止锁升级、不可重入。锁的世界没有银弹,每次「放开」都对应一次「牺牲」。
"读多写少场景优先考虑读写锁:ReentrantReadWriteLock 把 AQS 的 state 拆成高 16 位读计数和低 16 位写计数,读读共享、读写互斥,支持写锁降级为读锁、禁止读锁升级为写锁(会死锁);写者可能被读者挤占,需要公平模式兜底。若读流量压倒性占优且不需要可重入,用 JDK 8 的 StampedLock:乐观读先读后验,tryOptimisticRead 拿版本戳、validate 校验,成功时零锁开销,还能 tryConvertToWriteLock 尝试式升级;但它不可重入、不支持 Condition。写多为王时别用读写锁,直接用 ReentrantLock。"
👉 下一篇预告:ThreadLocal 深挖——从线程私有存储到内存泄漏,为什么线程池里必须 remove()。
Comments · 评论