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 |
②没有它会怎样:只有一把互斥锁,和一张线程转储
- 读也要排队。只用 ReentrantLock 或 synchronized,一千个查库存的线程也只能一个一个进;明明谁都没改数据,却在互相等。
- 下游没人护着。线程池有 200 个线程,库存服务只扛得住几个并发,所有线程一起涌过去,它一慢,200 个线程全卡在它身上(全景第 2 站的池子被占满)。
- 线程转储只是一张照片。
jstack拍到的是那一瞬间:碰巧拍到几个 BLOCKED,不知道它们已经等了多久、一分钟里一共等了多少次、是不是每次都卡在同一把锁上。拍得不巧,问题就漏过去了。 - 缺的那一块:按读写区分的锁、按名额放行的闸门,和一台一直开着的“录像机”。前两个都是 AQS 换个用法;录像机是 JDK 自带的 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 劈成两半
- 1写锁会不会被读锁饿死?非公平模式下,新来的读线程只有在“队首等着的是一个写线程”时才让一让(源码里的
readerShouldBlock()调apparentlyFirstQueuedIsExclusive(),注释原话是“避免写线程无限饥饿的一个启发式办法,只在概率上有效”)。所以一般不会饿死,但写线程等的时间会被一串读拉长。实测 写线程等锁最久 10.0~10.6 ms,比 ReentrantLock 的 6.9~7.4 ms 长。 - 2公平模式:读线程只要看到前面有人排队(
hasQueuedPredecessors())就去排队。高并发下几乎每次加锁都要进队列、park、被叫醒,交接一次就是几微秒的上下文切换。 - 3锁降级可以,升级不行。拿着写锁再拿读锁,然后放写锁,可以(降级);拿着读锁去拿写锁,要等所有读锁都放掉,而自己就拿着一个,于是永远等下去(⑥ 坑 4)。
④-2StampedLock:乐观读什么都不写
tryOptimisticRead() 就一行,state 里没有写锁位就返回版本位,否则返回 0;validate(stamp) 是 U.loadFence() 加一次比较。和上一页 jcstress 里的 MP 写法同一个道理:先读版本,再读数据,最后用一个读屏障保证“数据读在核对之前”。④-3Semaphore:state 是剩下几个名额
- 1
new Semaphore(3)把 AQS 的 state 设成 3。acquire()是 AQS 的共享模式:CAS 把 state 减一,减不下去(已经是 0)就排队 park;release()加一,再叫醒排队的。和 ReentrantLock 不一样,它不记“谁拿了”,任何线程都能 release。 - 2拿它护住下游,叫舱壁(bulkhead):调库存服务前先拿一个名额,名额用完就等一会儿,等不到就降级(返回“稍后再试”或读缓存)。这样库存服务最多同时被 3 个线程调用,它慢了也只卡住这 3 个,线程池里其他 197 个照样干别的。
- 3它限的是同时进行的个数,不是每秒次数。调用 30 ms 的服务、3 个名额,大约每秒能过 100 次;调用变成 300 ms,就只剩每秒 10 次。要按 QPS 限,用令牌桶(J12)。
④-4JFR:JVM 自带的录像机
| 事件 | 什么时候记一条 | 带着什么 |
|---|---|---|
jdk.JavaMonitorEnter | 进 synchronized 时等锁超过阈值 | 等了多久、锁对象的类、锁地址、上一个持有者、等锁线程的栈 |
jdk.ThreadPark | LockSupport.park 超过阈值(ReentrantLock、读写锁、Semaphore、阻塞队列底下都是它) | 等了多久、parkedClass(比如 ReentrantLock$NonfairSync)、栈 |
jdk.JavaMonitorWait | Object.wait 超过阈值 | 等了多久、有没有超时、被谁叫醒 |
- 1阈值:JDK 自带的
default.jfc是 20 ms,profile.jfc是 10 ms(本页用 profile)。短于阈值的等待不记,所以 JFR 抓的是“等得久的”,不是“所有的”。 - 2怎么开:启动时加
-XX:StartFlightRecording:filename=x.jfr,settings=profile;已经在跑的进程用jcmd <pid> JFR.start、JFR.dump。看:jfr summary、jfr print --events …,或者用 JDK Mission Control 打开。官方说 default 配置开销通常在 1% 以内,所以很多团队生产上一直开着、出事了 dump 最近一段。
④-5为什么这样设计:读锁要不要“写一下”
悲观读:读锁也记账ReentrantReadWriteLock
- 写线程能知道“现在有几个人在读”,等他们读完再写,读到的永远是完整的数据
- 可重入、可以 Condition、可以降级,用法和 ReentrantLock 一样
- 每次读都 CAS 同一个 state:读越短、核越多,这一个 int 越成为瓶颈
乐观读:读完再核对StampedLock(JDK 8)
- 读的时候不写任何共享状态,读线程之间完全不互相影响
- 可能读到写了一半的数据,代码必须“先复制、后核对、再使用”
- 不可重入、没有 Condition,用错了容易死锁
决定胜负的约束:读的临界区有多长,和写有多频繁。读很短时,CAS 共享计数的开销盖过了“读能并行”的好处,读写锁反而输给普通锁;读很长、写很少时,读写锁才赚。乐观读把读的开销降到几乎为零,代价是写法变难,所以 JDK 把它做成一个单独的类,而不是改掉读写锁。
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 核。哪把锁的读吞吐最高?哪把最低?
- 1实测 读很短时两次运行:ReentrantLock 1515 / 1503 万次每秒,读写锁 1090 / 1052,公平读写锁 20 / 24,StampedLock 乐观读 2293 / 2328。乐观读有约 4% 核对失败、退回读锁(两次各约 184 万次)。
- 2读很长时:读写锁 47 比 ReentrantLock 42,只领先 12%。2 核上同一时刻最多两个读真正并行,写又占了 5%;核越多、读越长,读写锁的优势才越大。StampedLock 75,领先 1.8 倍。
实验 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.ThreadPark | ReentrantLock$NonfairSync | useCoupon | 74 | 8899 ms | 1653 ms |
jdk.JavaMonitorEnter | java.lang.Object | deductStock | 7 | 8414 ms | 2107 ms |
jdk.ThreadPark | Semaphore$NonfairSync | 舱壁那一段 | 35 | 1932 ms | 100 ms |
实测 用 jfr print --json --stack-depth 20 导出后按“事件类型 + 锁的类 + 第一个业务方法”汇总。注意 jfr print 默认只打印栈的前 5 帧,ThreadPark 的前 5 帧全是 JDK 内部,要加 --stack-depth 才看得到业务方法。
- 1不读代码就能得出结论:两把锁各让线程一共等了 8 秒多,一把是
deductStock里的 synchronized,一把是useCoupon里的 ReentrantLock;Semaphore 那段每次最多等 100 ms,是设计好的。两个慢锁的总耗时都等于“80 次 × 30 ms”,说明锁里的东西是完全串行的。 - 2synchronized 只记了 7 条,ReentrantLock 记了 74 条,合计时间却差不多。原因在 ⑥ 第三圈:synchronized 放锁的线程马上又抢回来,其他线程一等就是一两秒。
- 3Semaphore(3):80 次调用最多同时 3 个,63 次拿到名额,17 次等 100 ms 没拿到被拒绝(这些在真实系统里就走降级),总共 696 ms,没有任何线程被库存服务拖住超过 100 ms。
⑥细节·规范·优化
注意事项:不知道就会出事故
- 现象
- 换完更慢了。⑤:读很短时 1090 对 1515 万次每秒
- 根源
- 每次读都 CAS state 的高 16 位,还要维护 ThreadLocal 里的重入计数;读本身只有几十纳秒时,这些开销比读还大。
- 做法
- 先测。读短:试 StampedLock 乐观读或不可变快照;读长、写少、核多:读写锁才值得。
- 现象
- 吞吐断崖式下跌。⑤:每秒 20 万次,比非公平慢 50 倍、比 ReentrantLock 慢 75 倍
- 根源
- 公平模式下,只要队列里有人,新来的读线程也得排队、park、等着被叫醒;每次交接都是一次上下文切换。
- 做法
- 默认非公平。真需要“写一定能按时拿到”,先确认非公平模式下写线程等多久(本机最久 10 ms),再决定。
- 现象
- 偶发
NullPointerException、数组越界,或者算出一个从没存在过的组合(剩余 0、已售 0)。 - 根源
- 乐观读期间写线程可能正在改,validate 之前读到的东西不可信。
- 做法
- 固定写法:tryOptimisticRead → 把字段复制到局部变量 → validate → 失败就加读锁重读 → 最后才用局部变量。另外 StampedLock 不可重入,拿着写锁再调一个也拿写锁的方法,会把自己锁死。
- 现象
- “查一下库存,不够了就补货”,线程永远卡在
writeLock().lock(),状态 WAITING。 - 根源
- 写锁要等所有读锁放掉,而等的人自己就拿着一个读锁。
- 做法
- 先放读锁再拿写锁,拿到后重新检查条件;或者用 StampedLock 的
tryConvertToWriteLock。
- 现象
- 说好最多 3 个并发,跑一阵变成了 5 个、10 个,下游又被打挂。
- 根源
- release 只是把 state 加一,不检查你有没有拿过。
tryAcquire失败的分支里也走了 finally 里的 release,名额就越放越多。 - 做法
- 只在拿到之后进 try,finally 里 release(⑤ 的写法)。
- 现象
- 明明锁竞争很凶,JFR 里锁事件寥寥无几;或者看到了事件,栈里全是 JDK 内部方法。
- 根源
- 短于阈值(default 20 ms、profile 10 ms)的等待不记:竞争又多又短时要看总次数,不是单次时长。
jfr print默认只打印 5 帧栈。 - 做法
- 用 profile 配置或自定义阈值;看栈加
--stack-depth;短而多的竞争换 async-profiler 的 lock 模式。
使用规范
用接近真实的读写比例和读耗时,在和生产相同核数的机器上比。
坑 1、坑 2。
不可变对象 + volatile 引用(写时整份替换),或者 ConcurrentHashMap。
读也要排队。
Semaphore 或线程池隔离,tryAcquire 带超时,拿不到就降级。
一个慢服务拖死整个线程池。
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 5 | ReentrantReadWriteLock、Semaphore(都基于 AQS) |
|---|---|
| JDK 8 | StampedLock;8u262 起 OpenJDK 8 也带 JFR |
| JDK 11 | JFR 开源,OpenJDK 里免费可用(JEP 328);自带 jfr 命令行工具在 JDK 12 加入 |
| JDK 14 | JFR 事件流(JEP 349),可以在程序里边录边读 |
| JDK 17 | 本页实测版本:jfr summary、jfr print --json --stack-depth |
| JDK 21 | jfr 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、⑤)。
- 用 JFR 事件流(
RecordingStream)在程序里实时统计锁等待,超过阈值就打日志或报警 - JDK Mission Control 的“锁实例”和“线程”视图,配合
jfr view(JDK 21) - async-profiler 的 lock 和 wall 模式,看短而多的竞争(J13)
FutureTask为什么从 AQS 换成了自己的等待栈(上一圈列过,留给 CompletableFuture 那一页)- 限流:令牌桶、漏桶、Sentinel 的并发线程数限流和 Semaphore 的关系(J12)
↺回到⑤:改造你的案例
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 站的条件更新。