jcstress · 结果直方图 · 存储缓冲 · 失效队列 · MESI · 缓存行 · x86 TSO · ARM 弱内存序 · ldar / stlr · SB · MP · 伪共享 · @Contended · LongAdder · AQS / ConcurrentHashMap 里的 volatile 和 CAS
JMM 下一圈:在 ARM 上把重排序真跑出来,再往下看一层 CPU 缓存
上一圈的 Java 内存模型 讲了规则:没有 happens-before,就不保证看得到、不保证顺序。这一圈往下走一层,看规则管的是什么:每个核先把写放进自己的存储缓冲,同一个地址靠缓存一致性协议对齐,不同地址之间的先后谁也不保证。用 OpenJDK 的 jcstress 跑了几亿次:库存超卖、两边都读到 0、看到标志却没看到数据,全在本机(ARM)出现了;加对 volatile 或 CAS 以后,一次也没有。最后看缓存行带来的另一个坑:两个线程改的是不同变量,也会互相拖慢。
全景:一个下单请求在线程池里 · 第 4、5 站地图:J02 · Java 内存模型(第 2 个知识点,下一圈)上一圈:Java 内存模型实验:JDK 17.0.20,jcstress 0.16,2 核 Neoverse-N1(ARM) 实测
①要解决的问题:全景第 5 站的超卖,到底是哪一层出的错
在全景第 5 站,这一步要解决的是:内存里一个普通 int 记库存,1000 人抢 100 件卖出了 102 件;有人说“加 volatile 就好了”。真是这样吗?上一圈页面的 ↺ 里还留着几条“预期”,当时没在本机跑过。这一圈把它们和第 5 站的库存扣减一起,交给专门测并发的工具跑几亿次,看哪些结果真的会出现。
| 要回答的问题 | 上一圈怎么说 | 这一圈怎么验证 |
|---|---|---|
| 第 5 站:普通 int 扣库存会超卖吗?加 volatile 呢? | volatile 只管可见,不管读-改-写 | jcstress:StockTests 三种写法 |
| 两个线程都“先写后读”,能都读到 0 吗?只让一个变量 volatile 行不行? | 预期能;只一边 volatile 也挡不住(没在本机跑过) | jcstress:SbPlain / SbOneVolatile / SbBothVolatile |
| 先写数据再写标志,另一边看到标志就一定看到数据吗? | 没有 happens-before 就不保证 | jcstress:MpPlain / MpVolatile,这台 ARM 上能看出 x86 上看不出的东西 |
| 为什么硬件会这样?两个线程改不同变量为什么也会变慢? | 列在“下一圈” | ④ 存储缓冲和缓存一致性;⑥ 伪共享实测 |
②朴素做法:写个 main 跑几千次,没出错就当它对
- 上一圈的
JmmDemo就是这么写的:两个线程跑 100 万次,数一数出现几次奇怪结果。它能“碰”出问题,但说不清没碰到是因为不可能,还是因为没碰巧。 - 线程启动有先后:一个线程起来时,另一个可能已经跑完了,两边根本没“同时”。自己写的循环还容易被 JIT 优化掉,或者被循环里的打印、加锁顺手修好(上一圈坑 6)。
- 结果只有“出错 / 没出错”两种,没有“这个结果按规范允不允许”的判断。
- 缺的那一块:一个专门的测试框架,让多个线程在同一时刻对同一个干净的对象动手,跑几亿次,把每种结果出现了几次列成直方图,再按你事先声明的“允许 / 禁止 / 值得注意”自动判对错。这就是 jcstress。
不懂这一点时的典型误用:在 x86 笔记本上跑了 1000 万次“先写数据再写标志”,从没看到过错,就认定不加 volatile 也行。x86 的硬件恰好不会把两次写的顺序对调;代码上了 ARM 服务器(云上越来越多),本机实测 5 亿次里出现了 36.9 万次(⑤)。
③几种思路:怎么确认一段并发代码是对的
| 办法 | 能回答什么 | 不能回答什么 |
|---|---|---|
| 按 JMM 推 happens-before | 按规范允许哪些结果(上一圈 ④) | 在这台机器上到底会不会出现、多常出现 |
| 自己写 main 循环 | 碰巧能复现一些问题 | 没复现时什么也说明不了 |
| jcstress | 几亿次里每种结果出现几次,自动对照你声明的允许 / 禁止 | 只测你写的那几种交错;测不出不等于规范允许 |
| 形式化验证、模型检查 | 所有可能的执行都覆盖 | 门槛高,业务代码很少用 |
选择规则:先用 JMM 推出“允许什么”,再用 jcstress 看“出现了什么”,两边对上才算懂。JDK 自己的并发类就是这样测的(jcstress 是 OpenJDK 的项目)。本页用 jcstress,同时往下看一层,解释为什么出现的是这些结果。
④关键洞察:同一个地址有人管,不同地址之间的先后没人管
- happens-before、数据竞争:见上一圈洞察 2、3。
- 缓存行:CPU 和内存之间按 64 字节一块搬数据,这一块叫缓存行。读一个 long,它旁边的 56 个字节也一起进了缓存。
- 线程和核:一个线程某一刻在某个核上跑(见线程、线程状态与上下文切换)。
④-1每个核写东西,先写进自己的存储缓冲
x = 1; r1 = y; 这一边,写进了存储缓冲就往下走去读 y,不等写真正生效。在核自己看来,顺序没乱(它读自己的 x 会先查存储缓冲);从另一个核看,好像“读”跑到了“写”前面。- 1为什么要存储缓冲:写一个不在自己缓存里的地址,要先通过一致性协议向别的核“要”这一行的独占权,可能要几十到上百个时钟周期。让核停下来干等太亏,于是先记进存储缓冲,接着执行后面的指令。失效队列同理:别人的“作废”通知先排着,晚一点再处理。
- 2一致性协议只管同一个地址。MESI(修改、独占、共享、无效四种状态)保证一个缓存行同一时刻只有一个核能写,写完以后别的核读到的一定是新值。但 x 和 y 是两个地址,“x 的写先于 y 的写被别人看到”这件事,协议不管。JMM 里的“有序性”,管的就是不同地址之间的先后。
④-2不同 CPU 放宽的程度不一样:x86 只放宽一种,ARM 放宽很多种
| 两条访存指令(不同地址) | x86(TSO) | ARM v8 | Java 普通字段(JMM) | 本页哪个测试 |
|---|---|---|---|---|
| 写 → 读:先写后读,读能跑到前面 | 允许 | 允许 | 允许 | SbPlain |
| 写 → 写:两次写,后一次先被看到 | 不允许 | 允许 | 允许 | MpPlain(写的那一边) |
| 读 → 读:两次读,后一次读到更早的值 | 不允许 | 允许 | 允许 | MpPlain(读的那一边) |
| 读 → 写 | 不允许 | 允许 | 允许 | (没测) |
- 1JMM 对普通字段放得比 ARM 还宽:除了 CPU,JIT 编译器也会重排、合并、提到循环外。所以“我在 x86 上测过没问题”不能证明代码对,只能证明 x86 加上这一版 JIT 恰好没暴露。
- 2volatile 在不同 CPU 上翻译成不同的指令(按 HotSpot 的实现):ARM v8 上 volatile 读用
ldar(读-获取),写用stlr(写-释放),两者之间还保证不重排;x86 上 volatile 读就是普通读,volatile 写后面加一条带lock前缀的指令清空存储缓冲。写 Java 只看 JMM,翻译是 JVM 的事,这正是 JMM 存在的意义。 - 3CAS(
compareAndSet)在 ARM v8.1 以上用一条cas类原子指令(本机 CPU 的atomics标志就是这个),更老的 ARM 用“独占读 + 条件写”循环;x86 用lock cmpxchg。它们都对同一个地址做“比较并写入”,中间插不进别人。
④-3为什么这样设计:硬件放宽,语言给一个统一的约定
JMM:只保证 happens-beforeJava 5 起(JSR-133)
- x86、ARM、POWER、RISC-V 上同一份字节码,只要没有数据竞争,结果都一样
- 没有 volatile、锁的地方,CPU 和 JIT 想怎么优化都行:存储缓冲、乱序执行、寄存器缓存全用上
- 程序员要自己找出“哪里需要先后关系”,漏掉一处就是在某种 CPU 上偶发的 bug
被放弃的:所有访存都按程序顺序顺序一致性(SC)
- 最好懂:所有线程的读写排成一条线,每个线程内部按代码顺序
- 每次写都得等存储缓冲清空、每次读都不能提前:在 ARM 上等于每条访存都加屏障,慢几倍
- JIT 很多优化(把读提到循环外、合并写)都不能做
决定胜负的约束:绝大多数内存访问根本不跨线程,为了少数共享变量让所有访问都变慢,不划算。所以 JMM 让你把共享的那几个地方标出来(volatile、锁、原子类、final),只在那里付屏障的钱。
④-4jcstress 怎么测:一个状态对象、几个演员、一张结果表
@JCStressTest // 这是一个测试
@Outcome(id = {"1, 0", "0, 1"}, expect = ACCEPTABLE, desc = "只有一个人抢到")
@Outcome(id = "1, 1", expect = ACCEPTABLE_INTERESTING, desc = "超卖") // 或 FORBIDDEN
@State // 每一轮都 new 一个新的,字段是共享的数据
public static class Plain {
int left = 1;
@Actor public void exec17(II_Result r) { if (left >= 1) { left = left - 1; r.r1 = 1; } }
@Actor public void exec23(II_Result r) { if (left >= 1) { left = left - 1; r.r2 = 1; } }
} // 两个 @Actor 由两个线程同时执行;@Arbiter 在它们都结束后执行
# 编译时注解处理器生成测试清单;运行
java -cp classes:jcstress-core-0.16.jar org.openjdk.jcstress.Main -m quick -t 'StockTests'
jcstress 会用不同的 JVM 参数(解释执行、C1、C2、不同的分配方式)各跑一批,结果按“所有配置合计”汇总。-m 的 sanity / quick / default / tough 是跑多久,放到第⑥步。
⑤手推一遍:先猜,再看几亿次的结果
9 个测试,每个都先按 ④ 推一推:那个“值得注意”的结果在这台 ARM 上会不会出现?换成 x86 呢?点选项猜,再看本机跑出来的直方图。完整代码:study-labs/java/juc/jmm-jcstress-cache →
- 1库存:volatile 不但没修好,超卖还更多了(14.8% → 33.6%)。比例本身没有意义(取决于两个线程碰得多齐),有意义的是“出现了”。volatile 让每次读都去拿最新值,却挡不住两个线程都在对方写回之前读到 1。CAS 版 2.47 亿次里 0 次超卖。
- 2SB:上一圈的预期对了。普通字段 6.4% 两边都读到 0;只让 x 是 volatile,照样 10.8%;两边都 volatile,2.55 亿次里 0 次。
- 3MP:x86 上看不到的,ARM 上看到了。普通字段 5.3 亿次里 36.9 万次“看到标志、没看到数据”(0.07%)。按 ④-2,x86 不会把两次写或两次读对调,这个结果在 x86 硬件上不会出现(这一条是按规范推的,没在 x86 上跑)。只把
ready改成 volatile,4.1 亿次里 0 次。 - 4volatile 计数:两个线程各
count++一次,3.2 亿次里 78 万次结果是 1(0.24%)。
⑥边界和变体
注意事项:不知道就会出事故
- 现象
- 开发机、测试环境都是 x86,压测几天没问题;迁到 ARM 云主机(Graviton、鲲鹏、Ampere)后偶发数据错乱。⑤ MpPlain:ARM 上 0.07%
- 根源
- x86 只放宽“写 → 读”一种,很多数据竞争在 x86 上碰不出来;JMM 允许的比 ARM 还多。
- 做法
- 先按 happens-before 推出允许什么,再测;有 ARM 机器的话,jcstress 在 ARM 上跑一遍。
- 现象
- 两个线程各自累加自己的计数器,没有任何共享数据,加了线程反而更慢。实测:挤在一行 174~316 ms,隔开 63~144 ms
- 根源
- 一致性协议以 64 字节的缓存行为单位。两个变量在同一行,核 0 写 a 要把整行抢成独占、让核 1 那份作废;核 1 写 b 又抢回去,这一行在两个核之间来回搬。
- 做法
- 热点计数用
LongAdder(它的Cell带@Contended,按核分散);自己的类可以在两个热点字段之间塞 7 个 long,或用@jdk.internal.vm.annotation.Contended(JDK 内部注解,业务代码要加-XX:-RestrictContended和--add-exports才能用)。
- 现象
- 以为一个 volatile 变量能“刷新整个内存”,于是只给其中一个字段加。⑤ SbOneVolatile:10.8%
- 根源
- volatile 只在“写它”和“读到这次写”之间建立先后关系。另一个线程如果根本不读这个 volatile 变量,它自己的普通读写照样可以重排。
- 做法
- 把要配对的读写都画出来:写的一边最后写 volatile,读的一边先读 volatile(MP 的写法);两边都要互相看到对方(SB),两边都得是 volatile 或者用锁。
- 现象
- volatile 计数少了、volatile 库存超卖。⑤ 0.24% 和 33.6%
- 根源
count++是读、加、写三条指令。一致性协议保证每一条都看到最新值,但三条之间能插进别人的写。- 做法
AtomicInteger/LongAdder,或者加锁,或者全景第 8 站的数据库条件更新。
使用规范
声明所有允许和禁止的结果,CI 里至少跑 -m sanity,改动并发代码时跑 quick 以上。
坑 1。
AtomicXxx、LongAdder、ConcurrentHashMap、各种锁,都由 JDK 团队用 jcstress 测过。
自己写错屏障。
读少写多的统计(QPS、调用次数)用它;要精确 CAS 语义(比如库存)用 AtomicInteger。
坑 2:伪共享和 CAS 冲突。
至少把并发相关的单测、压测在 ARM 上跑一遍。
坑 1:x86 掩盖的问题。
深处:JDK 源码里 volatile 和 CAS 怎么配合
一个模式:volatile 读看状态,CAS 改状态
AQS 的 private volatile int state:读状态用普通的 volatile 读,改状态用 compareAndSetState(底下是 U.compareAndSetInt)。ConcurrentHashMap 的桶数组是 transient volatile Node[] table,但数组元素没法声明 volatile,所以读一个桶用 tabAt(getReferenceAcquire,带获取语义的读),往空桶放第一个节点用 casTabAt(CAS),节点里的 val、next 是 volatile。(按本机 JDK 17 源码)
这就是 ⑤ 里 MP 的正经写法:发布方用一次 CAS 或 volatile 写“发布”,读取方用一次 volatile 读“接收”,之前写好的字段就都看得见。
伪共享:本机实测和 LongAdder
实测 两个线程各写自己的 volatile long 2 亿次,跑两次、每次两轮。用 Unsafe 查过字段位置:挤在一起时 a、b 在偏移 16 和 24,隔开后在 16 和 80,正好差 64 字节。每一轮都是挤在一行的更慢,但快多少波动很大(1.3~3 倍),这是普通 main 程序,不是 JMH,只看方向。
LongAdder 把一个计数拆成 base 加若干 Cell,线程冲突时各自累加到不同的 Cell,Cell 类带 @Contended,前后自动留出空隙(JDK 17 Striped64 源码)。
版本差异
| JDK 5 | JSR-133 新内存模型:volatile 有了“写之前的都对读到它的线程可见”的语义,final 字段有了初始化安全 |
|---|---|
| JDK 8 | @sun.misc.Contended;LongAdder、Striped64 出现 |
| JDK 9 | 注解挪到 jdk.internal.vm.annotation.Contended;VarHandle 提供 plain / opaque / acquire-release / volatile 四档访问(上一圈第三圈) |
| jcstress | 0.16 是目前最新版(Maven Central,本页用它);运行需要 JDK 8 以上 |
面试官会怎么问
volatile 的底层原理是什么?
JMM 层面:volatile 写和读到它的 volatile 读之间建立 happens-before,并禁止和前后普通访存的某些重排。硬件层面由 JVM 翻译成屏障或带获取 / 释放语义的指令:ARM 上是 ldar / stlr,x86 上是写后加 lock 前缀指令清空存储缓冲(④-2)。
MESI 都保证了缓存一致,为什么还需要 volatile?
一致性协议只管同一个地址;存储缓冲和失效队列让不同地址之间的先后能乱,JIT 还会把读提到循环外、放进寄存器(④-1、④-2)。
什么是伪共享?怎么解决?
不同线程写的不同变量落在同一条 64 字节缓存行,这一行在核之间来回作废。填充、@Contended、LongAdder 那样按线程分散(坑 2)。
怎么测试一段并发代码是对的?
先按 happens-before 推出允许的结果,再用 jcstress 跑几亿次看实际结果的分布,并在弱内存序的机器(ARM)上也跑(③、坑 1)。
- 用 hsdis 打开
-XX:+PrintAssembly,亲眼看 volatile 在 ARM 上变成 ldar / stlr、在 x86 上变成 lock 指令 - JMH 正式测伪共享和 LongAdder vs AtomicLong 在多核下的差距(2 核看不全)
- IRIW、LB 这些更少见的重排序测试,以及 jcstress 自带的几百个样例
- synchronized 的加锁和锁升级(J02 下一个知识点),它在硬件上也是 CAS 加屏障
↺回到⑤:改造你的测试
1. 给 Plain 库存加锁
新写一个 StockTests.Sync:两个 Actor 都在 synchronized (this) 里扣,把 "1, 1" 声明成 FORBIDDEN,跑 -m quick -t Sync。先猜结果,再看它比 CAS 慢多少(看 jcstress 打印的采样速率)。
用到:坑 4、④-4
2. 写一个“半发布”的测试
仿照 MP:写方 order = new Order(); order.status = PAID;,读方读 order 再读 status。声明“看到 order 却看到旧状态”为 INTERESTING,在 ARM 上跑;再把 order 字段改成 volatile。
用到:④-2、第三圈“volatile 读看状态”
3. 伪共享换成 LongAdder
在 FalseSharing 里加一组:两个线程都往同一个 AtomicLong 加、和往同一个 LongAdder 加,各 2 亿次,比较耗时。
用到:坑 2、规范 3
自测
1. 用一句话说出这一圈的精华。
每个核先把写放进存储缓冲,一致性协议只保证同一个地址对齐,不同地址之间的先后要靠 volatile、锁、CAS 这些 happens-before 来建立;jcstress 能把这些重排序几亿次地跑出来,而且 ARM 比 x86 暴露得多。
2. 自己写 main 循环测并发,疼在哪?
线程不一定真同时、JIT 会改写循环、没碰到不说明不可能,也没有“允许 / 禁止”的判定(②)。
3. ARM 上 MpPlain 出现“看到标志没看到数据”,根源是 ④ 的哪一条?
④-2:ARM 允许两次写(data、ready)以相反顺序被别的核看到,也允许两次读(ready、data)以相反顺序取值;JMM 对普通字段同样允许。ready 改成 volatile 后,“写 data 先于写 ready”“读到 ready 的这次写先于读 data”都被固定住。
4. 预测:把 MpPlain 里的 data 改成 volatile、ready 保持普通,还会看到 (1, 0) 吗?
还会。volatile 写 data 只保证“它之前的写”不往后跑,不管它之后的普通写 ready 会不会提前;读的一边先读普通的 ready,也没有获取语义。要“看到标志就看到数据”,必须是最后写、最先读的那个变量是 volatile(④-2、坑 3)。
5. 预测:把 FalseSharing 的两个线程都绑到同一个核上(taskset -c 0),挤在一行和隔开还差那么多吗?
差距基本消失:同一个核上两个线程轮流跑,缓存行一直在这一个核的 L1 里,没有核之间来回作废(④-1)。总时间变成两个线程串行的时间。
6. 放回全景:第 5 站用 AtomicInteger 修好了单机超卖。部署到两台 ARM 服务器后,CAS 还能挡住超卖吗?“ARM 内存序更弱”会让它更糟吗?
挡不住,但原因不是内存序:两台机器各有一个 AtomicInteger,彼此根本不共享内存,各卖 100 件。ARM 的弱内存序只影响同一个 JVM 里线程之间的可见性,CAS 在 ARM 上照样是原子的(⑤ 0 次)。跨机器只能靠全景第 8 站的数据库条件更新或 Redis 这类共享的地方。