Skip to content

Java内存模型从重排序到安全发布完整原理

Java Memory Model,简称 JMM,是 Java 语言规范定义的多线程内存语义。它不规定对象物理放在哪一块内存,而是规定:当多个线程读写共享变量时,哪些值允许被读到、哪些操作之间必须可见、哪些顺序不能被观察为颠倒。

JMM 在 Java 5 经过重要修订,JDK 7、JDK 8 以及后续版本都建立在这套核心规则上。因此企业常用 JDK 7/8 学并发时,JMM 是 volatilesynchronizedfinal、线程池和 JUC 的共同地基。

学习目标

学完后应能:

  1. 区分 JMM 与 JVM 堆、栈、方法区等运行时区域。
  2. 理解主内存、工作内存是规范抽象,不是物理部件名称。
  3. 解释可见性、原子性、有序性分别是什么。
  4. 识别编译器、JIT、CPU和Store Buffer等重排序来源。
  5. 理解 as-if-serial 为什么不能保证线程间安全。
  6. 使用 happens-before 判断一个写是否必须对另一个读可见。
  7. 解释 volatile 的读写语义、屏障方向和能力边界。
  8. 解释锁释放/获取、startjoin 等怎样传递数据。
  9. 理解 final 字段初始化安全与 this 逸出问题。
  10. 识别数据竞争、丢失更新、DCL和不安全发布。
  11. 使用锁、volatile、静态初始化和并发容器安全发布对象。
  12. 能根据代码画出 happens-before 链,而不是背“刷新缓存”。

一、JMM解决什么问题

单线程中,你通常按源码顺序理解程序:

java
int x = 1;
int y = x + 1;

多线程中,源码顺序、实际执行顺序和其他线程观察到的顺序可能不同。原因不是 Java 故意随机,而是现代系统为了性能允许:

  • 编译器和 JIT 重排互不影响的指令。
  • CPU 乱序执行并在提交时保持单线程语义。
  • 写操作暂存在 Store Buffer,稍后对其他核心可见。
  • 每个核心有多级缓存。
  • 运行时消除锁、标量替换或合并访问。

若语言不规定跨线程规则,同一程序在不同 CPU/JVM 上就无法推理。JMM 给出一个合法执行边界:JVM可以优化,但不能破坏程序根据同步规则应观察到的语义。

二、JMM和JVM内存结构不是一回事

概念回答的问题
JVM运行时数据区堆、虚拟机栈、方法区、程序计数器等怎样组织运行时数据
JMM多线程读写共享变量时,什么值可见、何时有顺序、哪些操作原子

对象通常在堆上,不代表线程每次读取字段都直接访问同一个物理DRAM地址;字段值可能经过寄存器、缓存、编译器优化。反过来,变量位于堆上也不自动线程安全。

三、主内存和工作内存是抽象模型

mermaid
flowchart TD
    A["JMM主内存:共享变量的抽象位置"] --> B["线程A工作内存:使用中的值"]
    A --> C["线程B工作内存:使用中的值"]
    B --> D["寄存器、CPU缓存、JIT临时值等实现"]
    C --> E["寄存器、CPU缓存、JIT临时值等实现"]
  • 主内存表示线程共享变量的规范抽象。
  • 工作内存表示线程执行时使用的变量值和中间状态。
  • 真实实现可能映射到寄存器、缓存、栈、堆或优化后的机器指令。

不要把工作内存机械等同于 Java 栈,也不要理解成“每次 volatile 写都把整个CPU缓存刷到物理内存”。JMM规定的是可观察语义,JVM针对目标CPU使用机器指令和内存屏障实现。

哪些变量受关注

JMM重点处理可被多个线程共享的实例字段、静态字段和数组元素。方法局部变量通常在线程私有栈帧中,不共享;但局部变量引用的对象仍可能被发布给其他线程。

四、可见性、原子性和有序性

4.1 可见性

线程A写 running=false 后,线程B是否必须读到新值。如果没有 happens-before,B可能持续使用旧值,也可能最终看到,但程序不能依赖“过一会儿自然同步”。

4.2 原子性

一个动作是否作为不可再分的整体观察。普通引用和大多数基本类型的单次读写具有相应原子语义,但:

