ReentrantReadWriteLock 与 StampedLock:读多写少场景和乐观读(039)

Java并发编程约 10 分钟阅读5,081 字符
知识库文档 40 / 251

一、普通互斥锁在读多写少场景下浪费了什么

前一篇讲的 ReentrantLock,本质上还是一把彻底的互斥锁——不管是读操作还是写操作,只要涉及这把锁保护的数据,同一时刻都只能有一个线程进入。但仔细想想很多实际场景:一份配置数据,写入(更新配置)可能一天只发生几次,而读取(查询配置)可能每秒被调用成千上万次。多个线程同时只读这份数据,彼此之间完全不会产生冲突,本来是可以安全并发的,但普通互斥锁把这些本可以并发的读操作也强行串行化了,白白浪费了并发能力。

这就是读写锁要解决的问题:把"读"和"写"这两种性质不同的访问区分开,读操作之间可以互相并发,只有涉及写操作时才需要真正互斥。

二、ReentrantReadWriteLock:读锁与写锁分离

ReentrantReadWriteLock 内部维护两把锁:读锁(共享锁)和写锁(独占锁),通过 readLock() 和 writeLock() 分别获取。它们遵循三条基本规则:

  • 读读不互斥:多个线程可以同时持有读锁,并发读取。
  • 读写互斥:只要有线程持有读锁,写锁就无法被获取;反之只要有线程持有写锁,读锁也无法被获取。
  • 写写互斥:写锁是独占的,同一时刻只能有一个线程持有。

文章配图
文章配图

这套读写分离机制,正是上一篇提到的 AQS 共享模式和独占模式的一次完整实战:读锁走共享模式(tryAcquireShared),写锁走独占模式(tryAcquire)。而且巧妙的地方在于,读锁和写锁的状态共用同一个 state 字段——把 32 位的 state 拆成两部分:高 16 位记录当前持有读锁的线程数量,低 16 位记录写锁的重入次数。这样只需要一次 CAS,就能原子性地判断和更新"读锁数+写锁重入"这个组合状态,不需要维护两个独立字段还要额外保证它们之间的一致性。

文章配图
文章配图

三、锁降级可以,锁升级不行

ReentrantReadWriteLock 支持一种叫锁降级的操作:一个线程在持有写锁的期间,可以直接再获取读锁,然后再释放写锁——这样整个过程中,这个线程对数据的可见性始终是连续的,不会出现"释放写锁的瞬间,别的线程插进来改了数据,自己却还蒙在鼓里"的情况。典型用法是:写完数据后,为了在后续继续读取时保持这份数据的最新状态不被其他线程修改,可以先降级为读锁再继续读,读完再释放读锁。

但反过来的锁升级——持有读锁的时候试图直接获取写锁——是不被支持的,这么做会导致死锁。原因很直接:如果两个线程都先持有了读锁,然后都试图升级为写锁,那么线程A要等线程B释放读锁才能拿到写锁,线程B同样要等线程A释放读锁才能拿到写锁,双方都在等对方先放手,谁也不会主动放手,就死锁了。

文章配图
文章配图

四、StampedLock:为读多写少再往前走一步

ReentrantReadWriteLock 已经解决了"读读可以并发"的问题,但在读操作极其频繁的场景下,还有一层隐藏的开销:多个线程并发获取读锁时,仍然需要通过 CAS 修改那个共享的 state 字段(把高16位的读锁计数加一),当读线程数量非常多、频率非常高时,这些看似轻量的 CAS 操作之间仍然会产生竞争,成为性能瓶颈。

StampedLock 在 Java 8 引入,进一步引入了乐观读模式:读取数据时完全不加锁、不修改任何共享状态,只是记录一个当前的版本戳(stamp),读完之后再检查一下这个版本戳有没有变化——如果没变,说明读的过程中没有写操作插入,这次读取的数据是可靠的;如果变了,说明中间有写操作发生过,刚才读到的数据可能不一致,需要退回到加锁的悲观读重新读一次。

文章配图
文章配图

