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 上看不出的东西
为什么硬件会这样?两个线程改不同变量为什么也会变慢?列在“下一圈”④ 存储缓冲和缓存一致性;⑥ 伪共享实测
需求地图上这一项的深度是“懂设计”:不光会用 volatile 和锁,还要说得清 JMM 为什么要这样规定,它和 CPU 的缓存、存储缓冲是什么关系,以及为什么同一段代码在 x86 笔记本上测不出问题、上了 ARM 服务器才出事。

②朴素做法:写个 main 跑几千次,没出错就当它对

不懂这一点时的典型误用:在 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每个核写东西,先写进自己的存储缓冲

核 0:线程 a(exec-17) 存储缓冲x = 1(还没出去) 失效队列别人发来的“作废”通知 L1 缓存(按 64 字节一行)y = 0 核 1:线程 b(exec-23) 存储缓冲y = 1(还没出去) 失效队列别人发来的“作废”通知 L1 缓存x = 0 缓存一致性协议(MESI 一类)+ 共享的 L2 / L3 / 内存 保证同一个地址:任一时刻只有一个核能写它,写完以后别的核读到的一定是新值 a 读 y:0 b 读 x:0 两边的写都还在 各自的存储缓冲里
这就是 SB(store buffering)测试里“两边都读到 0”的来历:x = 1; r1 = y; 这一边,写进了存储缓冲就往下走去读 y,不等写真正生效。在核自己看来,顺序没乱(它读自己的 x 会先查存储缓冲);从另一个核看,好像“读”跑到了“写”前面。

④-2不同 CPU 放宽的程度不一样:x86 只放宽一种,ARM 放宽很多种

两条访存指令(不同地址)x86(TSO)ARM v8Java 普通字段(JMM)本页哪个测试
写 → 读:先写后读,读能跑到前面允许允许允许SbPlain
写 → 写:两次写,后一次先被看到不允许允许允许MpPlain(写的那一边)
读 → 读:两次读,后一次读到更早的值不允许允许允许MpPlain(读的那一边)
读 → 写不允许允许允许(没测)

④-3为什么这样设计:硬件放宽,语言给一个统一的约定

JMM:只保证 happens-beforeJava 5 起(JSR-133)

  • x86、ARM、POWER、RISC-V 上同一份字节码,只要没有数据竞争,结果都一样
  • 没有 volatile、锁的地方,CPU 和 JIT 想怎么优化都行:存储缓冲、乱序执行、寄存器缓存全用上
  • 程序员要自己找出“哪里需要先后关系”,漏掉一处就是在某种 CPU 上偶发的 bug

被放弃的:所有访存都按程序顺序顺序一致性(SC)

  • 最好懂:所有线程的读写排成一条线,每个线程内部按代码顺序
  • 每次写都得等存储缓冲清空、每次读都不能提前:在 ARM 上等于每条访存都加屏障,慢几倍
  • JIT 很多优化(把读提到循环外、合并写)都不能做

决定胜负的约束:绝大多数内存访问根本不跨线程,为了少数共享变量让所有访问都变慢,不划算。所以 JMM 让你把共享的那几个地方标出来(volatile、锁、原子类、final),只在那里付屏障的钱。

④-4jcstress 怎么测:一个状态对象、几个演员、一张结果表

API最少要会的写法
@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 →

测StockTests.Plain

本机 ARM 上
换成 x86
到这里已经能交差了:你能写一个 jcstress 测试,声明哪些结果允许、哪些禁止,读懂结果表;能用“存储缓冲 + 只管同一地址的一致性协议”解释 SB 和 MP 为什么会出现,为什么 x86 上测不出 MP。
已知还没处理的细节:测不出不等于对(⑥ 坑 1)、伪共享(⑥ 坑 2)、只给一个变量加 volatile(⑥ 坑 3)、JDK 源码里怎么配合 volatile 和 CAS(⑥ 第三圈)。

⑥边界和变体

第一圈

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

坑 1测不出来,不等于是对的← ④-2 各 CPU 放宽程度不同
现象
开发机、测试环境都是 x86,压测几天没问题;迁到 ARM 云主机(Graviton、鲲鹏、Ampere)后偶发数据错乱。⑤ MpPlain:ARM 上 0.07%
根源
x86 只放宽“写 → 读”一种,很多数据竞争在 x86 上碰不出来;JMM 允许的比 ARM 还多。
做法
先按 happens-before 推出允许什么,再测;有 ARM 机器的话,jcstress 在 ARM 上跑一遍。
坑 2伪共享:改的是不同变量,却互相拖慢← ④-1 一致性按缓存行算
现象
两个线程各自累加自己的计数器,没有任何共享数据,加了线程反而更慢。实测:挤在一行 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 才能用)。
坑 3只给“一半”加 volatile← ④-2 volatile 只约束它自己前后
现象
以为一个 volatile 变量能“刷新整个内存”,于是只给其中一个字段加。⑤ SbOneVolatile:10.8%
根源
volatile 只在“写它”和“读到这次写”之间建立先后关系。另一个线程如果根本不读这个 volatile 变量,它自己的普通读写照样可以重排。
做法
把要配对的读写都画出来:写的一边最后写 volatile,读的一边先读 volatile(MP 的写法);两边都要互相看到对方(SB),两边都得是 volatile 或者用锁。
坑 4把 volatile 当原子操作← ④-1 同一地址也要“读-改-写”一步完成
现象
volatile 计数少了、volatile 库存超卖。⑤ 0.24% 和 33.6%
根源
count++ 是读、加、写三条指令。一致性协议保证每一条都看到最新值,但三条之间能插进别人的写。
做法
AtomicInteger / LongAdder,或者加锁,或者全景第 8 站的数据库条件更新。
第二圈

使用规范

自己写的无锁代码,配一个 jcstress 测试

声明所有允许和禁止的结果,CI 里至少跑 -m sanity,改动并发代码时跑 quick 以上。

坑 1。

优先用 JDK 现成的并发类

AtomicXxx、LongAdder、ConcurrentHashMap、各种锁,都由 JDK 团队用 jcstress 测过。

自己写错屏障。

多线程高频写的计数器用 LongAdder

读少写多的统计(QPS、调用次数)用它;要精确 CAS 语义(比如库存)用 AtomicInteger。

坑 2:伪共享和 CAS 冲突。

生产机器是 ARM,测试也要有 ARM

至少把并发相关的单测、压测在 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

a、b 挤在一行174~316 ms
中间塞 7 个 long63~144 ms

实测 两个线程各写自己的 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 5JSR-133 新内存模型:volatile 有了“写之前的都对读到它的线程可见”的语义,final 字段有了初始化安全
JDK 8@sun.misc.Contended;LongAdder、Striped64 出现
JDK 9注解挪到 jdk.internal.vm.annotation.Contended;VarHandle 提供 plain / opaque / acquire-release / volatile 四档访问(上一圈第三圈)
jcstress0.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)。

下一圈

↺回到⑤:改造你的测试

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 这类共享的地方。