ReentrantReadWriteLock · state 高 16 位 / 低 16 位 · readerShouldBlock · 写锁饥饿 · 锁降级 · StampedLock · 乐观读 · validate · Semaphore · 舱壁 · JFR · jdk.JavaMonitorEnter · jdk.ThreadPark · jfr print

AQS 下一圈:读多写少该用哪把锁,锁竞争怎么从线上抓出来

上一圈的 AQS 源码讲了 state 加一条队列。这一圈拿它解决交易靶场的三件事:库存查询读多写少,换成读写锁会不会快;下游库存服务扛不住,怎么限住同时调用的线程数;线上几十个线程卡住了,怎么不读代码就找出是哪把锁。实测结论有点反直觉:读很短时,读写锁比普通锁还慢,公平读写锁慢 75 倍;StampedLock 的乐观读最快,因为它读的时候什么都不写。找锁竞争靠 JFR,一行启动参数,录下每一次等锁超过 10 ms 的线程、锁的类型、等了多久、在哪一行。

全景:一个下单请求在线程池里 · 第 6 站地图:J02 · AQS 与 ReentrantLock、Condition、Semaphore(第 4 个知识点,下一圈)上一圈:AQS 源码实验:JDK 17.0.20,2 核 ARM 实测

①为什么需要:全景第 6 站修好了超卖,又冒出三个问题

在全景第 6 站,这一步要解决的是:锁加上了、不超卖了,但最多 47 个线程同时 BLOCKED。再往下想,交易靶场还有三件事等着:

靶场里的事要回答的问题本页在哪讲
商品详情页每秒查几千次库存,下单才偶尔扣一次读多写少,换读写锁会快吗?StampedLock 呢?④-1、④-2、⑤ 实验 1
库存服务一次调用 30 ms,并发一高它就被打挂怎么让同一时刻最多 3 个线程去调它,其余的等一会儿或直接降级④-3、⑤ 实验 2
线上接口突然变慢,线程转储里一堆 BLOCKED 和 WAITING不读代码,怎么知道是哪把锁、在哪一行、一共卡了多久④-4、⑤ 实验 2
需求地图上这一项的深度是“懂设计”。上一圈讲透了 AQS 的队列和 ReentrantLock,这一圈补上它的三个“兄弟”:读写锁、StampedLock、Semaphore,讲清它们各自把 state 用成了什么、什么时候真能更快;再学会用 JFR 在线上找锁竞争。

②没有它会怎样:只有一把互斥锁,和一张线程转储

不懂这一点时的典型误用:觉得“读多写少就上读写锁”是常识,把 ReentrantLock 换成 ReentrantReadWriteLock,再顺手传个 true 要公平。本机实测吞吐从每秒 1515 万次读掉到 20 万次(⑤)。

③相邻方案

要解决的候选一句话
读多写少ReentrantLock读写都互斥,最简单
ReentrantReadWriteLock读和读能同时进,读和写互斥;读的时候也要改一下共享的计数
StampedLock乐观读:先不加锁读,读完核对版本号,被写过才退回去加读锁
不可变快照 + volatile 引用写的时候整份复制一个新对象再替换引用,读完全不加锁(CopyOnWriteArrayList 的思路)
护住下游Semaphore限同时进行的个数(并发数)
限流器(令牌桶,如 Guava RateLimiter、Sentinel)限每秒的次数(QPS),J12 讲
找锁竞争jstack / jcmd Thread.print一张照片,看得到谁在等谁
JFR一段录像,每次等锁超过阈值都记一条,开销低,能一直开
async-profiler 的 lock 模式按锁等待时间画火焰图,适合深挖(J13)

选择规则:读多写少先问能不能做成不可变快照;不能,再拿实测比 ReentrantLock、读写锁、StampedLock,别凭直觉。护下游用 Semaphore 管并发,用限流器管 QPS。找锁竞争:先开 JFR 录一段,再用 jstack 看现场。本页学读写锁、StampedLock、Semaphore 和 JFR 的锁事件。

④原理:同一个 AQS,state 换三种用法

先知道这些
  • AQS 的 state 和队列:一个 int 说明“能不能拿”,拿不到的线程排进双向队列 park(AQS 源码机制 1~4)。
  • CAS 和缓存行:很多线程 CAS 同一个地址,那条缓存行就在核之间来回搬(synchronized 与 CAS、JMM 下一圈)。

