Contents

JVM基础:Java 内存模型(JMM)全解析

一、引言:为什么需要 Java 内存模型?

在单线程程序中,变量的读取和写入是直观且一致的。但一旦进入多线程环境,情况就复杂得多——一个线程对变量所做的修改,另一个线程可能看不到,也可能看到不一致的中间状态。更令人困惑的是,这种问题即使在没有显式锁的代码中也可能悄然发生。你写下的代码语义清晰、逻辑无误,但却因为“看不见的顺序”而出现诡异的 bug。

这背后正是 Java 内存模型(Java Memory Model, JMM) 所要解决的问题。


并发编程中的三个核心问题

Java 内存模型主要为了解决三个问题:

  1. 原子性(Atomicity):一个操作是否不可被打断
  2. 可见性(Visibility):一个线程对变量的修改对其他线程是否可见
  3. 有序性(Ordering):程序的执行顺序是否和代码顺序一致

JMM 的设计目的,就是在保证这三者之间合理权衡的同时,让 Java 程序在多线程下依然能够跨平台正确运行


为什么不能只依赖硬件或操作系统?

在现代硬件架构中:

  • CPU 有多级缓存,线程可能只读写自己的缓存而非主内存
  • 编译器会优化代码执行顺序,比如将某些语句重排序以提高性能
  • 不同 CPU 有不同的内存一致性模型(如 x86 vs ARM)

这些差异导致,如果没有统一的规范,那么相同的 Java 代码在不同平台上会表现出不同的并发行为。这对 Java 的“跨平台”哲学是个巨大威胁。


JMM:Java 层的并发“契约”

为了解决上述问题,Java 在语言级别提出了一套规范——Java 内存模型,它并不是 CPU 的物理内存模型,而是:

  • 一套 行为规范(specification):描述多线程之间读写共享变量时应遵循的规则
  • 一种 抽象语义模型:屏蔽硬件差异,在语义层面保证程序执行的一致性
  • 并通过 volatilesynchronizedfinal 等语言关键字来进行控制

JMM 使得开发者可以用统一的方式来思考并发问题,而不需要关心底层平台的指令级差异。


举个实际例子:变量失效问题

class Flag {
    static boolean stop = false;

    public static void main(String[] args) {
        new Thread(() -> {
            while (!stop) {
                // do something
            }
        }).start();

        try { Thread.sleep(1000); } catch (InterruptedException e) {}
        stop = true;
    }
}

你期望 1 秒后主线程将 stop 设置为 true,子线程跳出循环。但很可能子线程永远不会结束

为什么?因为子线程可能只从 CPU 缓存中读取 stop,而根本不知道主线程已经修改了它。这种现象,正是 JMM 中可见性问题的典型代表

解决方法之一就是将 stop 声明为 volatile,告诉 JVM:这个变量不能缓存,必须直接从主内存读取


二、JMM 的抽象模型与基本规则

Java 内存模型的核心是一种抽象的、面向线程与主内存交互的模型。理解这个模型,是掌握 JMM 的关键第一步。


主内存(Main Memory)与工作内存(Working Memory)

JMM 将每一个线程的执行环境分为两个部分:

  • 主内存(Main Memory):所有线程共享的内存区域,存储所有的实例字段、静态字段等(即共享变量)
  • 工作内存(Working Memory):每个线程私有的内存区域,用于保存该线程使用的变量的副本(类似 CPU 的寄存器或高速缓存)

线程对变量的所有操作(读取、写入),必须先从主内存复制到工作内存中再操作,并在操作后刷新回主内存

注意:局部变量不在 JMM 控制之内,它们存储在栈帧中,不共享,因此不存在并发问题。


工作内存与主内存的交互指令(8个)

Java 内存模型定义了 8 个用于线程与主内存交互的低级指令:

指令作用
load从主内存中读取变量值到工作内存
store将工作内存中的变量值写回主内存
read从主内存中复制变量到工作内存中(依赖于 load)
write将变量从工作内存写入主内存(依赖于 store)
use将工作内存中的变量值传递给执行引擎
assign执行引擎将值赋给变量,存入工作内存
lock对变量加锁(用于 synchronized)
unlock对变量解锁(释放锁的过程会强制刷新到主内存)

虽然我们在写代码时看不到这些指令,但 JVM 会在字节码层隐式使用它们。


内存访问规则

