← 返回题目列表

读写锁 ReentrantReadWriteLock 的原理是什么?StampedLock 又快在哪?

困难 第 25 / 31 题 更新于 2026/07/26
读写锁ReentrantReadWriteLockStampedLockAQS

简化版

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 只是并发度更高,不是「更快」。在写频繁的场景,读锁被写锁频繁阻塞,反而不如直接用独占锁或无锁结构。它只在「读远多于写」时才有优势。

完整版教学

一、为什么要读写锁:独占锁的浪费

普通的 synchronizedReentrantLock完全互斥的——任何时刻只有一个线程能进临界区,读也不例外。但「读操作不修改数据,多个读之间本不冲突」,让它们互斥是巨大的浪费:

独占锁下,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 乐观读先斩后奏最快」。