④-1ReentrantReadWriteLock:一个 int 劈成两半

state(一个 int,32 位) 高 16 位:读锁被持有几次(所有读线程合计) 低 16 位:写锁重入几次 读线程加锁:低 16 位是 0(没有写锁)→ CAS 把高 16 位 +1;每个线程自己重入了几次,另记在 ThreadLocal 里(HoldCounter) 写线程加锁:整个 state 是 0(没人读也没人写)→ CAS 低 16 位 +1 读锁最多 65535 次(2¹⁶ − 1),超了抛 Error 代价:“读锁”也是一次写。 每个读线程进出都要 CAS 同一个 state,这一个 int 所在的缓存行在所有核之间来回搬。读本身越短,这个代价越显眼。

④-2StampedLock:乐观读什么都不写

① tryOptimisticRead()只读 state,拿版本戳 ② 复制到局部变量a = left; b = sold; ③ validate(stamp)读屏障 + 比较版本位 没被写过:直接用 a、b 被写过:退回 readLock()重读一遍再用 关键:①和③都只读 state,所有读线程不写任何共享的东西,缓存行不用在核之间搬。写线程每次拿写锁都会让版本号变,读的一边靠 ③ 发现。 代价:② 读到的可能是“写了一半”的值,所以只能先复制、核对完再用,期间不能拿它去做有副作用的事;不可重入;没有 Condition。
JDK 17 源码:tryOptimisticRead() 就一行,state 里没有写锁位就返回版本位,否则返回 0;validate(stamp) 是 U.loadFence() 加一次比较。和上一页 jcstress 里的 MP 写法同一个道理:先读版本,再读数据,最后用一个读屏障保证“数据读在核对之前”。

④-3Semaphore:state 是剩下几个名额

④-4JFR:JVM 自带的录像机

事件什么时候记一条带着什么
jdk.JavaMonitorEnter进 synchronized 时等锁超过阈值等了多久、锁对象的类、锁地址、上一个持有者、等锁线程的栈
jdk.ThreadParkLockSupport.park 超过阈值(ReentrantLock、读写锁、Semaphore、阻塞队列底下都是它)等了多久、parkedClass(比如 ReentrantLock$NonfairSync)、栈
jdk.JavaMonitorWaitObject.wait 超过阈值等了多久、有没有超时、被谁叫醒

④-5为什么这样设计:读锁要不要“写一下”

悲观读:读锁也记账ReentrantReadWriteLock

  • 写线程能知道“现在有几个人在读”,等他们读完再写,读到的永远是完整的数据
  • 可重入、可以 Condition、可以降级,用法和 ReentrantLock 一样
  • 每次读都 CAS 同一个 state:读越短、核越多,这一个 int 越成为瓶颈

乐观读:读完再核对StampedLock(JDK 8)

  • 读的时候不写任何共享状态,读线程之间完全不互相影响
  • 可能读到写了一半的数据,代码必须“先复制、后核对、再使用”
  • 不可重入、没有 Condition,用错了容易死锁

决定胜负的约束:读的临界区有多长,和写有多频繁。读很短时,CAS 共享计数的开销盖过了“读能并行”的好处,读写锁反而输给普通锁;读很长、写很少时,读写锁才赚。乐观读把读的开销降到几乎为零,代价是写法变难,所以 JDK 把它做成一个单独的类,而不是改掉读写锁。

API最少要会的几行
ReentrantReadWriteLock rw = new ReentrantReadWriteLock();     // 别传 true,见坑 2
rw.readLock().lock();  try { return left; } finally { rw.readLock().unlock(); }
rw.writeLock().lock(); try { left--; }      finally { rw.writeLock().unlock(); }

StampedLock sl = new StampedLock();
long st = sl.tryOptimisticRead();
long a = left, b = sold;                                      // 先复制到局部变量
if (!sl.validate(st)) {                                       // 被写过:退回读锁
  st = sl.readLock(); try { a = left; b = sold; } finally { sl.unlockRead(st); }
}

