Java 后端八股精讲一问一答,背得下也答得出 速查 追问链路 ⌂ 全部教程

主题 06JMM 与 volatile

JMM(Java 内存模型)是八股里最抽象、也最容易露馅的一章。 「可见性、有序性、原子性」谁都会背,但面试官一句「那你写个可见性给我看」就能分出高下。

这一章的实验都跑在真机上:不加 volatile 的循环,5 次里有 5 次永远退不出来; 而伪共享那一段,仅仅是给两个字段中间塞了 7 个 long, 同一份代码就从 1283ms 变成 345ms。

学习目标
能现场写出可见性实验,并解释「为什么重排测不出来」
前置要求
读过第 08 章(线程池与并发)最好;知道 happens-before 这个词
本章产出
4 组实测 + 3 张速记卡 + 1 个「测不出来」的负结果
建议学时
2 小时(可见性实验务必自己跑)

06.1并发三大特性,JMM 只管后两个

先把三个词切开,因为它们经常被混着答:

特性一句话定义谁负责保证破了会怎样
原子性一个操作不可被打断锁 / CAS / Atomic*count++ 丢失更新
可见性一个线程改了,另一个线程能看见volatile / synchronized / final死循环、读到旧值
有序性代码按写的顺序执行volatile / synchronized / happens-before半初始化对象被发布

JMM 只规定后两个。原子性 JMM 不管,它交给硬件和 java.util.concurrent.atomic。 这一点答错会被追问到崩:「volatile 能保证 i++ 安全吗」——不能,因为它只管可见性和有序性。

JMM 到底规定了什么:它定义了一个线程的写,在什么条件下对另一个线程可见。 具体手段是「主内存 + 工作内存」的抽象(每个线程有自己的副本),加上一套 happens-before 规则来告诉你什么时候必须把副本同步回主内存。 注意:这套「工作内存」是逻辑模型,不是真的有一块线程私有的内存—— CPU 缓存、写缓冲、编译器优化都被它抽象成了这一个概念。

06.2可见性:5 次全都没退出

实验很朴素:主线程改一个标记,另一个线程 while (!flag) 死等。 唯一变量是 flag 加不加 volatile。跑 5 轮,看有几轮线程退不出来:

=== case1 一个线程改标记,另一个线程看得见吗 ===
  无 volatile 5 次里有 5 次【没退出】(自旋最后值 0)
  有 volatile 5 次里有 0 次【没退出】(自旋最后值 60379151)

两行数据的差别是质变,不是量变:

为什么 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 万轮撞不上是常态
这恰恰是并发 bug 最可怕的地方:它不能靠测试发现。 你在开发机上跑 20 万轮是 0 次,线上跑到第 800 万次时崩一次,日志里什么都没有。 所以这类问题只有两条路:要么按 happens-before 规则写对,要么用现成的并发容器。 「我测过了没问题」在并发领域不是证据。

那重排到底存不存在?看一段能稳定复现的: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() 之后子线程的写对主线程可见」——这一句往往就够分了。

一个反直觉的推论:happens-before 不代表时间上先发生。 它只是「结果必须可见」的逻辑约束。两条语句完全可以并行执行, 只要保证后一条看到的是前一条的结果。所以「hb」应该读作「对 B 可见」,不是「先于 B 执行」。

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」。标准答案三句话:

更好的答案(能拉开差距):「其实静态内部类和枚举两种写法 根本不需要 DCL」—— 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)比单线程还慢一倍, 说明两个核互相拖累——这就是伪共享的成本。

JDK 自己就在用这个技巧: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 万次才看到。
2volatile 的两件事 可见性 + 禁止重排。不保证原子性——i++ 照样丢更新(08 章实测丢了 3617/800000)。
3伪共享 两个线程改同一缓存行里的不同变量,实测 1283ms vs 345ms(3.72 倍)。 解法是填充 7 个 long 或 @Contended。
留一个「负结果」当武器:重排实验跑 20 万轮,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 的一个字段 耗时继续变差,因为争用同一缓存行的核更多
反例实验(做了会记一辈子):把 06.2 的循环体从 while (!plainFlag) 改成 while (!plainFlag) { Thread.sleep(1); },你会发现它能正常退出。 因为 sleep 是 native 方法,每次返回都会重新读内存。 「加了一行无关代码就好了」是并发 bug 最典型的伪装——它没有修好,只是把窗口挪了。
卡住了怎么办去哪查
不确定某个写法要不要 volatile问自己:另一个线程必须看到这次写吗?必须就加
不确定是不是原子问题把操作拆成「读-改-写」,有跨步骤依赖就不是原子的
怀疑伪共享用 perf c2c(Linux)看 HITM 计数,或直接 padding 前后对比
想验证 happens-before写一个只有 volatile 变量通信的最小用例,跑循环看会不会读到旧值