java
count++;

等价于读、加、写三个逻辑步骤,不是原子复合操作。两个线程都读到 0,再各写 1,最终不是 2。

JLS 对非 volatile long/double 历史上允许实现将64位访问拆分;现代常见JVM通常原子实现,但可移植并发代码不能靠实现偶然性。需要跨线程协议时使用 volatile、锁或原子类。

4.3 有序性

源码顺序不等于所有线程观察顺序。只要单线程结果不变,编译器/CPU可能重排;同步动作会限制那些可能破坏跨线程语义的重排。

三者不能互相替代

机制可见性有序性复合原子性
普通变量无跨线程保证无同步顺序count++不保证
volatile保证相关写读可见限制关键重排不把复合操作变原子
synchronized/Lock锁释放获取保证临界区受锁规则约束临界区互斥实现复合原子性
Atomic类volatile式可见性相应内存语义单个原子API用CAS等保证

五、重排序来自哪里

mermaid
flowchart TD
    A["Java源码顺序"] --> B["javac字节码"]
    B --> C["JIT优化和机器指令"]
    C --> D["CPU乱序、Store Buffer和缓存"]
    D --> E["另一个线程可观察结果"]
    F["JMM同步规则"] --> C
    F --> D

编译器和JIT

只要不改变当前线程按单线程规则能观察到的结果,就可以移动普通读写、消除重复读取、把值保存在寄存器等。

CPU

CPU可让独立指令并行、乱序执行。写入可能先进入本核心 Store Buffer,另一个核心暂时看不到。

内存系统

缓存一致性协议让核心最终对缓存行形成一致认识,但“最终一致”不等于 Java 程序需要的操作顺序,也不把 count++ 变成原子操作。JMM同步与机器屏障共同建立语言承诺。

六、as-if-serial为什么不等于线程安全

as-if-serial 的直觉是:优化可以改变执行方式,但不能改变单线程程序可观察结果。

java
data = 42;
ready = true;

在当前线程中,只要后面逻辑不受影响,普通写可能被其他线程观察为不同顺序。另一个线程若没有同步地执行:

java
if (ready) {
    System.out.println(data);
}

JMM不保证它看到 ready=true 时一定看到 data=42。跨线程需要 happens-before,而不是用单线程直觉推理。

七、数据竞争是什么

当两个线程访问同一变量,至少一个是写,并且访问之间没有 happens-before 排序,就存在数据竞争。

数据竞争不等于程序必定每次出错,而是结果不再拥有你期待的同步保证。常见表现:

  • 偶发旧值。
  • 丢失更新。
  • 对象字段组合不一致。
  • 只在生产多核机器出现。
  • 加日志或调试后“自己好了”。

正确同步的程序可获得更容易推理的语义;不要把压力测试暂时没复现当作没有数据竞争。

八、happens-before到底保证什么

若 A happens-before B:

  1. A的内存效果对B可见。
  2. A在JMM语义上排在B之前。

它不是墙上时钟的“先发生”。两个操作时间上先后,不建立规则时也不一定有跨线程可见性;建立 happens-before 的操作可以通过传递性把之前的普通写一并发布。

常用规则

规则含义
程序顺序同一线程中前序动作 happens-before 后序动作
Monitor锁对锁的释放 happens-before 后续对同一锁的获取
volatile对变量的写 happens-before 后续读取该写的读
线程启动调用 start() 前的动作 happens-before 新线程动作
线程终止线程动作 happens-before 另一线程从 join() 成功返回等终止检测
中断interrupt() happens-before 被中断线程检测到中断
传递性A→B且B→C,则A→C

类初始化也由JVM同步,静态初始化完成对随后使用该类的线程安全可见,这是静态内部类单例成立的重要基础。

九、用链路推导消息发布

java
class MessageBox {
    int data;
    volatile boolean ready;
}

线程A:

java
box.data = 42;       // A1 普通写
box.ready = true;    // A2 volatile写

线程B:

java
if (box.ready) {     // B1 volatile读,且读到true
    System.out.println(box.data); // B2 普通读
}

推导:

text
A1 --程序顺序--> A2
A2 --volatile规则--> B1
B1 --程序顺序--> B2
所以 A1 --传递性--> B2

