Skip to content

内存模型

更新: 1/19/2026 字数: 0 字 时长: 0 分钟

Java 内存模型(JMM)是一个抽象概念,它不是实际存在的硬件或软件,而是一套规则和规范。这套规则定义了程序中所有变量(包括实例变量、静态变量和数组元素)在内存中的访问方式,特别是在多线程环境下的行为。

JMM 的作用

  • 平台一致性:屏蔽不同硬件和操作系统内存访问的差异,确保 Java 程序在任何平台下都能有一致的并发行为。
  • 定义内存关系:规定了线程如何与内存进行交互。

核心思想

  • JMM 假定系统存在一个主内存(Main Memory),所有线程共享的变量都存储在这里。
  • 每条线程都有自己的工作内存(Working Memory),里面保存着主内存中这些共享变量的副本
  • 线程对变量的所有操作(读、写)都必须先在自己的工作内存中进行,不能直接操作主内存。
  • 线程之间的通信(数据传递)也必须通过主内存来完成。

主内存与工作内存的对应关系(与 JVM 内存区域的区别):JMM 的主内存和工作内存是抽象概念,与 JVM 运行时数据区(堆、栈、方法区等)不是直接对应的。

  • 主内存:更接近于物理内存,主要存储 Java 堆中的对象实例数据。
  • 工作内存:更接近于 CPU 的高速缓存和寄存器,存储线程私有的变量副本。

内存交互

JMM 定义了 8 种原子操作来规范主内存和工作内存之间的数据交互。每个操作都是不可中断的。

注意:除了 volatile 修饰的 longdouble 类型变量,其他 longdouble 变量的读写操作在 32 位系统上可能不是原子的,而是被拆分成两次 32 位操作。

  • lock(锁定):作用于主内存,将变量标记为线程独占状态。
  • unlock(解锁):作用于主内存,释放变量的独占状态,允许其他线程锁定。
  • read(读取):作用于主内存,将变量值从主内存传输到线程的工作内存。
  • load(载入):作用于工作内存,紧接在 read 之后,将 read 到的值放入工作内存的变量副本。
  • use(使用):作用于工作内存,将工作内存中变量的值传递给执行引擎(CPU),每次使用变量前都需要。
  • assign(赋值):作用于工作内存,将执行引擎计算出的值赋给工作内存的变量。
  • store(存储):作用于工作内存,将工作内存中变量的值传输到主内存。
  • write(写入):作用于主内存,紧接在 store 之后,将 store 得到的值写入主内存的变量。

规则总结:这些操作必须遵循严格的顺序和组合规则,以确保内存操作的正确性。例如,readload 必须成对出现,storewrite 也必须成对出现。lockunlock 必须成对,且 unlock 前必须先 storewritelock 操作会清空工作内存中该变量的副本,强制线程重新从主内存加载最新值。

参考文章:https://github.com/CyC2018/CS-Notes/blob/master/notes/Java 并发.md

三大特性

可见性

定义:当多个线程访问同一个共享变量时,一个线程修改了这个变量的值,其他线程能够立即看到并读取到这个修改后的最新值。

问题根源:由于每个线程都有自己的工作内存(缓存),它们操作的是共享变量的副本。如果一个线程修改了副本,但没有及时同步回主内存,或者其他线程没有及时从主内存刷新副本,就会导致“不可见”问题,即线程读取到的是旧值。

示例:main 线程对 run 变量的修改对于 t 线程不可见,导致了 t 线程无法停止

java
static boolean run = true; // 默认值是 true
public static void main(String[] args) throws InterruptedException {
    Thread t = new Thread(() -> {
        while(run) { // t 线程持续检查 run 变量
        // ... 其他操作
        }
        System.out.println("t 线程停止了。");
    });
    t.start();

    Thread.sleep(1000); // 主线程等待 1 秒
    run = false; // 主线程修改 run 的值
    System.out.println("主线程将 run 设置为 false。");
    // 预期:t 线程应该在 1 秒后停止,但实际可能不会。
}

原因分析

  1. 初始状态t 线程启动后,将 run 变量的初始值 true 从主内存读取到自己的工作内存中。
  2. JIT 优化:为了提高效率,JIT 编译器可能会将 while(run) 循环优化为直接读取 t 线程工作内存中的 run 副本,甚至可能将其视为一个常量 true,从而减少频繁访问主内存。
  3. 修改不同步:1 秒后,main 线程修改了 run 的值为 false,并同步回主内存。
  4. t 线程的盲区:由于 t 线程可能持续从自己的工作内存缓存中读取 run 的旧值(true),它无法感知 main 线程对主内存中 run 变量的修改,导致 while(run) 循环永远不会结束。

解决方案

  • 使用 volatile 关键字修饰 run 变量:volatile 强制所有线程从主内存读取最新值,并立即写回主内存。
  • 使用 synchronizedLock:它们不仅保证原子性,也隐式地保证了可见性(在释放锁前会把工作内存修改同步回主内存,在获取锁后会清空工作内存,强制从主内存读取)。
  • 使用 final 关键字修饰的变量是不可变的,虽然不是直接解决可见性问题,但其不可变性天然保证了多线程环境下的一致性(因为值不会变)。

