第8章:Java 内存模型(JMM)入门——可见性、有序性、原子性

1. 项目背景

业务场景:某社交平台的"在线状态"模块有一个简单的设计------用 boolean isOnline = false; 标志位标记用户登录状态。用户登录后主线程设 isOnline = true,后台心跳线程循环检查这个标志位。奇怪的是,在某些高负载的机器上,心跳线程"看不到" isOnline 被设为 true------即使登录成功了,用户状态一直显示"离线"。开发反复检查代码逻辑,确认没有 bug,怀疑是 JVM 的 bug。

痛点:

  1. "可见性"盲区:Java 内存模型(JMM)规定,不同线程之间对共享变量的修改不保证立即可见------一个线程写入了值,另一个线程可能永远读不到。这不是 bug,而是 JMM 允许编译器、JIT 和 CPU 为了性能做的优化(寄存器缓存、CPU Store Buffer、指令重排等)。
  2. 指令重排的反直觉 :开发写了 a = 1; b = 2;,CPU 可能实际执行的顺序是 b = 2; a = 1;。在单线程下毫无影响,但在多线程下,另一个线程可能看到 b = 2a 还是旧值------这就是"有序性"被破坏的经典案例。
  3. 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 做的远不止"更新公告牌"。它实际上做了两件事:

  1. 保证可见性 :对 volatile 变量的写操作会立即刷新到主内存,读操作总是从主内存读。在 x86 架构上,这对应一条 lock 前缀的汇编指令------它会刷写 Store Buffer(写屏障)并使其他核心的 L1 缓存失效(读屏障)。

  2. 禁止指令重排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 就保证前一个操作的结果对后一个操作可见。核心规则包括:

  1. 程序顺序规则:同一个线程中,前面的操作 happens-before 后面的操作(但仅限单线程!)
  2. volatile 变量规则 :对一个 volatile 变量的写 happens-before 后续对这个变量的读
  3. 锁规则 :一个锁的 unlock happens-before 后续的 lock
  4. 传递性:如果 A hb B,B hb C,则 A hb C
  5. 线程 start/join 规则thread.start() hb 线程内的任何操作;线程内的任何操作 hb thread.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 修复可见性

目标:给 flagvolatile,验证写入立即对读线程可见。

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());
    }
}

关键点volatileflag=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 addllock cmpxchg 等带 lock 前缀的指令,这些就是 volatile 的写屏障。

可能遇到的坑

  1. 可见性实验"总是成功" :如果在 while (!flag) 循环中调用了 System.out.println()Thread.sleep(),这些方法内部有 synchronized 块------它会隐式地刷新 CPU 缓存,导致"自然可见"。所以可见性实验的循环体中必须不能有任何同步操作。
  2. JIT 的死循环优化 :如果 JIT 发现 while (!flag) 中的 flag 不是 volatile,它可能直接把 flag 的值缓存在寄存器里,并优化成一个"无限循环 while(true)"------这确实会发生。这就是为什么有时候需要在 flag 上额外做一个"空壳"操作来抑制优化。
  3. javac 编译优化 :如果 flagfalse 且从未修改的字面量常量,编译器可能直接优化掉整个 while 循环------确保 flag 在运行时可能被修改。
  4. 不同 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 适用场景

  1. 状态标志位 :如 boolean shutdownboolean initialized------一个线程写、多个线程读,用 volatile 完美解决。
  2. DCL 单例 :双重检查锁定中的 instance 必须 volatile,防止半初始化对象泄漏。
  3. 无锁计数器AtomicLongLongAdder 在高并发计数场景下秒杀任何锁方案。
  4. 轻量级"读-写"锁 :用 volatile 修饰状态变量,配合 CAS 实现简易读写分离。
  5. 并发框架基础 :理解 AQS、ConcurrentHashMap 等高级并发工具必须先理解 JMM。

不适用场景

  • "读-改-写"的复合操作------volatile 不能保证原子性,应选用 Atomic* 或锁。
  • 多个变量需要保持一致性------比如"A 和 B 必须同时更新",volatile 无法保证两个变量更新的原子性。
  • 单线程场景------没有可见性问题,不加任何修饰即可。

4.3 注意事项

类型 详细说明
volatile 数组 volatile int[] arr 只保证数组引用本身的可见性,不保证数组元素 arr[i] 的可见性------用 AtomicIntegerArray 替代
64 位变量 longdouble 的 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 思考题

  1. 进阶题volatile 修饰的 long 变量,在多线程中执行 i++ 是否线程安全?如果不安全,请分析字节码层面涉及了多少条指令,并解释 AtomicLong.incrementAndGet() 的内部实现原理(提示:查看 Unsafe.compareAndSwapLong 的 native 实现)。

  2. 实战题:你的系统从 x86 迁移到 ARM 服务器(如 AWS Graviton)后,之前跑了 2 年的"无 bug"并发代码突然出现了间歇性的数据不一致。怀疑是指令重排导致。请设计一个最小可复现方案(不依赖任何外部工具),并给出修复建议。

答案提示:思考题 1 答案见本章步骤三 + 第 17 章 AQS/CAS 部分;思考题 2 答案见第 23 章逃逸分析与锁优化。


下一章预告:第 9 章将进入 GC 的世界------堆分代模型、Serial/Parallel 垃圾收集器,以及如何用 Unified Logging 绘制"分配速率-停顿"曲线。

延伸阅读与资源

Java 工程师进阶:从 JVM 生产排障到OpenJDK原理

相关推荐
xiaoqiMikko1 小时前
Central 上的 Spring 5.3.41 不是官方发的:逐字节比对,官方修两条它少一条
java·安全
AI深栈1 小时前
第 9 章 · Model Context Protocol(MCP)
java·人工智能
雨辰AI1 小时前
信创多租户项目 9 大踩坑|数据隔离失效、权限越权终极解决(金仓 / 达梦 / 高斯全库适配)
java·大数据·数据库·后端
captain3761 小时前
网络原理(4)-TCP ▲▲▲
java·服务器·网络·网络协议·tcp/ip
橙子圆1231 小时前
JUC之线程和进程
java
Bs_MoneyMagnet1 小时前
基于springboot+vue的旅游行程分享与推荐小程序的设计与实现 源码+文档
java·vue.js·spring boot·后端·微信小程序·毕业设计·计算机毕业设计
一技安身1 小时前
【信创】统信UOS 银河麒麟离线部署Python3.11完整方案
android·java·python3.11
陈皮波比茶1 小时前
k8s 快速入门
java·kubelet
Json____2 小时前
基于 Spring Boot + Vue3 的校园自习室座位预约系统实战
java·管理系统·it学习·wwwoop.com