因此B读到对应的 ready=true 后,必须看到此前发布的 data=42。这比“volatile强制从主内存读”更准确,因为核心是同步顺序和传递性。

十、volatile读写语义

可以把 volatile 写近似理解为 Release,把读近似理解为 Acquire:

  • Release:该写之前的普通读写不能被重排到它之后而破坏发布。
  • Acquire:该读之后的普通读写不能被重排到它之前而破坏获取。
  • 读到对应写后,通过 happens-before 看到发布线程之前的内存效果。

具体机器屏障组合由JVM和CPU决定,不应把源码层语义死记成所有平台完全相同的某条指令。

volatile不能做什么

  • 不能保证 count++ 原子。
  • 不能维持多个字段的联合不变量。
  • 不能代替事务和对象级权限。
  • 不保证公平。
  • 不让“读改写”自动互斥。

适合:状态标志、一次写多次读的配置引用、配合CAS的状态字段、发布不可变快照。

十一、可运行Demo:volatile消息发布

java
public class VolatilePublishDemo {
    private static int data;
    private static volatile boolean ready;

    public static void main(String[] args) throws InterruptedException {
        Thread reader = new Thread(new Runnable() {
            @Override
            public void run() {
                while (!ready) {
                    Thread.yield(); // JDK 7/8可运行;仅用于演示,不是生产等待方案
                }
                System.out.println("data=" + data);
            }
        }, "reader");

        reader.start();
        data = 42;
        ready = true;
        reader.join();
    }
}

该示例可直接在 JDK 7/8 运行。Thread.yield() 只给调度器提示,不负责可见性,真正建立消息发布语义的是 volatile;生产代码不应长期自旋,应根据场景使用阻塞队列、Latch、Condition等协调工具。

运行:

bash
javac VolatilePublishDemo.java
java VolatilePublishDemo

输出应为 data=42。Demo证明的是建立的happens-before链,不是说所有普通变量最终都可见。

十二、为什么volatile count++仍丢失

mermaid
flowchart TD
    A["线程A读取count=0"] --> C["线程A计算1"]
    B["线程B读取count=0"] --> D["线程B计算1"]
    C --> E["线程A写count=1"]
    D --> F["线程B写count=1"]
    E --> G["最终count=1"]
    F --> G

每一次 volatile 读和写本身有可见性,但两个读改写序列可以交错。需要:

java
AtomicInteger count = new AtomicInteger();
count.incrementAndGet();

或把 count++ 放入同一把锁保护的临界区。多个字段必须同时满足不变量时,锁通常比多个独立Atomic更直接。

十三、锁怎样建立可见性和互斥

线程A在同步块中修改普通字段,退出时释放 Monitor;线程B随后获取同一个 Monitor,A释放 happens-before B获取,因此B能看到A临界区中的写。

java
synchronized (lock) {
    balance = balance - amount;
    status = "PAID";
}

锁同时提供:

  • 同一时刻一个线程进入,实现复合操作互斥。
  • 释放/获取的可见性。
  • 临界区边界所需的有序性。

关键是“同一把锁”。A锁对象1、B锁对象2,不建立Monitor规则。锁对象若变化也会破坏协议。

ReentrantLock 等 JUC Lock 实现提供与 Monitor 类似的内存同步语义,必须在 finally 中释放。

十四、start和join怎样安全传递数据

start规则

java
Config config = loadConfig();
Thread worker = new Thread(() -> use(config));
worker.start();

调用 start() 前的配置构造和写入 happens-before 新线程执行,因此不需要为了这次启动额外把每个字段都声明 volatile。不能直接调用 run() 代替 start;run() 只是当前线程普通方法。

join规则

java
Thread worker = new Thread(() -> result = calculate());
worker.start();
worker.join();
System.out.println(result);

worker中的动作 happens-before join() 成功返回后的动作,因此当前线程可读到结果。

sleep没有该语义

Thread.sleep(1000) 只让当前线程暂时不运行,不释放已有Monitor,也不建立“另一个线程写入对我可见”的 happens-before。用sleep猜任务完成既慢又不可靠。

十五、final字段为什么特殊