StampedLock 提供三种获取方式:

  • 写锁(writeLock):独占模式,和普通写锁一样,会真正阻塞其他所有读写操作。
  • 悲观读锁(readLock):共享模式,类似 ReentrantReadWriteLock 的读锁,会真正获取一把锁,读写互斥、写写互斥。
  • 乐观读(tryOptimisticRead):完全不阻塞、不加锁,只是记录版本戳,靠事后校验来判断数据是否可靠。

文章配图
文章配图

五、StampedLock 的代价:更快,但也更容易用错

StampedLock 的性能优势建立在"读操作大概率不会撞上写操作、事后校验代价很低"的假设上,但它也带来了明显的使用门槛:

  • 不支持重入:如果同一个线程在已经持有写锁的情况下再次尝试获取写锁,会导致死锁——这一点和 ReentrantLock、ReentrantReadWriteLock 都不一样,使用时必须格外小心,绝不能像用 ReentrantLock 那样想着"反正能重入"就随手嵌套调用。
  • 不支持 Condition:StampedLock 没有提供类似 newCondition() 的能力,如果业务需要精细的等待通知机制,StampedLock 无法胜任,只能退回 ReentrantLock。
  • 乐观读不能直接使用读到的数据做后续有副作用的操作:必须先 validate 通过之后才能确认数据可靠,如果在校验之前就把读到的中间数据用于打印日志、发起网络请求等有副作用的操作,一旦数据实际不一致,副作用已经发生,无法撤销。

正因为这些限制,StampedLock 通常只在性能确实是瓶颈、且能严格按照"乐观读+校验+失败后悲观读重试"这套规范写代码的场景下才值得引入,日常业务代码里 ReentrantReadWriteLock 甚至 ReentrantLock 依然是更稳妥、更不容易踩坑的选择。

六、面试追问链

从"普通互斥锁在读多写少场景下浪费了什么"出发,逐层追问:ReentrantReadWriteLock 的读锁和写锁分别对应 AQS 的哪种模式、它的 state 字段是怎么同时表达读锁数和写锁重入次数的、为什么支持锁降级却不支持锁升级、StampedLock 相比 ReentrantReadWriteLock 在读性能上做了什么进一步优化、乐观读是怎么通过版本戳判断数据是否可靠的、以及为什么 StampedLock 不支持重入和 Condition,这条链基本能覆盖面试官对读写锁体系的核心考察点。

文章配图
文章配图

延伸阅读

  • java.util.concurrent.locks.ReentrantReadWriteLock 源码,重点看内部类 ReadLock/WriteLock 的 tryAcquireShared/tryAcquire 实现
  • java.util.concurrent.locks.StampedLock 源码,重点看 tryOptimisticRead() 和 validate() 如何配合版本戳工作
  • 下一篇预告:wait/notify 与 Condition——等待通知模型、虚假唤醒和标准范式

问答(不计入阅读时长)

Q1:ReentrantReadWriteLock 的读锁和写锁,本质上是两个独立的锁对象吗?

A:不是独立的锁,而是共享同一个 Sync(继承自 AQS)的两个视图。readLock() 和 writeLock() 返回的 ReadLock/WriteLock 对象,底层都持有同一个外部类实例的 sync 引用,操作的是同一个 state 字段——只是读锁走 tryAcquireShared/tryReleaseShared,写锁走 tryAcquire/tryRelease。可以理解成"一把锁,两种加锁方式",而不是"两把锁",这也是为什么它们能共享同一个 state 来协调彼此。

Q2:如果一个线程已经持有读锁,这时另一个线程能不能获取写锁?

A:不能。只要 state 的高16位(读锁计数)不为 0,写锁的 tryAcquire 就会直接失败,不管是哪个线程持有读锁。因为写锁要求独占,一旦有任何读锁存在,写操作就必须等待所有读锁释放。这也是"读写互斥"规则的具体体现——不区分"是不是自己持有",只要读锁计数非零,写锁一律拿不到(除非是持有写锁的线程本身在做重入判断,那是另一套逻辑)。

