主题 06JMM 与 volatile
JMM(Java 内存模型)是八股里最抽象、也最容易露馅的一章。 「可见性、有序性、原子性」谁都会背,但面试官一句「那你写个可见性给我看」就能分出高下。
这一章的实验都跑在真机上:不加 volatile 的循环,5 次里有 5 次永远退不出来;
而伪共享那一段,仅仅是给两个字段中间塞了 7 个 long,
同一份代码就从 1283ms 变成 345ms。
06.1并发三大特性,JMM 只管后两个
先把三个词切开,因为它们经常被混着答:
| 特性 | 一句话定义 | 谁负责保证 | 破了会怎样 |
|---|---|---|---|
| 原子性 | 一个操作不可被打断 | 锁 / CAS / Atomic* | count++ 丢失更新 |
| 可见性 | 一个线程改了,另一个线程能看见 | volatile / synchronized / final | 死循环、读到旧值 |
| 有序性 | 代码按写的顺序执行 | volatile / synchronized / happens-before | 半初始化对象被发布 |
JMM 只规定后两个。原子性 JMM 不管,它交给硬件和 java.util.concurrent.atomic。
这一点答错会被追问到崩:「volatile 能保证 i++ 安全吗」——不能,因为它只管可见性和有序性。
06.2可见性:5 次全都没退出
实验很朴素:主线程改一个标记,另一个线程 while (!flag) 死等。
唯一变量是 flag 加不加 volatile。跑 5 轮,看有几轮线程退不出来:
=== case1 一个线程改标记,另一个线程看得见吗 ===
无 volatile 5 次里有 5 次【没退出】(自旋最后值 0)
有 volatile 5 次里有 0 次【没退出】(自旋最后值 60379151)
两行数据的差别是质变,不是量变:
- 无 volatile:5 轮全部卡死,自旋计数停在 0——
说明循环根本没有执行过
n++,JIT 把while (!flag)优化成了if (!flag) while (true) {},flag 只读了一次; - 有 volatile:5 轮全部正常退出,自旋了 6037 万次才看到标记变化。
为什么 300 毫秒之后才改标记,线程却看不见?因为 plainFlag 这个普通字段
被 JIT 提升到了寄存器里,线程此后一直在读寄存器,永远看不到主内存里的新值。
而 volatile 会禁止这种提升——每次读都必须真的去内存读。
① 先说现象:不加 volatile 的死循环,5 次全卡住,自旋计数是 0;
② 再说原因:JIT 把字段提到寄存器 + CPU 缓存没刷新,两个层面都有;
③ 最后说代价:加 volatile 之后自旋了 6037 万次才退出—— 说明它是轮询到了新值,而不是被「通知」的。volatile 不解决等待,只解决看见。
06.3有序性:一个诚实的失败实验
经典的重排实验是这样的:两个线程各自「先写自己的变量,再读对方的变量」,跑很多轮,
统计出现了多少次 x=0 且 y=0——如果没重排,这是不可能事件。
=== case2 两个线程各写一个变量、各读一个变量 ===
跑 200000 轮,出现 x=0 且 y=0 共 0 次(0.000%),耗时 2729 ms
代码里 a=1 在 x=b 之前,b=1 在 y=a 之前;
如果没重排,x=0 且 y=0 是不可能事件。
零次。这个结果我原样贴出来了,因为它本身就是这一节最重要的一课。
为什么测不出来?三个原因叠在一起:
| 原因 | 说明 |
|---|---|
| x86 是强内存模型 | x86 只允许「写-读」重排,而且需要写缓冲恰好没刷。ARM 上概率高得多 |
| 同步点抹掉了窗口 | 用 CyclicBarrier 对齐两个线程,barrier 内部的锁本身就插了内存屏障 |
| 窗口只有几个纳秒 | 两条指令之间,20 万轮撞不上是常态 |
那重排到底存不存在?看一段能稳定复现的:DCL 单例(下一节),
以及更直观的——对象发布的半初始化状态。原理是 new 在字节码里是三步:
1. NEW 分配内存,字段全是零值
2. INVOKESPECIAL <init> 执行构造器
3. PUTSTATIC instance = 引用
JIT 允许把 2 和 3 换位置 -> 别的线程可能拿到【构造器还没跑完】的对象
06.4happens-before:八条里只要记四条
happens-before 是 JMM 的唯一判据:如果 A happens-before B,那么 A 的结果对 B 一定可见, 且 A 不会被重排到 B 之后。八条规则里,面试和工作真正用得上的是这四条:
| 规则 | 内容 | 实际用途 |
|---|---|---|
| 程序顺序 | 一个线程内,前面的语句 hb 后面的语句 | 单线程内不用管重排 |
| volatile 规则 | 对 volatile 字段的写 hb 之后对它的读 | 这就是 06.2 那个实验能退出的原因 |
| 锁规则 | 解锁 hb 之后对同一把锁的加锁 | synchronized 同时保证三者 |
| 传递性 | A hb B,B hb C ⇒ A hb C | 把上面几条串起来用 |
另外四条(线程启动 start()、线程终止 join()、中断、对象终结)用得少但也会问。
答题时的技巧:不要一口气背八条,先说「最常用的是四条」,再补一句
「start() 之前的写对子线程可见,join() 之后子线程的写对主线程可见」——这一句往往就够分了。
06.5volatile 的两个语义,和一个不管的事
volatile 只做两件事,第三件它明确不管:
| # | 语义 | 实现手段 | 效果 |
|---|---|---|---|
| 1 | 保证可见性 | 写后立刻刷回主内存,读时强制从主内存读 | 06.2 的实验:5 次卡死 → 0 次 |
| 2 | 禁止重排 | 读写前后插内存屏障(LoadLoad / StoreStore / LoadStore / StoreLoad) | DCL 能成立的前提 |
| 3 | 不保证原子性 | — | i++ 照样丢更新 |
第 3 条在 08 章已经用数据说过一次:多线程 count++ 800000 次只成功 796383 次,
丢了 3617 次(0.45%)。把 count 换成 volatile 也一样丢——
因为 i++ 是「读-改-写」三步,volatile 只保证「读到的不是旧值」,不保证「这三步不被打断」。
要原子,只能换工具:
| 场景 | 用什么 | 为什么 |
|---|---|---|
| 单个变量自增 | AtomicInteger.incrementAndGet() | CAS 一条指令完成读改写 |
| 一个标记位 | volatile boolean | 只写不读改写,够了 |
| 多个变量的复合操作 | synchronized / 锁 | volatile 管不了跨变量的原子性 |
| 先检查再更新 | 锁 / CAS 循环 | if (x == null) x = new ... 不是原子的 |
06.6DCL 为什么必须加 volatile
双重检查锁(DCL)是 volatile 最经典的应用,也是「不加重排会怎样」最好的例子:
static Singleton get() {
if (instance == null) { // 第一次检查:不加锁,为了性能
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查:防止重复创建
instance = new Singleton();
}
}
}
return instance;
}
问题就出在 instance = new Singleton() 这一行。它在字节码里是三步,
而 JIT 允许把 2 和 3 换位置:
线程 A 线程 B
1. 分配内存
3. instance = 引用 (提前了!)
4. if (instance == null) -> false
5. return instance; <-- 拿到半成品
2. 执行构造器
线程 B 拿到的是一个引用不为 null、但字段全是零值的对象。这种 bug 极难复现, 所以面试里不会让你复现,只会问「为什么必须加 volatile」。标准答案三句话:
- 因为
new不是原子的,它是「分配-构造-赋值」三步; - volatile 的禁止重排语义保证「构造完成」一定发生在「赋值」之前;
- 第一次检查在锁外,所以必须靠 volatile 保证线程 B 看到的是一个完整的对象。
private static class Holder { static final Singleton INSTANCE = new Singleton(); }
利用的是类初始化的线程安全(JVM 保证 <clinit> 只执行一次且加锁),
比 DCL 更简洁,这也是《Effective Java》推荐的做法。
06.7伪共享:7 个 long 换来 3.72 倍
这是整章唯一一个「性能量级」的实验。两个线程分别给同一个对象的两个字段自增 5000 万次, 唯一变量是这两个字段之间隔多远:
=== case4 伪共享:两个线程分别改同一个对象的两个字段 ===
a、b 相邻(同一缓存行) : 1283 ms
a、b 间塞 7 个 long(各一行): 345 ms
比值 3.72 倍;单线程基线 693 ms
同一份逻辑,只因为字段挨得近,慢了 3.72 倍。原理:
| 概念 | 说明 |
|---|---|
| 缓存行 | CPU 和内存之间搬数据的最小单位,主流是 64 字节 |
| 伪共享 | 两个线程改不同变量,但这两个变量在同一缓存行里 |
| 后果 | 每次写都要把整行作废,另一个核的缓存失效 → 退化成串行 + 总线风暴 |
| 解法 | 填充(padding)把变量挤到不同行,或用 @Contended(需 -XX:-RestrictContended) |
注意最后那行单线程基线 693ms:加了填充的双线程(345ms)比单线程还快一倍, 说明并行是真的在并行;而不加填充时(1283ms)比单线程还慢一倍, 说明两个核互相拖累——这就是伪共享的成本。
Thread 类里有
@jdk.internal.vm.annotation.Contended 修饰的字段(threadLocalRandomSeed 等),
ConcurrentHashMap 的 CounterCell、
LongAdder 的 Cell 也都做了填充。
LongAdder 比 AtomicLong 快,核心原因之一就是它把热点分散到了多个 Cell 上,
既减少了 CAS 竞争,也减少了伪共享。
06.8速记卡与自查
| # | 卡 | 内容(带数字) |
|---|---|---|
| 1 | 可见性怎么证 | 主线程改标记,另一个线程 while(!flag)。不加 volatile:5 次全部退不出来,自旋计数 0;
加了之后 5 次全退出,自旋 6037 万次才看到。 |
| 2 | volatile 的两件事 | 可见性 + 禁止重排。不保证原子性——i++ 照样丢更新(08 章实测丢了 3617/800000)。 |
| 3 | 伪共享 | 两个线程改同一缓存行里的不同变量,实测 1283ms vs 345ms(3.72 倍)。
解法是填充 7 个 long 或 @Contended。 |
x=0 且 y=0 出现 0 次。
这不是重排不存在,是x86 强内存模型 + 屏障 + 概率三者叠加。
面试里如果有人问「你怎么证明重排」,正确回答是:
「我证明不了,所以我按 happens-before 写」——这个答案比背八个场景强。
动手清单与自查表
| # | 动手项 | 可观察的判据 |
|---|---|---|
| 1 | 跑 JmmDemo visible |
「无 volatile」那行显示 5 次没退出,自旋值 0 |
| 2 | 把 plainFlag 加 volatile 再跑 |
0 次卡住,自旋值变成千万级 |
| 3 | 跑 JmmDemo false,再把 Padded 里的 p1~p7 删掉 |
耗时从 345ms 涨回 1200ms 以上 |
| 4 | 给 DCL 的 instance 加/去 volatile,用 javap -c 看字节码 |
字节码顺序不变——重排是 JIT 干的,javap 看不到,所以才难查 |
| 5 | 用 4 个线程各改 Tight 的一个字段 | 耗时继续变差,因为争用同一缓存行的核更多 |
while (!plainFlag)
改成 while (!plainFlag) { Thread.sleep(1); },你会发现它能正常退出。
因为 sleep 是 native 方法,每次返回都会重新读内存。
「加了一行无关代码就好了」是并发 bug 最典型的伪装——它没有修好,只是把窗口挪了。
| 卡住了怎么办 | 去哪查 |
|---|---|
| 不确定某个写法要不要 volatile | 问自己:另一个线程必须看到这次写吗?必须就加 |
| 不确定是不是原子问题 | 把操作拆成「读-改-写」,有跨步骤依赖就不是原子的 |
| 怀疑伪共享 | 用 perf c2c(Linux)看 HITM 计数,或直接 padding 前后对比 |
| 想验证 happens-before | 写一个只有 volatile 变量通信的最小用例,跑循环看会不会读到旧值 |