构造函数中正确写入 final 字段,并且对象在构造完成前没有逸出时,其他线程通过正常引用看到对象后,对 final 字段有初始化安全保证;同时 final引用所指对象在构造期间完成的相关初始化也有特定语义。

java
final class UserSnapshot {
    private final long userId;
    private final String name;

    UserSnapshot(long userId, String name) {
        this.userId = userId;
        this.name = name;
    }
}

final 不会让引用对象内部自动不可变。例如 final List 只是引用不能重新赋值,列表内容仍可改变。真正不可变对象还要私有字段、防御性复制、不暴露可变内部状态。

十六、this逸出为什么破坏初始化安全

错误例子:

java
class Listener {
    private final int threshold;

    Listener(EventBus bus) {
        bus.register(this); // this在构造完成前被其他线程获得
        threshold = 100;
    }
}

另一个线程可能在 threshold 完成初始化前调用对象。常见逸出方式:

  • 构造函数中注册监听器。
  • 构造函数中启动线程并捕获this。
  • 把this写入静态集合。
  • 调用可被子类覆盖的方法,子类访问未初始化字段。

正确方式是先完成构造,再由工厂或外部代码注册/启动。

十七、安全发布对象的方式

安全发布意味着:另一个线程不仅拿到引用,还能看到构造完成的状态。

常用方式:

  1. 在静态初始化中发布。
  2. 将引用写入 volatile 字段。
  3. 在锁内写引用,并在同一锁内读。
  4. 放入线程安全容器、阻塞队列或并发工具。
  5. 构造完成后通过 start() 传给新线程。
  6. 发布正确构造的不可变对象。

静态初始化

java
public final class ConfigHolder {
    public static final Config INSTANCE = load();
}

类初始化由JVM同步,完成后对使用该类的线程可见。

volatile快照

java
private volatile ConfigSnapshot current = ConfigSnapshot.empty();

public void reload() {
    ConfigSnapshot next = loadAndValidate();
    current = next;
}

先完整构造不可变 next,再用一次 volatile 写发布。读线程获取某个完整快照,不会看到逐字段修改的中间状态。

十八、双重检查锁为什么必须volatile

java
public final class Singleton {
    private static volatile Singleton instance;

    private Singleton() {}

    public static Singleton getInstance() {
        Singleton result = instance;
        if (result == null) {
            synchronized (Singleton.class) {
                result = instance;
                if (result == null) {
                    result = new Singleton();
                    instance = result;
                }
            }
        }
        return result;
    }
}

没有 volatile 时,对象分配、初始化和引用发布可能被其他线程观察为引用先可见而字段尚未拥有期望状态。Java 5修订后的 volatile 语义使正确DCL成立,所以JDK7/8必须保留volatile。

更简单的选择:枚举单例、静态字段或静态内部类Holder,利用类初始化安全性。

局部变量 result 减少 volatile 读取次数,是常见优化,但不是正确性的核心。

十九、JUC工具怎样传递内存效果

JUC并发工具不仅协调线程,也定义内存一致性效果。例如:

  • 放入 BlockingQueue 前的动作对取出该元素后的动作可见。
  • CountDownLatch.countDown() 前的动作对成功从 await() 返回后的动作可见。
  • 提交任务前的动作对任务执行可见。
  • 异步计算动作对 Future.get() 返回后的线程可见。
  • 并发容器的指定操作建立相应可见性。

实际使用以对应类Javadoc的 Memory Consistency Effects 为准。不要用自制普通boolean+sleep替代成熟协调器。

二十、常见错误与后果

错误为什么不成立正确做法
多核缓存最终一致所以无需同步最终性无时限和程序顺序保证建立happens-before
volatile让所有代码线程安全不提供复合互斥Atomic或锁
sleep后变量肯定刷新sleep无该同步规则join、Latch、Queue等
ConcurrentHashMap里的对象自动安全容器操作安全不等于值对象内部安全不可变值或独立同步
锁了不同对象也能可见Monitor规则要求同一锁固定锁协议
final集合不可变final只固定引用不可变副本/只读视图
构造器里启动线程没问题this可能提前逸出构造后启动
压测没错就是安全数据竞争可能依赖时序/平台按JMM证明并用并发测试

二十一、生产场景:配置热更新