Q3:锁降级里,"先获取读锁再释放写锁"这个顺序能不能反过来?

A:不能反过来。如果先释放写锁再获取读锁,那么在"释放写锁"和"获取读锁"这两步之间就存在一个空档,其他线程完全可能在这个空档插进来获取写锁并修改数据,等你再去获取读锁时,数据已经不是你刚写完的那个版本了,整个"降级"的意义(保持数据可见性连续)就被破坏了。所以锁降级必须严格遵守"先拿读锁、再放写锁"的顺序,中间不能有释放的空档。

Q4:StampedLock 的 stamp(版本戳/版本号)具体是怎么变化的,是一个自增序列吗?

A:不完全是简单自增,但可以理解成"每次成功的写锁获取/释放都会导致这个数值发生变化"。具体实现上,StampedLock 内部同样用一个 state 字段的一部分位来编码版本信息,每次写锁被成功获取一次,这部分位就会变化一次;validate() 做的事情就是比较"当前 state 里的版本部分"和"当初 tryOptimisticRead() 返回的那个 stamp 里的版本部分"是否一致。只要中间发生过一次完整的写锁获取释放,版本部分必然变化,validate() 就会返回 false。

Q5:乐观读期间,如果读到的是"半个写操作"(数据正在被修改、还没写完)的中间状态,会不会有问题?

A:这正是为什么乐观读读到的数据不能直接拿去做有副作用的操作,必须先 validate 再决定要不要用。如果乐观读恰好和一次写操作在时间上重叠,读到的确实可能是不一致的中间状态(比如一个对象的两个字段本该配对更新,却只读到其中一个字段被更新后的值)。但没关系,因为只要中间发生了写操作,validate() 一定会返回 false,调用方按约定重新走一次悲观读拿到一致的数据即可——乐观读的"乐观"体现在它假设大部分时候不会撞上写操作,一旦撞上,用校验机制兜底,而不是假设读到的中间状态可以直接信任。

Q6:既然 StampedLock 性能更好,为什么不直接把 ReentrantReadWriteLock 全部替换成 StampedLock?

A:因为 StampedLock 的性能优势是用"更难正确使用"换来的。它不可重入(同线程重复获取写锁会死锁)、不支持 Condition(无法做精细的等待通知)、乐观读的结果必须手动 validate(业务代码里稍不注意就会直接使用未校验的数据)。ReentrantReadWriteLock 虽然性能上限更低,但可重入、语义更符合直觉、出错概率更低。选型应该基于"这个场景是不是真的读多写少到需要极致性能,并且团队能保证正确使用 StampedLock 的规范",不是简单的"新的更好"。

Q7:读锁支持重入吗?如果一个线程连续两次获取读锁会怎样?

A:读锁支持重入,同一个线程可以多次获取读锁,state 的高16位(读锁计数)并不区分"这次是不是同一个线程",每次成功获取读锁都会让这个计数加一(不过 ReentrantReadWriteLock 内部为了支持"重入"判断和后续正确释放,会用一个 ThreadLocal 结构给每个线程单独记录它自己持有的读锁重入次数)。但要注意一种特殊情况:如果一个线程已经持有读锁,之后又尝试获取写锁,这属于前面提到的"锁升级",是不允许的,会导致获取失败或死锁风险,跟"读锁本身可重入"是两件不同的事。

Q8:StampedLock 的悲观读锁(readLock)和 ReentrantReadWriteLock 的读锁,效果是不是完全一样?

A:效果类似但不完全一样。两者都是共享模式、读写互斥、写写互斥,但 StampedLock 的悲观读锁不可重入——同一个线程连续两次获取 StampedLock 的悲观读锁会导致线程被自己阻塞(因为它不会识别"这是同一个线程再次获取",而是当作一次新的竞争)。这也是为什么使用 StampedLock 时必须格外小心,绝不能凭着用 ReentrantReadWriteLock 积累的"读锁可以随便重入"的直觉去写 StampedLock 的代码。