初学JMM见解,认识内存可见性和指令重排序问题

1.初识JMM

虽然“JMM”这个名字听起来像是“JVM”的兄弟,实际上两者是完全不同的体系,职责也截然不同。

  • JVM(Java Virtual Machine):它是一台虚拟的“执行机器”,负责解释执行 Java 字节码,让 Java 程序能在各个平台运行。

  • JMM(Java Memory Model,Java 内存模型):它并不是一个具体的组件,而是一套规范和规则,规定了多线程环境下变量的读写规则、可见性、有序性、原子性等问题。JMM 用于协调线程之间如何安全地共享数据。

JMM 解决的是一个很实际的问题:多个线程到底是怎么“看见”共享变量的?

在没有 JMM 统一规范之前,不同平台、不同 CPU、不同编译器可能在处理线程之间的共享数据时表现不一致,容易出现千奇百怪的并发 bug。因此 JMM 的目标就是——

“让 Java 程序在任何平台、任何硬件下运行时,多线程的行为都是一致且可预期的。”

2.JMM结构 

Java内存模型规定所有的变量都存储在主内存中,包括实例变量,静态变量,但是不包括局部变量和方法参数。局部变量和方法参数直接存储在线程私有的栈帧中(属于工作内存的一部分),它们天生就是线程隔离的,不存在“从主内存拷贝”的过程,因此也不存在多线程可见性问题。每个线程都有自己的工作内存,线程的工作内存保存了该线程用到的变量和主内存的副本拷贝,线程对变量的操作都在工作内存中进行线程不能直接读写主内存中的变量

小知识点: 

1.CPU先从Cache中查找数据,如果找不到,再去主存(Memory)中取,最后才去硬盘。Cache的作用是加速数据访问,减少CPU等待时间。如果CPU频繁访问某些数据,使用Cache可以提高效率。

2.高速缓存Cache它不是寄存器,而是一种介于CPU和内存之间的高速存储器。

3.当你运行一个程序时,操作系统会把它从硬盘(如C盘或D盘)加载到内存(Memory)中,这样CPU才能快速访问。

1.可见性
一个线程对共享变量值的修改,能够被其他线程及时地看到。

由于每个线程都有自己的工作内存,这些工作内存中存储的相当于同一个共享变量的一份副本。因此,线程1修改自己的工作内存后,线程2的工作内存不一定会察觉到这一修改。

假设主内存中某个变量 a = 10,当线程1 和线程2 启动后,它们各自从主内存中读取了 a 的值到自己的工作内存中。此时线程1 的工作内存和线程2 的工作内存里都保存着 a = 10

接着,如果线程1 将自己工作内存中的 a 修改为 20,由于线程1 修改的是自己本地的副本,并不会立刻把结果写回主内存,所以:

线程2 完全感知不到线程1 已经修改了 a 的值,它仍然看到的是 a = 10

这就造成了所谓的 “内存不可见性”问题:一个线程对共享变量的修改,对另一个线程来说可能是不可见的

3.volatile如何解决内存可见性问题

volatile 保证可见性,靠的是底层 JIT 编译器在写操作中生成带 lock 前缀的汇编指令,这个指令通过缓存一致性协议,确保变量修改对所有 CPU 可见。

加 volatile 之后发生了什么?

当你加上:

private static volatile boolean isRunning = true;

当一个变量被声明为 volatile 时,Java 会保证:

  1. 每次读取该变量时,都直接从主内存加载最新值,不使用寄存器或线程本地缓存的旧数据;

  2. 每次写入该变量时,都会立即刷新到主内存,确保其他线程能够看到最新值;

  3. 同时,JVM 会在读写 volatile 变量的操作前后插入特殊的“内存屏障”指令,防止编译器和处理器对这些操作进行重排序;

这意味着线程在访问 volatile 变量时,能获得最新的值,并且程序执行顺序不会因为优化被打乱。