JMM 对上述指令有以下规则:

  • 一个变量在没有 load 之前不能 useassign
  • 一个变量在 assign 之后必须 store 才能 write 到主内存
  • 同一个变量的操作必须串行进行(不允许并发 load/store
  • 每个变量在每个线程中都有独立的副本

示意图:JMM 的线程与主内存模型

下面是一张简化的示意图:

        主内存(Main Memory)
      -------------------------
     |      变量x     变量y     |
      -------------------------
         ↑           ↑
         |           |
         ↓           ↓
线程 A 的工作内存        线程 B 的工作内存
 -------------------    -------------------
| 副本x_A    副本y_A |  | 副本x_B    副本y_B |
 -------------------    -------------------

操作顺序:
1. 线程A -> load(x) -> read(x) -> use/assign(x)
2. assign(x) -> store(x) -> write(x) -> 主内存

每个线程从主内存读取变量的副本放入自己的工作内存中,在那里进行修改,然后再同步回主内存。如果不刷新,其他线程是看不到这个修改的,这就是可见性问题的来源


小结

  • 线程对共享变量的修改,默认不会立刻对其他线程可见
  • 想要让别的线程看到你的修改,必须显式同步(如 volatile、锁)
  • JMM 的模型可以看作是:线程=操作缓存;主内存=真实世界

三、Happens-Before 原则

为了解决这些看不见的问题,Java 内存模型引入了一个关键的“可见性”规范 —— happens-before(先行发生)关系。

什么是 happens-before?

如果操作 A happens-before 操作 B,那么:

  • 操作 A 的结果对于操作 B 是可见的
  • 操作 A 的执行顺序在操作 B 之前

注意:happens-before 并不是“物理时钟上的先后”,而是一种程序行为的逻辑顺序保证

JMM 中的 happens-before 规则(重点)

  1. 程序顺序规则:一个线程内,代码的执行顺序就是 happens-before 顺序。
  2. 锁规则:一个线程对锁的 unlock 操作,happens-before 之后另一个线程对同一锁的 lock 操作。
  3. volatile 变量规则:对一个 volatile 变量的写操作,happens-before 于后续对该变量的读操作。
  4. 线程启动规则:线程 A 启动线程 B(即 Thread.start()),则 A 中的 start happens-before 于 B 线程的任意操作。
  5. 线程终止规则:线程中的所有操作 happens-before 于其他线程检测到它已经终止(比如 Thread.join() 返回)。
  6. 对象构造规则:构造函数中的所有操作在对象被另一个线程看到之前都必须完成(除非 this 泄露)。

这些规则共同构成了 Java 并发程序在内存层面的行为保障。

volatile 如何保证可见性?

JVM 对 volatile 做了两件事:

  1. 禁止重排序:对于 volatile 写操作,编译器和处理器不允许把它与前后的读写操作重排序;
  2. 强制刷新主内存:写入 volatile 的变量会立刻写入主内存,而读操作会从主内存中重新读取。

这就避免了工作内存缓存变量所造成的可见性失效问题。

volatile 是否保证原子性?

不保证。

例如:

volatile int count = 0;
count++;

虽然 count 是 volatile,但 ++ 不是原子操作。它包含了读、加一、写三个步骤,仍可能在多线程环境下发生竞态条件。

想保证原子性,需使用锁(如 synchronized)或原子类(如 AtomicInteger)。

四、有序性与指令重排序

为什么有序性重要?

在单线程程序中,我们天然相信代码是从上到下、顺序执行的。

但在并发程序中,CPU、编译器、JVM 为了优化性能,可能会调整代码的执行顺序。这并不是 bug,而是设计使然。但它会破坏我们对代码行为的直觉认知,从而引发并发错误。

例如,在没有同步保障的情况下,以下代码的执行顺序可能被改变:

int a = 1;
int b = 2;
int c = a + b;

可能会被重排序成:

int b = 2;
int a = 1;
int c = a + b;

在单线程下这不会出问题,但在多线程下就可能造成严重后果。

以下示例可以复现这种现象

public class InstructionReorder {

    public static void main(String[] args) {

        for (int i = 0; i < 1000000; i++) {
            final ReorderMe reorderMe = new ReorderMe();
            new Thread(() -> {
                reorderMe.a = 1;
                reorderMe.b = 2;
                reorderMe.c = reorderMe.a + reorderMe.b;
            }).start();

            new Thread(() -> {
                int tmpa = reorderMe.a, tmpb = reorderMe.b, tmpc = reorderMe.c;
                if(tmpb == 2 && tmpa == 0) {
                    System.out.println("Instruction Reordering: a = " + tmpa + ", b = " + tmpb + ", c = " + tmpc);
                }
                if(tmpc > 0 && tmpa == 0) {
                    System.out.println("Instruction Reordering: a = " + tmpa + ", b = " + tmpb + ", c = " + tmpc);
                }
                if(tmpc > 0 && tmpb == 0) {
                    System.out.println("Instruction Reordering: a = " + tmpa + ", b = " + tmpb + ", c = " + tmpc);
                }
            }).start();
        }
    }

    static class ReorderMe {
        int a = 0, b = 0, c = 0;

    }

}
输出: Instruction Reordering: a = 1, b = 0, c = 3

什么是指令重排序?

JMM 下的指令重排序分为三个层次:

  1. 编译器重排序:Java 编译器在生成字节码时,为了优化性能,可能会重排语句顺序;
  2. 指令级重排序:JVM 指令到机器指令的转换过程中,JIT 编译器可能改变执行顺序;
  3. CPU 重排序:处理器出于流水线和乱序执行等目的,也会对指令顺序进行动态调整。

注意:这些重排序都不影响单线程语义,但会对多线程造成影响。


JMM 如何限制重排序?

Java 内存模型通过 happens-before 关系和特定的关键字(如 volatilesynchronized)来约束重排序的范围,从而维持程序在多线程中的正确性。

以下操作具有内存屏障效果,用来限制或禁止重排序:

synchronized

synchronized 的进入和退出,JVM 会插入MonitorEnter 和 MonitorExit指令。这些操作会触发内存屏障:

  • 锁的释放(unlock)前:线程必须将工作内存中对共享变量的修改刷新到主内存。
  • 锁的获取(lock)后:线程必须从主内存中重新读取共享变量的值。

因此,synchronized 能同时保证原子性、可见性、有序性

volatile

JVM 在访问 volatile 变量时会插入特定的读写屏障:

  • volatile 写入之前:会插入 storestore 屏障,禁止与之前的写重排序;
  • volatile 写入之后:插入 storeload 屏障,禁止之后的读写指令提前执行;
  • volatile 读取之前:插入 loadload 屏障;
  • volatile 读取之后:插入 loadstore 屏障;

这些屏障使得 volatile 变量的写对其他线程“立刻可见”,并阻止读写操作的重排序。


指令重排序带来的并发错误示例

以下代码存在严重的重排序风险:

class Singleton {
    private static Singleton instance;

    public static Singleton getInstance() {
        if (instance == null) {               // ①
            synchronized (Singleton.class) {
                if (instance == null) {       // ②
                    instance = new Singleton(); // ③
                }
            }
        }
        return instance;
    }
}

上述“DCL”(双重检查锁)写法是错误的,因为 new Singleton() 实际上分为三步:

  1. 分配内存;
  2. 调用构造函数初始化;
  3. 将引用赋值给 instance

如果发生指令重排序,使得 3 在 2 前执行,另一个线程可能拿到一个“未初始化完成”的对象。

解决方案:

private static volatile Singleton instance;

加入 volatile 后,禁止了对象初始化的指令重排序,从而保证了线程安全。

五、原子性与 Java 中的原子操作

什么是原子性?

原子性(Atomicity)是指一个操作不可分割,即使在多线程环境下,也不会被中断或看到“中间状态”。

一个操作如果具有原子性,那么对其他线程来说,要么操作已经完成,要么尚未开始,绝不会看到“操作一半”的状态


Java 中的非原子操作示例

最经典的非原子性示例是 i++ 操作,看似一个语句,实际包含多个步骤:

i++; // 实际包含:
1. 读取 i 的值到工作内存
2. 执行 +1
3. 写回 i 到主内存

在多线程下,若两个线程同时执行 i++,可能造成如下情况:

  1. 线程 A 读到 i = 1;
  2. 线程 B 也读到 i = 1;
  3. 两者都 +1,写回结果为 2;
  4. 最终结果为 2,而不是期望的 3。

这种行为就违反了原子性。


如何在 Java 中保证原子性?

1. 使用 synchronized

synchronized 是最基本的原子性保障手段,通过对象锁或类锁,在进入临界区前保证互斥访问,避免线程之间交叉执行。

public synchronized void increment() {
    i++;
}

public void increment() {
    synchronized (this) {
        i++;
    }
}

synchronized 同时保障原子性、可见性和有序性,但性能可能较低。


2. 使用 ReentrantLock

java.util.concurrent.locks.ReentrantLock 是比 synchronized 更灵活的可重入锁,也能保证原子性。

Lock lock = new ReentrantLock();
lock.lock();
try {
    i++;
} finally {
    lock.unlock();
}

它支持尝试锁、定时锁、公平锁等扩展能力。


3. 使用 volatile?不能保证原子性!

一个常见误区是使用 volatile 变量来避免竞态。但 volatile 仅能保证可见性禁止重排序不能保证原子性

例如以下代码并不线程安全:

private volatile int count = 0;

public void increment() {
    count++; // 非原子操作
}

即便每个线程都能“看见”最新的值,但它们还是可能在读取到旧值的基础上计算,从而丢失更新。


4. 使用原子类(Atomic*)

JDK 提供了一组基于 CAS(Compare And Swap) 的原子类,可以无锁地保证单个变量的原子更新。

如:

AtomicInteger counter = new AtomicInteger(0);
counter.incrementAndGet();  // 原子性 +1

常用原子类包括:

  • AtomicInteger / AtomicLong / AtomicBoolean
  • AtomicReference
  • AtomicStampedReference(解决 ABA 问题)
  • AtomicIntegerArray / AtomicLongArray

底层依赖于 JVM 的 Unsafe.compareAndSwapInt() 等方法实现。


CAS 的底层原理

CAS 是一条原子性 CPU 指令,流程为:

如果当前值 == 预期值:
    则更新为新值
否则:
    什么也不做

这类指令由硬件提供,可以无需加锁就实现并发更新,性能更优。

CAS 三大问题:

  1. ABA 问题:值 A → B → A,CAS 无法感知变化;
  2. 自旋开销:CAS 会自旋重试,可能耗 CPU;
  3. 只能保障单变量:多个变量的一致更新无法用 CAS 保证,需要 AtomicReferenceFieldUpdater 等更复杂手段。

总结

手段可见性原子性有序性适用场景
volatile状态标志、单变量只读写
synchronized互斥操作,临界区控制
ReentrantLock复杂并发控制(如公平锁)
AtomicInteger单变量频繁更新,高性能场景

六、可见性与内存屏障机制

什么是可见性?

可见性(Visibility)是指一个线程对共享变量的修改,能被其他线程立即看到

在多线程环境中,Java 会为每个线程分配一块 工作内存(线程本地缓存)。当线程读取或写入变量时,可能只作用于自己的工作内存,不会立刻刷新到主内存。

因此,某线程对变量的更改可能对其他线程不可见,就会出现“读到旧值”的问题。


可见性的实现机制:内存屏障(Memory Barrier)

Java 的可见性保障离不开底层 内存屏障(memory barrier)/内存栅栏 的支持。

当你使用:

  • volatile
  • synchronized
  • final(构造完成前的只读语义)

JVM 会在合适的位置插入内存屏障,以限制指令重排序并强制刷新主内存。

常见屏障类型:

  1. StoreStore Barrier:确保前面的写操作先于后面的写操作;
  2. LoadLoad Barrier:确保前面的读操作先于后面的读操作;
  3. LoadStore Barrier:确保前面的读操作先于后面的写操作;
  4. StoreLoad Barrier最强屏障,确保前面的写操作对其他线程可见,必须在其后的读操作之前完成。

volatile 的内存语义(JMM 语义)

volatile 变量的读写,在字节码中表现为 volatile 指令(如 getfield volatileputfield volatile),JVM 在这些操作前后会插入内存屏障:

写操作时:

putfield volatile -> StoreStore + StoreLoad
  • 写入前,先将前面的所有写刷新;
  • 写完后,禁止之后的读写操作重排序。

读操作时:

getfield volatile -> LoadLoad + LoadStore
  • 读之前,确保之后的读不会重排序;
  • 读之后,禁止后续的写操作提前。

这样确保了写入操作对其他线程可见,也禁止了某些重排序,保证线程间的数据一致性。


使用 synchronized 是否也保证可见性?

是的。

synchronized 的进入和退出操作分别对应:

  • 进入临界区:读取主内存中的变量值到工作内存中
  • 退出临界区:强制将工作内存中的修改刷新到主内存中

因此它天然具有可见性保障。


小结

可见性保障手段是否保证可见性是否保证原子性是否禁止重排序
volatile✅(部分)
synchronized
原子类(CAS)✅(依赖内存屏障)✅(部分)

可见性是并发编程中最容易被忽视的问题,很多程序在单线程或低并发环境中运行良好,一旦在多核高并发环境中部署,就会出现“莫名其妙”的读错数据的问题,根本原因就是可见性丧失