Semaphore permits = new Semaphore(3);
if (permits.tryAcquire(100, TimeUnit.MILLISECONDS)) {         // 拿不到就降级
  try { callInventory(); } finally { permits.release(); }     // 只有拿到了才 release
}

# JFR
java -XX:StartFlightRecording:filename=app.jfr,settings=profile …
jfr print --events jdk.JavaMonitorEnter,jdk.ThreadPark app.jfr

JFR 的阈值怎么改、synchronized 为什么只记了 7 条、各版本的 JFR 工具差别,放到第⑥步。

⑤亲眼看到:四把锁比吞吐,JFR 抓慢锁,Semaphore 护下游

两个程序,--release 8 编译,JDK 17 运行。完整代码:study-labs/java/juc/aqs-rwlock-jfr →

实验 1:库存查询读 95%、扣减写 5%,四把锁,先猜再看

4 个线程、2 核。哪把锁的读吞吐最高?哪把最低?

最快
最慢

实验 2:锁里一次 30 ms 的慢 SQL,用 JFR 录下来

$ java -XX:StartFlightRecording:filename=slow.jfr,settings=profile \
       -cp … SlowLock
synchronized 里慢 SQL:80 单用了 2413 ms(串行下限 2400 ms)
ReentrantLock 里慢 SQL:80 单用了 2409 ms
Semaphore(3) 限并发:最多同时 3 个在调库存服务,
  等 100 ms 拿不到许可被拒绝 17 次,用了 696 ms

$ jfr summary slow.jfr
 jdk.ThreadSleep          223
 jdk.ThreadPark           109
 jdk.JavaMonitorWait        8
 jdk.JavaMonitorEnter       7
$ jfr print --events jdk.JavaMonitorEnter slow.jfr
jdk.JavaMonitorEnter {
  duration = 599 ms
  monitorClass = java.lang.Object
  previousOwner = "order-exec-7"
  address = 0xE5ABF8001F80
  eventThread = "order-exec-6"
  stackTrace = [
    …SlowLock.deductStock() line: 52
    …SlowLock.lambda$main$0() line: 23
  ]
}
事件锁卡在哪个方法条数合计等待最长一次
jdk.ThreadParkReentrantLock$NonfairSyncuseCoupon748899 ms1653 ms
jdk.JavaMonitorEnterjava.lang.ObjectdeductStock78414 ms2107 ms
jdk.ThreadParkSemaphore$NonfairSync舱壁那一段351932 ms100 ms

实测 用 jfr print --json --stack-depth 20 导出后按“事件类型 + 锁的类 + 第一个业务方法”汇总。注意 jfr print 默认只打印栈的前 5 帧,ThreadPark 的前 5 帧全是 JDK 内部,要加 --stack-depth 才看得到业务方法。

到这里已经能交差了:你知道读多写少时该实测比较哪几把锁,会写 StampedLock 的乐观读,能用 Semaphore 给下游做舱壁,能用一行参数开 JFR、用 jfr print 找到最费时间的锁和它所在的方法。
已知还没处理的细节:读写锁不一定快(⑥ 坑 1)、公平读写锁(⑥ 坑 2)、乐观读的写法(⑥ 坑 3)、读锁升级(⑥ 坑 4)、Semaphore 多放了名额(⑥ 坑 5)、JFR 的阈值和栈深(⑥ 坑 6)。

⑥细节·规范·优化

第一圈

注意事项:不知道就会出事故

