1. 项目背景
业务场景:某社交平台的"在线状态"模块有一个简单的设计------用 boolean isOnline = false; 标志位标记用户登录状态。用户登录后主线程设 isOnline = true,后台心跳线程循环检查这个标志位。奇怪的是,在某些高负载的机器上,心跳线程"看不到" isOnline 被设为 true------即使登录成功了,用户状态一直显示"离线"。开发反复检查代码逻辑,确认没有 bug,怀疑是 JVM 的 bug。
痛点:
- "可见性"盲区:Java 内存模型(JMM)规定,不同线程之间对共享变量的修改不保证立即可见------一个线程写入了值,另一个线程可能永远读不到。这不是 bug,而是 JMM 允许编译器、JIT 和 CPU 为了性能做的优化(寄存器缓存、CPU Store Buffer、指令重排等)。
- 指令重排的反直觉 :开发写了
a = 1; b = 2;,CPU 可能实际执行的顺序是b = 2; a = 1;。在单线程下毫无影响,但在多线程下,另一个线程可能看到b = 2时a还是旧值------这就是"有序性"被破坏的经典案例。 volatile的误用 :很多人知道volatile能解决"可见性",但对它的第二个作用------"禁止指令重排"------知之甚少。以为给所有字段都加volatile就万事大吉,结果性能一塌糊涂。
本章聚焦 JMM 的三个核心性质------可见性、有序性、原子性------通过编写刻意暴露问题的代码,让你亲眼看到"无同步下的写丢失""指令重排导致的怪异行为",再用 volatile、锁和 AtomicInteger 逐个修复。
2. 项目设计
(小胖瞪着自己的代码------isOnline = true 明明在第一行就执行了,心跳线程却永远读不到。)
小胖 :大师,这不可能啊!我 isOnline = true 就写在心跳线程启动之前,怎么可能它读不到?这不科学!难道 Java 还能把我的赋值语句给"吞"了?
大师(淡定地喝了口咖啡):Java 没有吞你的赋值语句------是你的心跳线程的 CPU 核心在"吞"。来,我给你画一张图,看完你就懂了。
ini
┌──────────────────────┐ ┌──────────────────────┐
│ CPU Core 1 │ │ CPU Core 2 │
│ ┌──────────────┐ │ │ ┌──────────────┐ │
│ │ 寄存器 │ │ │ │ 寄存器 │ │
│ │ isOnline=true│ │ │ │ isOnline=??? │ │
│ └──────┬───────┘ │ │ └──────┬───────┘ │
│ │ │ │ │ │
│ ┌──────▼───────┐ │ │ ┌──────▼───────┐ │
│ │ L1/L2 Cache │ │ │ │ L1/L2 Cache │ │
│ │ isOnline=true│ │ │ │ isOnline=? │ ← 可能还是旧值
│ └──────┬───────┘ │ │ └──────┬───────┘ │
│ │ │ │ │ │
└─────────┼────────────┘ └─────────┼────────────┘
│ │
┌────▼───────────────────────────────▼───┐
│ 共享内存 (Main Memory) │
│ isOnline = ???? │
└────────────────────────────────────────┘
现代 CPU 为了性能,每个核心有自己的 L1/L2 缓存和 Store Buffer。Core 1 写入 isOnline = true 后,这个值可能暂时待在 Core 1 的 L1 缓存里,没有立刻同步到主内存。与此同时,Core 2 上的心跳线程读取的是自己 L1 缓存里的旧值------于是它"看不到"更新。
技术映射:CPU 核心的私有缓存 ↔ 每个员工手里的小本本(写了他们各自认为的"库存数量"),主内存 ↔ 仓库门口的大公告牌。小本本上的数字改了,但没更新公告牌------另一个员工路过公告牌看到的还是旧数字。
小白 :那 volatile 就是强制"更新公告牌"的手段咯?
大师 :没错,但 volatile 做的远不止"更新公告牌"。它实际上做了两件事:
-
保证可见性 :对
volatile变量的写操作会立即刷新到主内存,读操作总是从主内存读。在 x86 架构上,这对应一条lock前缀的汇编指令------它会刷写 Store Buffer(写屏障)并使其他核心的 L1 缓存失效(读屏障)。 -
禁止指令重排 :
volatile变量的读写前后会插入"内存屏障"(Memory Barrier),阻止编译器和 CPU 把volatile写之前的指令重排到之后,以及把volatile读之后的指令重排到之前。
java
// 经典的双重检查锁定 (DCL) 单例
public class Singleton {
private static volatile Singleton instance; // ← 必须有 volatile
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton(); // ← 这行代码分三步执行
// 1. 分配内存
// 2. 初始化对象 (调用构造函数)
// 3. 将引用赋值给 instance
}
}
}
return instance;
}
}
如果没有 volatile,步骤 2 和步骤 3 可能被重排------另一个线程在步骤 3 之后、步骤 2 之前读到 instance != null,拿到的是一个半初始化的对象。这个 bug 极其隐蔽,只在特定 CPU 架构和运行条件下出现,可以在线上潜伏几个月。
技术映射 :volatile ↔ 公告牌+警卫。更新公告牌时,警卫确保之前所有相关的修改先上牌;读取公告牌时,警卫确保之后的操作看到的是最新内容。
小胖 :那 volatile 能解决所有并发问题吗?我是不是可以把 synchronized 全部换成 volatile,性能还好?
大师 (果断地):不能。volatile 只解决可见性和有序性,不能解决原子性。
看这个例子:
java
volatile int count = 0;
// 线程 A 线程 B
count++; // 三步骤 count++; // 三步骤
count++ 虽然只有一行代码,但在字节码层面是三条指令:getfield(读)→ iadd(加)→ putfield(写)。如果线程 A 读完(值=0),在还没来得及写回(值=1)时,线程 B 也读了(值=0),然后两个线程各写回 1------两个 ++ 操作,结果只加了 1。这就是"原子性"被破坏。
volatile 保证不了"读-改-写"的原子性。要解决原子性,有三个选择:
synchronized:重量级,但保证互斥访问。AtomicInteger(CAS):轻量级,适合简单计数。LongAdder:适合极高并发计数。
技术映射 :volatile ↔ 公告牌保证你看到的是最新库存,但如果两个人同时"看到 10 个 → 各拿 1 个 → 都更新为 9",实际库存该是 8 而不是 9。要解决这个问题,你需要在拿货时加一把锁,或者用"原子操作"的自动售货机。
小白 :那 JMM 中的 happens-before 规则又是什么?它跟 volatile 有什么关系?
大师 (在白板上写下几条核心规则):happens-before 是 JMM 对 Java 程序员提供的"秩序保证"------只要你的代码满足某条 happens-before 规则,JVM 就保证前一个操作的结果对后一个操作可见。核心规则包括:
- 程序顺序规则:同一个线程中,前面的操作 happens-before 后面的操作(但仅限单线程!)
- volatile 变量规则 :对一个
volatile变量的写 happens-before 后续对这个变量的读 - 锁规则 :一个锁的
unlockhappens-before 后续的lock - 传递性:如果 A hb B,B hb C,则 A hb C
- 线程 start/join 规则 :
thread.start()hb 线程内的任何操作;线程内的任何操作 hbthread.join()返回
happens-before 是你写并发代码的"安全网"------只要你能证明代码满足其中一条规则,JVM 就保证不乱序。
技术映射 :happens-before ↔ 火车的轨道调度系统。如果没有调度,两列火车可能相撞(并发 bug)。有了调度,你只需要按照调度逻辑开车------调度系统保障了次序。
3. 项目实战
3.1 环境准备
| 组件 | 版本 | 用途 |
|---|---|---|
| JDK | OpenJDK 21 | 运行示例 |
| JMH | 可选 | 微基准测试 |
| hsdis | 可选(JDK 自带) | 反汇编查看内存屏障指令 |
3.2 分步实现
步骤一:复现可见性失败
目标:证明没有 volatile 时,一个线程的写入对另一个线程可能永远不可见。
java
// VisibilityDemo.java ------ 可见性失败复现
public class VisibilityDemo {
// 注意:没有 volatile 修饰
private static boolean flag = false;
private static int value = 0;
public static void main(String[] args) throws InterruptedException {
System.out.println("开始演示可见性问题(可能需要几秒到几分钟)...");
// 写线程:修改 flag 和 value
Thread writer = new Thread(() -> {
value = 42; // 步骤 1
flag = true; // 步骤 2 (没有 volatile,CPU 可能重排 1↔2)
}, "Writer");
// 读线程:不停检查 flag,期望读到 value=42
Thread reader = new Thread(() -> {
int iterations = 0;
while (!flag) { // ← 没有 volatile,可能永远读不到 true
iterations++;
// 注意:此处不能有 println 或 sleep(它们隐含 synchronized,会刷缓存)
}
System.out.println("Reader 在 " + iterations
+ " 次循环后读到: flag=" + flag + ", value=" + value);
// 如果 value != 42,说明发生了指令重排或可见性问题
if (value != 42) {
System.out.println("ERROR: value=" + value + " 但预期=42 (重排或可见性失败)");
}
}, "Reader");
reader.start();
Thread.sleep(100); // 确保 reader 先进入循环
writer.start();
reader.join(5000); // 最多等 5 秒
if (reader.isAlive()) {
System.out.println("Reader 在 5 秒内没读到 flag=true ------ 可见性问题确认!");
reader.interrupt();
}
}
}
运行结果 :Reader 可能在 5 秒超时后仍未读到 flag=true。
步骤二:用 volatile 修复可见性
目标:给 flag 加 volatile,验证写入立即对读线程可见。
java
// VisibilityFixed.java ------ volatile 修复可见性
public class VisibilityFixed {
private static volatile boolean flag = false; // ← 加 volatile
private static int value = 0;
public static void main(String[] args) throws InterruptedException {
System.out.println("使用 volatile,写入应立即可见...");
Thread reader = new Thread(() -> {
int iterations = 0;
while (!flag) { iterations++; }
System.out.println("Reader 在 " + iterations + " 次后读到 flag=true");
System.out.println("value=" + value + " (期望=42)"
+ (value == 42 ? " OK" : " FAIL"));
}, "Reader");
Thread writer = new Thread(() -> {
value = 42; // volatile 写之前的普通写也对 reader 可见
flag = true; // volatile 写 → 触发 happens-before
}, "Writer");
reader.start();
Thread.sleep(100);
writer.start();
reader.join(1000);
System.out.println("Reader 是否结束: " + !reader.isAlive());
}
}
关键点 :volatile 写 flag=true 不仅保证 flag 的可见性,还保证 value=42(在 volatile 写之前的所有操作)也对读线程可见------这是 happens-before 的"传递性" + "volatile 变量规则"的联合效果。
步骤三:展示原子性缺失------volatile 的 count++ 陷阱
目标:证明 volatile ++ 不是原子操作。
java
// AtomicityDemo.java ------ volatile 无法保证原子性
import java.util.concurrent.CountDownLatch;
public class AtomicityDemo {
private static volatile int count = 0; // volatile, 但不保证原子性
private static int countSync = 0; // synchronized 保护
private static java.util.concurrent.atomic.AtomicInteger countAtomic
= new java.util.concurrent.atomic.AtomicInteger(0); // AtomicInteger
private static final int THREADS = 10;
private static final int ITERATIONS = 10000;
public static void main(String[] args) throws InterruptedException {
// 方案 A: volatile ++ (预期结果 < THREADS * ITERATIONS)
test("volatile only", () -> count++);
System.out.println("volatile 最终值: " + count
+ " (预期 " + THREADS * ITERATIONS + ", 丢失 " + (THREADS * ITERATIONS - count) + ")");
// 方案 B: synchronized ++ (预期结果 = THREADS * ITERATIONS)
test("synchronized", () -> { synchronized (AtomicityDemo.class) { countSync++; } });
System.out.println("synchronized 最终值: " + countSync
+ " (预期 " + THREADS * ITERATIONS + ")");
// 方案 C: AtomicInteger (预期结果 = THREADS * ITERATIONS)
test("AtomicInteger", () -> countAtomic.incrementAndGet());
System.out.println("AtomicInteger 最终值: " + countAtomic.get()
+ " (预期 " + THREADS * ITERATIONS + ")");
}
static void test(String name, Runnable task) throws InterruptedException {
CountDownLatch latch = new CountDownLatch(THREADS);
for (int i = 0; i < THREADS; i++) {
new Thread(() -> {
for (int j = 0; j < ITERATIONS; j++) {
task.run();
}
latch.countDown();
}).start();
}
latch.await();
System.out.print(name + " -> ");
}
}
预期输出:
arduino
volatile only -> volatile 最终值: 87345 (预期 100000, 丢失 12655)
synchronized -> synchronized 最终值: 100000 (预期 100000)
AtomicInteger -> AtomicInteger 最终值: 100000 (预期 100000)
步骤四:展示 volatile 的内存屏障------反汇编验证
bash
# 用 hsdis 反汇编查看 volatile 变量的读写指令
java -XX:+UnlockDiagnosticVMOptions \
-XX:+PrintAssembly \
-XX:CompileCommand=compileonly,*VisibilityFixed.* \
VisibilityFixed 2>&1 | grep -A 5 "lock"
如果能看到 lock addl 或 lock cmpxchg 等带 lock 前缀的指令,这些就是 volatile 的写屏障。
可能遇到的坑:
- 可见性实验"总是成功" :如果在
while (!flag)循环中调用了System.out.println()或Thread.sleep(),这些方法内部有synchronized块------它会隐式地刷新 CPU 缓存,导致"自然可见"。所以可见性实验的循环体中必须不能有任何同步操作。 - JIT 的死循环优化 :如果 JIT 发现
while (!flag)中的flag不是volatile,它可能直接把flag的值缓存在寄存器里,并优化成一个"无限循环while(true)"------这确实会发生。这就是为什么有时候需要在flag上额外做一个"空壳"操作来抑制优化。 javac编译优化 :如果flag是false且从未修改的字面量常量,编译器可能直接优化掉整个while循环------确保flag在运行时可能被修改。- 不同 CPU 架构差异:x86 是"强内存模型"(TSO),很多重排在 x86 上不会发生,但 ARM 和 RISC-V 是"弱内存模型"------在 x86 上能跑通的并发代码,在 ARM Mac 上可能立刻暴露 bug。跨平台验证很重要。
3.3 测试验证
| 验证点 | 方法 | 预期結果 |
|---|---|---|
| 可见性失败 | 运行 VisibilityDemo | Reader 5 秒内读不到 flag=true |
| volatile 修复 | 运行 VisibilityFixed | Reader 立即可见,value=42 |
| 原子性失败 | 运行 AtomicityDemo | volatile count 远小于 100000 |
| happens-before | 线程 start/join 验证 | join 后的线程操作必然可见 |
| 指令重排效果 | 多次运行无 volatile 的 VisibilityDemo | value 可能不是 42 |
bash
#!/bin/bash
echo "=== 1. 可见性失败演示 ==="
java VisibilityDemo
echo ""
echo "=== 2. volatile 修复验证 ==="
java VisibilityFixed
echo ""
echo "=== 3. 原子性验证 ==="
java AtomicityDemo
echo ""
echo "=== 4. volatile 传递性验证 (happens-before) ==="
# 编译并运行额外的验证:volatile 写之前的非 volatile 写是否可见
java -cp . VisibilityFixed # value=42 在 volatile 写 flag=true 之前 → 应对 reader 可见
4. 项目总结
4.1 优点与缺点
| 维度 | 优点 | 缺点 |
|---|---|---|
| volatile | 轻量级(读操作等同于普通读),保证可见性+有序性 | 不保证原子性------"读-改-写"需要额外同步 |
| happens-before | 提供清晰的并发正确性判断框架 | 规则较多(8 条核心规则),需要刻意记忆 |
| JMM 设计 | 平衡了性能与安全性------不强求"顺序一致性"的所有开销 | 理解门槛高------需要同时理解编译器优化 + CPU 缓存 + 指令重排 |
| Atomic* 类 | 无锁 CAS 操作,极高并发下吞吐远超锁 | CAS 失败时自旋消耗 CPU;ABA 问题需要额外关注 |
| 强/弱内存模型 | x86 的 TSO 让很多并发 bug 被"隐藏"------部署到 ARM 前可幸免 | 在 x86 上测试通过的代码不等于正确------必须有意识地验证可见性和有序性 |
| 对比技术 | volatile | synchronized | AtomicInteger (CAS) |
|---|---|---|---|
| 互斥性 | 不支持 | 支持 | 不支持(仅单变量原子操作) |
| 性能 | 读 ≈ 普通读,写略慢 | 竞争时最慢 | 无竞争时极快,高竞争时自旋浪费 |
| 适用操作 | 单变量读写 | 任意代码块 | 单变量的增减/CAS |
| 内存屏障 | 读写各一屏障 | 进出各一屏障 | 一次 CAS = 读+写屏障 |
4.2 适用场景
- 状态标志位 :如
boolean shutdown、boolean initialized------一个线程写、多个线程读,用volatile完美解决。 - DCL 单例 :双重检查锁定中的
instance必须volatile,防止半初始化对象泄漏。 - 无锁计数器 :
AtomicLong、LongAdder在高并发计数场景下秒杀任何锁方案。 - 轻量级"读-写"锁 :用
volatile修饰状态变量,配合 CAS 实现简易读写分离。 - 并发框架基础 :理解 AQS、
ConcurrentHashMap等高级并发工具必须先理解 JMM。
不适用场景:
- "读-改-写"的复合操作------volatile 不能保证原子性,应选用
Atomic*或锁。 - 多个变量需要保持一致性------比如"A 和 B 必须同时更新",volatile 无法保证两个变量更新的原子性。
- 单线程场景------没有可见性问题,不加任何修饰即可。
4.3 注意事项
| 类型 | 详细说明 |
|---|---|
| volatile 数组 | volatile int[] arr 只保证数组引用本身的可见性,不保证数组元素 arr[i] 的可见性------用 AtomicIntegerArray 替代 |
| 64 位变量 | long 和 double 的 64 位操作在 JMM 下被分为两次 32 位读写------非 volatile 的 long 可能读到半个旧值半个新值 |
| final 域安全发布 | 构造函数中对 final 字段的写入 happens-before 构造函数返回------这是 JMM 提供的"对象安全发布"保障,前提是不能让 this 在构造过程中逃逸 |
| VarHandle | JDK 9 引入的 VarHandle 提供了比 volatile 更细粒度的内存顺序控制------支持 getAcquire/setRelease/getOpaque/setOpaque 四种模式 |
4.4 常见踩坑经验
案例 1:双重检查锁定的"半初始化"炸弹
某核心服务的配置管理器用 DCL 单例缓存配置。JDK 7 下运行了 3 年从未出错。迁移到 JDK 11 + ARM 服务器后,偶发性地读出 config.get("key") 返回 null(配置文件确定有这个 key)。根因 :单例的 instance 字段没加 volatile,ARM 的弱内存模型下指令重排导致"分配内存 → 引用赋值 → 初始化"的顺序被执行------别的工作线程看到了非空但未初始化的 instance。修复 :加 volatile ------ private static volatile Config instance;。
案例 2:volatile 的传递性误解
某交易系统用 volatile boolean orderSubmitted = false; 标志订单提交状态。提交线程顺序写 order.setAmount(100); → orderSubmitted = true;。处理线程读到 orderSubmitted == true 后立刻读 order.getAmount(),期望拿到 100。结果在 ARM Mac 上测试时偶尔拿到 0。根因 :开发误以为 volatile 只有"立即可见",不了解它的"传递序"------orderSubmitted = true(volatile 写)确实让后续 volatile 读可见,但"后续 volatile 读"之前发生了什么,volatile 不保证!正确顺序是:orderSubmitted volatile 写在 order.setAmount 之前。
案例 3:System.exit(0) 触发 DCL 失败
垃圾回收线程在 JVM 关闭前的最后一瞬间,触发了 finalize(),调用了单例的 getInstance()------此时构造函数刚好执行到一半(还没退出 synchronized 块),得到半初始化对象。根因 :System.exit() 的特殊性与 DCL 交互的极端边缘条件。修复:使用"基于类的初始化"(Class Initialization Lock)替代 DCL:
java
private static class Holder {
static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() { return Holder.INSTANCE; }
4.5 思考题
-
进阶题 :
volatile修饰的long变量,在多线程中执行i++是否线程安全?如果不安全,请分析字节码层面涉及了多少条指令,并解释AtomicLong.incrementAndGet()的内部实现原理(提示:查看Unsafe.compareAndSwapLong的 native 实现)。 -
实战题:你的系统从 x86 迁移到 ARM 服务器(如 AWS Graviton)后,之前跑了 2 年的"无 bug"并发代码突然出现了间歇性的数据不一致。怀疑是指令重排导致。请设计一个最小可复现方案(不依赖任何外部工具),并给出修复建议。
答案提示:思考题 1 答案见本章步骤三 + 第 17 章 AQS/CAS 部分;思考题 2 答案见第 23 章逃逸分析与锁优化。
下一章预告:第 9 章将进入 GC 的世界------堆分代模型、Serial/Parallel 垃圾收集器,以及如何用 Unified Logging 绘制"分配速率-停顿"曲线。