要求:解析新配置时失败不能污染旧配置,读请求不能加重锁。

java
public final class ConfigService {
    private volatile ConfigSnapshot current = ConfigSnapshot.empty();

    public ConfigSnapshot current() {
        return current;
    }

    public void reload(byte[] source) {
        ConfigSnapshot next = ConfigSnapshot.parse(source);
        next.validate();
        current = next;
    }
}

过程:

text
后台线程解析所有字段
→ 校验成功形成不可变快照
→ volatile一次发布引用
→ 业务线程volatile读取
→ 得到旧完整快照或新完整快照

不能在一个共享可变对象上逐字段更新,否则读线程可能看到“新地址+旧端口”组合。若更新还涉及数据库、文件等多系统原子性,volatile也不够,需要更高层状态机和事务协议。

二十二、排查多线程可见性问题

  1. 找出共享变量和所有读写线程。
  2. 标出哪些操作至少一个为写。
  3. 为每对冲突访问画出 happens-before 链。
  4. 若链不存在,判断需要可见性、互斥还是联合不变量。
  5. 检查volatile是否只保护标志,却遗漏复合状态。
  6. 检查锁是否同一对象、是否存在锁外访问。
  7. 检查对象是否在构造完成前逸出或不安全发布。
  8. 用线程转储确认死锁/阻塞,但线程转储不能直接证明所有数据竞争。
  9. 使用压力测试、JCStress等放大交错;测试通过仍需规范证明。
  10. 修复后把失败模式加入并发回归。

不要通过“加一条日志”“多sleep一会儿”作为修复。日志和调度变化可能只改变时序,掩盖竞争。

二十三、面试标准回答

JMM是什么

JMM是Java规范定义的多线程内存语义,规定共享变量读写的可见性、原子性、有序性和合法执行结果。主内存/工作内存是抽象,不等于物理DRAM/线程栈。程序通过volatile、锁、线程生命周期和JUC工具建立happens-before,从而跨CPU/JVM实现获得一致可推理的语义。

happens-before是什么

若A happens-before B,A的内存效果必须对B可见且语义顺序在B之前。常见有程序顺序、同锁释放到后续获取、volatile写到后续读、start、join和传递性。它不是简单的时间先后,而是判断跨线程可见性的规范依据。

volatile为什么能保证前序普通写可见

线程内普通写程序顺序先于volatile写;该volatile写happens-before另一个线程读到它;volatile读又程序顺序先于后续普通读,通过传递性,发布线程的前序普通写对获取线程后续读取可见。

volatile为什么不能保证count++

count++包含读、加、写,两个线程可以读取同一个旧值再写回相同新值。volatile保证单次读写可见性和相关顺序,不把三个步骤变成互斥整体;应使用AtomicInteger或锁。

怎样安全发布对象

可通过静态初始化、volatile引用、同一把锁、并发容器/阻塞队列、线程start规则或正确构造的不可变对象发布。构造期间不能让this逸出;多个字段作为快照时先完整构造不可变对象,再一次发布引用。

二十四、关联知识点

二十五、学习验收清单

  • [ ] 能区分JMM和JVM运行时数据区。
  • [ ] 能解释工作内存为何不是物理线程栈。
  • [ ] 能分别举例可见性、原子性和有序性问题。
  • [ ] 能说明编译器、CPU和缓存重排序来源。
  • [ ] 能判断一段代码是否存在数据竞争。
  • [ ] 能画出volatile消息发布的happens-before链。
  • [ ] 能解释volatile count++丢失更新。
  • [ ] 能使用锁、start、join和JUC规则传递结果。
  • [ ] 能解释final字段和this逸出。
  • [ ] 能写正确DCL并说明Java 5/JDK7/8关系。
  • [ ] 能设计不可变快照热更新。
  • [ ] 能按JMM证据排查,而不是靠sleep猜测。

本章小结

JMM的价值是给多线程优化划定可推理边界。普通读写没有跨线程承诺;happens-before通过volatile、锁、线程生命周期和并发工具建立可见性与顺序;复合状态还需要互斥或原子操作。理解这些规则后,volatilesynchronized、DCL、安全发布和JUC不再是孤立八股,而是同一套内存语义的不同实现。