坑 1读多写少就换读写锁← ④-1 读锁也要 CAS 同一个 state
现象
换完更慢了。⑤:读很短时 1090 对 1515 万次每秒
根源
每次读都 CAS state 的高 16 位,还要维护 ThreadLocal 里的重入计数;读本身只有几十纳秒时,这些开销比读还大。
做法
先测。读短:试 StampedLock 乐观读或不可变快照;读长、写少、核多:读写锁才值得。
坑 2读写锁开了公平模式← ④-1 公平:前面有人就排队
现象
吞吐断崖式下跌。⑤:每秒 20 万次,比非公平慢 50 倍、比 ReentrantLock 慢 75 倍
根源
公平模式下,只要队列里有人,新来的读线程也得排队、park、等着被叫醒;每次交接都是一次上下文切换。
做法
默认非公平。真需要“写一定能按时拿到”,先确认非公平模式下写线程等多久(本机最久 10 ms),再决定。
坑 3乐观读没先复制就用← ④-2 可能读到写了一半
现象
偶发 NullPointerException、数组越界,或者算出一个从没存在过的组合(剩余 0、已售 0)。
根源
乐观读期间写线程可能正在改,validate 之前读到的东西不可信。
做法
固定写法:tryOptimisticRead → 把字段复制到局部变量 → validate → 失败就加读锁重读 → 最后才用局部变量。另外 StampedLock 不可重入,拿着写锁再调一个也拿写锁的方法,会把自己锁死。
坑 4拿着读锁去拿写锁← ④-1 升级不行
现象
“查一下库存,不够了就补货”,线程永远卡在 writeLock().lock(),状态 WAITING。
根源
写锁要等所有读锁放掉,而等的人自己就拿着一个读锁。
做法
先放读锁再拿写锁,拿到后重新检查条件;或者用 StampedLock 的 tryConvertToWriteLock。
坑 5没拿到名额也 release← ④-3 Semaphore 不记谁拿的
现象
说好最多 3 个并发,跑一阵变成了 5 个、10 个,下游又被打挂。
根源
release 只是把 state 加一,不检查你有没有拿过。tryAcquire 失败的分支里也走了 finally 里的 release,名额就越放越多。
做法
只在拿到之后进 try,finally 里 release(⑤ 的写法)。
坑 6JFR 里什么都没看到← ④-4 阈值和栈深
现象
明明锁竞争很凶,JFR 里锁事件寥寥无几;或者看到了事件,栈里全是 JDK 内部方法。
根源
短于阈值(default 20 ms、profile 10 ms)的等待不记:竞争又多又短时要看总次数,不是单次时长。jfr print 默认只打印 5 帧栈。
做法
用 profile 配置或自定义阈值;看栈加 --stack-depth;短而多的竞争换 async-profiler 的 lock 模式。
第二圈

使用规范

换锁之前先测

用接近真实的读写比例和读耗时,在和生产相同核数的机器上比。

坑 1、坑 2。

读多写少优先考虑不加锁的读

不可变对象 + volatile 引用(写时整份替换),或者 ConcurrentHashMap。

读也要排队。

调外部服务一律有舱壁和超时

Semaphore 或线程池隔离,tryAcquire 带超时,拿不到就降级。

一个慢服务拖死整个线程池。

生产常开 JFR

default 配置、设好最大大小和保留时长,出事时 jcmd JFR.dump 拿最近一段。

问题过去了才去查,什么都没留下。

第三圈

深处:公平、插队和工具

synchronized 为什么只记了 7 条

每个线程是“拿锁 → 睡 30 ms → 放锁 → 马上又去拿”。放锁的线程正在 CPU 上,被叫醒的线程还要几微秒才能跑起来,于是放锁的线程又抢到了:synchronized 不保证公平,一个线程往往连着做完自己的 10 单,其他线程一等就是一两秒(实测最长 2107 ms),只留下少数几条很长的事件。ReentrantLock 默认也不公平,但被叫醒的队首线程和插队者同时 CAS,换手更频繁,于是有 74 条较短的事件。两者合计等待时间差不多,因为锁里的 30 ms 本来就是串行的。

StampedLock 的 state

一个 long:低 7 位记读锁个数(满了溢出到一个额外的 int),第 8 位是写锁位,再往上是版本号。每次放写锁,版本号加一,所以 validate 只要比较“版本 + 写锁位”变没变。乐观读返回的戳就是当时的这几位(JDK 17 源码 tryOptimisticRead)。JDK 8 引入,JDK 14 起它的等待队列和 AQS 一起重写过。

版本差异

JDK 5ReentrantReadWriteLock、Semaphore(都基于 AQS)
JDK 8StampedLock;8u262 起 OpenJDK 8 也带 JFR
JDK 11JFR 开源,OpenJDK 里免费可用(JEP 328);自带 jfr 命令行工具在 JDK 12 加入
JDK 14JFR 事件流(JEP 349),可以在程序里边录边读
JDK 17本页实测版本:jfr summary、jfr print --json --stack-depth
JDK 21jfr view / jcmd JFR.view,直接出“竞争最多的锁”这类汇总表