因此,在你的循环中,线程1会每次都从主内存读取最新的 isRunning 值,当线程2把它改成 false,线程1就能立即看到,从而跳出循环。

总结

1. 可见性 当一个线程修改了volatile变量的值,对于其他线程新值是立即可见的

2.有序性 精致指令重排序优化

3.不保证原子性: volatile不保证 复合操作 比如i++的原子性

4.指令重排序

在上面我们分析了,为什么加上了volatile关键字,就能确保各个线程都能看到共享变量,其中一个重要的原因是:JVM为变量保驾护航,防止指令重排序 ,究竟什么是指令重排序呢?

水果摊的分布,可能和我们水果清单的顺序不一致,如果强行按照初始的水果清单来购买,肯定会重跑很多次,如果能调整一下购买的顺序,按照图示的顺序就会轻松很多。

指令重排序就是编译器或处理器为了提升程序执行效率,在不影响单线程程序语义的前提下,调整指令执行的顺序

这种优化让CPU更好地利用流水线、缓存和执行单元,提高性能。


如果没有 volatile,这种重排序在多线程环境下可能导致某些操作看似乱序执行,引发难以发现的并发问题。

而加上 volatile 后,JVM 会插入必要的内存屏障,禁止某些关键指令被重排序,保证共享变量的写入对其他线程是可见的,避免并发混乱。

4.1 new 的底层过程

在 Java 中,执行 new Object() 实际上大致分为三个步骤(JMM 视角):

  1. 分配内存空间

  2. 调用构造器,初始化对象

  3. 返回对象引用


4.2 问题:重排序可能打乱顺序

编译器或 CPU 可能会把 步骤 2 和步骤 3 重排序

  • 正常顺序(没重排序): 1 → 2 → 3

    • 内存分配完成,对象初始化完毕,再把引用返回。

  • 可能的重排序顺序: 1 → 3 → 2

    • 先返回引用(指向一块尚未初始化完成的内存),然后才初始化对象。

为什么CPU要冒着风险去进行1-3-2这样的重排序?

计算机执行指令时,最重要的目标就是 最大化流水线和缓存利用率。 在 new 的三个步骤里:

  1. 分配内存(很快)

  2. 调用构造方法,初始化对象(可能比较慢,要执行很多赋值/逻辑)

  3. 把引用赋值给变量(只是一次简单的赋值,很快)

对 CPU 来说,如果按照 1 → 2 → 3 执行:

  • 它必须等 初始化(步骤 2)完成 才能执行 步骤 3

  • 这中间 CPU 的流水线、寄存器可能就会“闲着”。

而如果 允许 1 → 3 → 2

  • 分配内存后,就可以立刻把引用存到变量里(很快的操作)。

  • 然后慢慢去做初始化(步骤 2)。

  • CPU 没有“等待”,流水线利用率更高,程序整体可能更快。


4.3 程序举例(经典 DCL 单例)

public class Singleton {
    private static Singleton instance;
​
    private Singleton() {}
​
    public static Singleton getInstance() {
        if (instance == null) {                // 第一次检查
            synchronized (Singleton.class) {
                if (instance == null) {        // 第二次检查
                    instance = new Singleton(); // 可能被重排序
                }
            }
        }
        return instance;
    }
}
4.3.1 可能的问题:
  • 假设线程 A 执行 instance = new Singleton();

  • 如果发生重排序,步骤可能是:

    1. A 分配内存

    2. A 把引用赋值给 instance(但对象还没初始化完!)

    3. A 开始执行构造方法

  • 同时线程 B 进入 getInstance(),发现 instance != null,直接返回。

  • 但这时 instance 可能还是“半初始化状态”,使用它可能抛出异常。


4.3.2  解决方案:加 volatile
private static volatile Singleton instance;
  • volatile 禁止了 步骤 2 和步骤 3 的指令重排序

  • 保证一个线程看到 instance != null 时,对象一定已经初始化完成。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值