读写锁 ReentrantReadWriteLock 的原理是什么?StampedLock 又快在哪?
简化版
ReentrantReadWriteLock 是读写分离锁:读锁共享(多个线程可同时读)、写锁独占(写时谁都不能读或写)。它适合「读多写少」场景——比独占锁并发度高得多,因为大量读操作能并行。底层靠 AQS 的一个 int state:高 16 位记读锁计数、低 16 位记写锁计数。StampedLock(Java 8)是它的升级:多了一种「乐观读」——读的时候先不加锁,读完用一个 stamp 校验期间没被写过,没写过就直接用(零锁开销),被写过再降级为悲观读锁。读远多于写时 StampedLock 更快,但它不可重入、不支持 Condition,用起来更复杂。
详细版
ReentrantReadWriteLock 的规则(读读共享,其余互斥):
| 已持读锁 | 已持写锁 | |
|---|---|---|
| 想加读锁 | ✅ 允许(共享) | ❌ 阻塞 |
| 想加写锁 | ❌ 阻塞 | ✅ 允许(重入) |
一句话:读读不互斥,读写、写写互斥。
ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
Lock readLock = rwLock.readLock();
Lock writeLock = rwLock.writeLock();
// 读:多线程可并行
readLock.lock();
try { return cache.get(key); } finally { readLock.unlock(); }
// 写:独占
writeLock.lock();
try { cache.put(key, value); } finally { writeLock.unlock(); }
几个关键特性:
- 锁降级支持:持有写锁时可以再获取读锁,然后释放写锁——「写锁降级为读锁」,保证数据修改后能安全读取。但不支持锁升级(持读锁直接申请写锁会死锁)。
- 可重入:同一线程可重复获取。
- 公平/非公平可选。
StampedLock 三种模式:
StampedLock sl = new StampedLock();
// ① 乐观读:不加锁,最快
long stamp = sl.tryOptimisticRead();
int x = data; // 读数据
if (!sl.validate(stamp)) { // 校验期间有没有被写
stamp = sl.readLock(); // 被写过 → 降级为悲观读锁
try { x = data; } finally { sl.unlockRead(stamp); }
}
// ② 悲观读锁(类似读锁) ③ 写锁(独占)
⚠️ ReentrantReadWriteLock 只是并发度更高,不是「更快」。在写频繁的场景,读锁被写锁频繁阻塞,反而不如直接用独占锁或无锁结构。它只在「读远多于写」时才有优势。
完整版教学
一、为什么要读写锁:独占锁的浪费
普通的 synchronized 或 ReentrantLock 是完全互斥的——任何时刻只有一个线程能进临界区,读也不例外。但「读操作不修改数据,多个读之间本不冲突」,让它们互斥是巨大的浪费:
独占锁下,10 个读线程访问缓存:
读1 [==] 读2 [==] 读3 [==] ... 必须排队,串行
读写锁下:
读1 [==] 读2 [==] 读3 [==] ... 可以同时读,并行
读写锁的洞察是:冲突只发生在「写」参与时。读读之间无冲突(都不改数据),可以共享;只要有写,就必须独占(写-读会读到中间态,写-写会互相覆盖)。于是它把锁拆成读锁(共享)和写锁(独占),让读操作并行,大幅提升「读多写少」场景的吞吐。
二、一个 int 怎么同时记读写两个计数
ReentrantReadWriteLock 基于 AQS,而 AQS 只有一个 int 类型的 state。它用按位拆分把一个 int 当两个计数器用:
32 位 state:
高 16 位 = 读锁计数(所有读线程共享这个计数)
低 16 位 = 写锁计数(写锁重入次数)
state = 0x0002_0001
├─高16位 0x0002 = 2 个读锁
└─低16位 0x0001 = 写锁被持有1次
读锁数 = state >>> 16
写锁数 = state & 0xFFFF
获取写锁时检查低 16 位(有没有别人持写锁);获取读锁时检查低 16 位(有没有人持写锁),没有就把高 16 位加 1。这个位运算技巧让单个 state 表达了「读共享计数 + 写独占计数」两种状态,是 AQS 设计的经典范例。最大读/写线程数因此被限制在 2^16-1 = 65535。
三、锁降级:为什么写完要先拿读锁再放写锁
ReentrantReadWriteLock 支持锁降级(写锁→读锁),且这是有意义的模式:
writeLock.lock(); // 持写锁,修改数据
try {
cache = newData;
readLock.lock(); // ★ 在释放写锁前,先获取读锁
} finally {
writeLock.unlock(); // 释放写锁,此时降级为只持读锁
}
try {
use(cache); // 用刚写的数据,期间别的线程无法写(被读锁挡住)
} finally {
readLock.unlock();
}
为什么要这么绕?为了保证「写完到读」这段时间数据不被别人改。如果直接 writeLock.unlock() 再 readLock.lock(),中间有个空隙——别的写线程可能插进来改数据,你接着读到的就不是自己刚写的值了。降级(先拿读锁再放写锁)消除了这个空隙。注意:只能降级不能升级——持读锁时直接申请写锁会死锁(写锁要等所有读锁释放,但你自己的读锁不放,永远等不到)。
四、StampedLock 的乐观读:连读锁都不加
ReentrantReadWriteLock 虽然读能共享,但读锁仍是锁——加锁/解锁有 CAS 开销,而且读锁会阻塞写锁(大量读会让写「饥饿」)。StampedLock 的突破是「乐观读」:读的时候先不加任何锁,读完再验证。
乐观读流程:
1. stamp = tryOptimisticRead() 拿一个"版本戳",不加锁
2. 读取数据(此时可能有写线程在改)
3. validate(stamp):检查从第1步到现在,有没有写锁介入过
- 没写过 → 数据有效,直接用(全程零锁!)
- 写过了 → 数据可能脏,降级为悲观 readLock 重读一遍
关键洞察:读多写少时,绝大多数读期间根本没有写发生,乐观读的 validate 几乎总是通过,于是这些读全程零锁开销,比读写锁的「加读锁」还快。只有偶尔撞上写,才付出「重读一次」的代价。这是一种「先斩后奏、事后校验」的乐观思想,和 CAS、乐观锁同源。
五、三者性能对比与代价
| 锁 | 读读 | 读写 | 写写 | 读的开销 | 特性 |
|---|---|---|---|---|---|
| ReentrantLock(独占) | 互斥 | 互斥 | 互斥 | 加锁 | 可重入、Condition |
| ReentrantReadWriteLock | 共享 | 互斥 | 互斥 | 加读锁(CAS) | 可重入、锁降级、Condition |
| StampedLock | 乐观/共享 | 互斥 | 互斥 | 乐观读零锁 | 不可重入、无 Condition |
StampedLock 快是有代价的:
StampedLock 的限制:
- 不可重入:同一线程重复获取会死锁(RRW 可重入)
- 不支持 Condition:没有条件等待
- 乐观读期间数据可能变化:读到的字段要整体一致校验,
否则可能读到"半新半旧"的字段组合(所以常把字段读进局部变量再 validate)
- API 复杂:用错 stamp、忘记降级都会出 bug
所以选型:一般读多写少用 ReentrantReadWriteLock(简单、可重入、够用);读极多、写极少、且追求极致性能、能接受复杂 API 才上 StampedLock。
六、写饥饿与公平性
读写锁有个隐患:写饥饿。如果读线程源源不断,读锁一直被持有(读读共享,可以无限续),写线程可能永远等不到「所有读锁都释放」的时刻:
非公平模式下:
读1 持读锁 → 写线程等待 → 读2 又来(读读共享,直接获取)→ 读3...
→ 读锁计数一直 > 0 → 写线程饿死
ReentrantReadWriteLock 的对策:公平模式下按 FIFO 排队,写线程前面若有等待就不让新读线程插队,缓解饥饿;但公平模式吞吐更低。StampedLock 的写锁优先级更高(乐观读不阻塞写),对写饥饿更友好。这也提醒:读写锁不是银弹,写较多时它的读写互斥反而让写更难获取,此时不如用独占锁或分段设计。
记忆钩子:「读读共享、读写写写互斥;一个 int 高16位记读、低16位记写;只能降级不能升级;StampedLock 加乐观读——先不锁、读完 validate,读多写少时零锁最快」。
七、常见误区与追问
- 误区:读写锁一定比独占锁快。 只在读多写少时更快(读能并行);写频繁时读锁被写锁频繁阻塞、还可能写饥饿,反而更慢。
- 误区:ReentrantReadWriteLock 支持锁升级。 不支持——持读锁直接申请写锁会死锁;只支持锁降级(写→读),且降级要「先拿读锁再放写锁」。
- 误区:StampedLock 可重入。 不可重入,同一线程重复获取会死锁;也不支持 Condition,比 RRW 限制多。
- 误区:乐观读能保证读到最新值。 乐观读可能读到正在被修改的数据,必须 validate 校验;校验失败要降级为悲观读锁重读。
- 追问:读写锁的 state 怎么同时表示读和写计数? 一个 32 位 int,高 16 位存读锁总数、低 16 位存写锁重入数,用位运算拆分,所以读写线程数各上限 65535。
- 追问:什么是写饥饿,怎么缓解? 读线程源源不断使读锁一直非空,写线程永远等不到释放;用公平模式排队、或用 StampedLock(写优先)缓解。
- 追问:StampedLock 的乐观读为什么要把字段读进局部变量? 乐观读期间数据可能被写改,直接用字段可能读到「半新半旧」的组合;先读进局部变量、validate 通过后再用,保证一致性。
八、加强记忆
读写锁的核心洞察是「冲突只在写参与时发生」:读读不改数据可以共享,读写/写写必须互斥。ReentrantReadWriteLock 据此把锁拆成共享读锁 + 独占写锁,用 AQS 的一个 int(高 16 位记读锁计数、低 16 位记写锁计数)实现,支持可重入、公平性、以及「先拿读锁再放写锁」的锁降级(只能降级不能升级,升级会死锁),适合读多写少。StampedLock 更进一步加了乐观读——读时先不加锁拿个 stamp,读完 validate 校验期间没被写就直接用(零锁开销),被写过再降级为悲观读锁重读;读极多写极少时最快,但代价是不可重入、无 Condition、API 复杂、乐观读要把字段读进局部变量再校验。两者都要警惕写饥饿(读不停则写等不到)。选型记住:写多别用读写锁,一般读多写少用 RRW,追极致性能且能接受复杂才上 StampedLock。一句话「读共享写独占、int 高低位分记、只降不升、Stamped 乐观读先斩后奏最快」。