JDK 12 / 21 的工具改动按发布说明,本机没跑。

面试官会怎么问

ReentrantReadWriteLock 的 state 怎么同时表示读和写?

一个 int 劈成两半:高 16 位是读锁持有次数,低 16 位是写锁重入次数;每个线程自己的读重入次数存在 ThreadLocal 里(④-1)。

读写锁会不会让写线程饿死?

非公平模式下,新读线程看到队首是写线程就会让,所以通常不会无限饿死,但这只是启发式;写线程的等待会被拉长(④-1、⑤ 写等锁最久 10 ms)。

StampedLock 和 ReentrantReadWriteLock 有什么区别?

多一个乐观读:读时不写共享状态,读完 validate;不可重入、没有 Condition;读很短时快很多(⑤:2293 对 1090 万次每秒)(④-2)。

线上怎么定位锁竞争?

JFR 录 jdk.JavaMonitorEnter 和 jdk.ThreadPark,按锁和方法汇总等待时间;再用 jstack -l 看当前持有者;短而频繁的用 async-profiler lock 模式(④-4、⑤)。

下一圈

↺回到⑤:改造你的案例

1. 库存快照:读完全不加锁

给 RwBench 加第五种:库存三个字段放进一个不可变对象,volatile 引用指向它;写的时候加锁、new 一个新对象替换引用,读直接读引用。和 StampedLock 比读吞吐。

用到:③、规范 2

2. 把慢 SQL 挪到锁外,再录一次

改 SlowLock.deductStock:锁里只做“检查 + 扣减”,30 ms 的 SQL 放在锁外。重新开 JFR,对比 jfr summary 里锁事件的条数和总耗时。

用到:坑 6、synchronized 页坑 2

3. 故意写错 Semaphore

把舱壁改成 try { if (!tryAcquire(…)) continue; … } finally { release(); },看“最多同时几个”还是不是 3。

用到:坑 5

自测

1. 用一句话说出这一圈的精华。

读写锁、StampedLock、Semaphore 都是 AQS 换了 state 的用法:读写锁让读也去改共享计数,所以读短时不一定快;乐观读不写任何东西,最快但写法讲究;Semaphore 用名额限并发。锁竞争用 JFR 的锁事件录下来,按锁和方法汇总等待时间。

2. 只有互斥锁和 jstack 时,疼在哪?

读也互相排队;下游没人护着;jstack 只是一张照片,看不出等了多久、多少次(②)。

3. 读写锁在读很短时反而慢,根源是 ④ 的哪一条?

④-1:读锁加锁也要 CAS 同一个 state、维护 ThreadLocal 计数,那条缓存行在核之间来回搬。

4. 预测:把实验 1 放到 32 核机器上、32 个线程,读很长时,读写锁和 ReentrantLock 的差距会变大还是变小?读很短时呢?

读很长:差距变大,读写锁能让几十个读同时进行,ReentrantLock 只能一个一个来。读很短:读写锁可能更糟,32 个核一起 CAS 同一个 state,缓存行争用比 2 核严重得多;StampedLock 乐观读的优势会更明显(④-1、④-5)。

5. 预测:库存服务的一次调用从 30 ms 变成 300 ms,Semaphore(3) 加 100 ms 超时的舱壁,每秒能放过去多少次?被拒绝的比例怎么变?

3 个名额 × 每秒约 3.3 次 ≈ 每秒 10 次,比原来的约 100 次少一个数量级;排队的线程大多等不到 100 ms 就被拒绝,拒绝比例大幅上升。舱壁把损失限制在“这个功能降级”,线程池其他请求不受影响(④-3)。

6. 放回全景:第 6 站 47 个线程 BLOCKED。如果那时生产上开着 JFR,你会看到哪种事件、哪个锁类、哪一行?第 2 站的线程池会怎样?

jdk.JavaMonitorEnter,monitorClass 是那把锁对象的类(全景程序里是 java.lang.Object),栈顶在 StockService 的 sync 分支;previousOwner 告诉你上一个持有者。第 2 站:锁里慢,exec 线程大量 BLOCKED,200 个线程被占满后新请求排进 Tomcat 的队列,接口整体变慢。修法:锁里只放检查和扣减,或者交给第 8 站的条件更新。