原子性

定义:一个或一系列操作是不可分割的,要么全部成功执行,要么全部不执行(失败),在执行过程中不会被任何其他线程的操作打断。它保证了指令的完整性,不会受到线程上下文切换的影响。

JMM 对原子操作的规则:JMM 定义了 8 个内存交互操作,这些操作本身是原子的。但为了保证更高级别操作的原子性,JMM 规定了一些使用规则:

  1. readload 必须成对出现,不能单独执行。storewrite 也必须成对出现。
  2. 线程不能丢弃 assign 操作,即对工作内存变量的修改必须同步回主内存。
  3. 线程不能无原因地将工作内存的数据同步回主内存(必须是发生了 assign 操作)。
  4. 新变量只能在主内存中创建。线程在使用或存储变量前,必须先进行 assignload 操作(即从主内存初始化)。
  5. 一个变量在同一时刻只允许一个线程对其执行 lock 操作。lock 操作可以被同一线程重复执行多次(可重入),但只有执行相同次数的 unlock 操作后,变量才会被真正解锁。lockunlock 必须成对出现。
  6. 对变量执行 lock 操作会清空工作内存中此变量的值,强制线程在使用前重新从主内存加载。
  7. 不允许对未被 lock 操作锁定的变量执行 unlock 操作,也不允许解锁被其他线程锁定的变量。
  8. 对变量执行 unlock 操作之前,必须先把此变量同步到主内存中(执行 storewrite 操作)。

解决方案

  • 使用 synchronized 关键字:保证代码块或方法的原子性。
  • 使用 java.util.concurrent.atomic 包下的原子类:如 AtomicIntegerAtomicLong,它们通过 CAS 操作实现原子性。
  • 使用 java.util.concurrent.locks.Lock 接口的实现类:如 ReentrantLock

有序性

定义

  • 本线程内观察(有序):无论底层指令如何重排序,程序都会保证最终执行结果与你写的代码逻辑一致,让你感觉是按顺序执行的。
    • 就像你在家穿衣服,你是先穿袜子还是先穿裤子,你自己并不在意,只要最后你“穿整齐了出门”这个结果是对的,你就觉得自己的逻辑是有序的。
  • 线程间观察(无序):在没有同步约束时,一个线程为了性能而产生的指令重排会被另一个线程察觉,从而看到逻辑颠倒或未完成的中间状态。
    • 你在窗外看发货员。发货员(线程 A)为了效率,先在系统里点了“已发货”,然后再去打包快递。你(线程 B)看到系统显示“已发货”,兴冲冲去取件,结果发现包裹还是空的。在你的视角里,发货员的操作顺序“乱了”。

指令重排序:为了提高性能,编译器和处理器会对指令进行优化,调整其执行顺序,只要不改变单线程内的执行结果,这种重排序就是允许的。

重排序的阶段:源代码 → 编译器优化的重排 → 指令并行的重排 → 内存系统的重排 → 最终执行指令

  • CPU 流水线:现代 CPU 支持多级指令流水线(如经典的“取指令、指令译码、执行指令、访存取数和结果写回”五级流水线)。CPU 可以在一个时钟周期内,同时运行五条指令的不同阶段。这本质上不能缩短单条指令的执行时间,但通过并行处理提高了指令的吞吐率。
  • 数据依赖性:处理器在进行重排序时,必须考虑指令之间的数据依赖性。如果一个指令的执行结果是另一个指令的输入,那么这两个指令不能被重排序。
    • 单线程环境:即使存在指令重排,由于数据依赖性的保证,最终执行结果和代码顺序的结果是一致的。
    • 多线程环境:线程交替执行时,由于编译器和处理器的优化重排,一个线程可能观察到另一个线程的操作顺序与代码顺序不一致,这可能导致意想不到的错误。

示例:著名的“双重检查锁定(DCL)”单例模式中,如果不使用 volatile,就可能因为指令重排序导致问题。

java
class Singleton {
    private static Singleton instance; // 考虑不使用 volatile 的情况

    private Singleton() {}

    public static Singleton getInstance() {
        if (instance == null) { // 第一次检查
            synchronized (Singleton.class) {
                if (instance == null) { // 第二次检查
                    // 这行代码可能发生指令重排序
                    // 1. memory = allocate() 分配内存
                    // 2. ctorInstance(memory) 初始化对象
                    // 3. instance = memory 设置 instance 指向内存
                    // 如果重排序为 1 -> 3 -> 2,那么在 3 执行后,另一个线程可能看到非空的 instance,
                    // 但此时对象还未完全初始化,导致访问到半初始化对象。
                    instance = new Singleton(); 
                }
            }
        }
        return instance;
    }
}

解决方案:使用 volatile 关键字:volatile 除了保证可见性,还能禁止指令重排序(特别是针对 volatile 变量的读写操作)。在 DCL 中,private static volatile Singleton instance; 可以解决重排序问题。

贡献者

The avatar of contributor named as LI SIR LI SIR

页面历史