JMM介绍

        JMM是基于CPU缓存模型实现的一套Java内存管理程序,都在于解决多核情况下的缓存同步问题。它定义了主存和工作内存的抽象概念,底层对应着CPU寄存器、高速缓存、RAM、CPU指令优化等。

 

 CPU缓存模型

JMM特性

可见性

定义

        当一个线程写入操作刷回主存时会触发mesi协议,即广播其它线程作废相关变量的缓存行,当其它线程需要读取变量时会主动从主存获取最新值。

作用

        保证跨线程协作时实时读取最新数据。

原理

【注:】上图主存指的是所有线程共享的数据,比如公共静态成员变量、公共成员变量,而工作内存是线程自己私有的数据。

有序性

定义

        JIT编译器按照代码编辑顺序依次执行,但是出于优化考虑,JIT可能会在语义正确的前提下指令重排序。JMM 提供了一套 happens-before 规则用来禁止指令重,强制保障了有序性。

作用        

        编码执行顺序可能会被编译器或者CPU进行指令重排序,这原是编译或者CPU执行的高效策略,但可能导致其它线程观察结果不准确导致出现一些运行异常,因此多线程协作时禁止指令重排序。

案例1

        DCL(double check locking),instance变量通过volatile的修饰获取了可见性和有序性,代码如下。

    public class Singleton {
        private static volatile Singleton instance;
    
        public static Singleton getInstance() {
            if (instance == null) {  // 第一次检查
                synchronized (Singleton.class) {
                    if (instance == null) {  // 第二次检查
                        instance = new Singleton();  // 创建对象
                    }
                }
            }
            return instance;
        }
    }

    没有volatile修饰,instance = new Singleton()执行指令:

    1. 分配内存:在堆里给对象找块空地。
    2. 赋值引用:把内存地址赋给 instance。
    3. 初始化对象:调用构造方法,填充字段值。

    拥有volatile修饰,instance = new Singleton()执行指令:

    1. 分配内存:在堆里给对象找块空地。
    2. 初始化对象:调用构造方法,填充字段值。
    3. 赋值引用:把内存地址赋给 instance。
    4. store-store屏障:保障赋值引用之前的指令发生在赋值引用之前。
    5. store-load屏障:作废其它线程的instance变量缓存并将当前线程的instance变量缓存回更到主存。

    案例2

            读写标志位,通过添加volatile修饰符保证了可见性和有序性;

    // 全局变量
    int config = 0;      // 配置数据
    boolean volatile ready = false; // 初始化完成标志
    
    // 线程 A (初始化线程)
    void init() {
        config = 123;      // 1. 写入配置数据
        ready = true;      // 2. 标记初始化完成
    }
    
    // 线程 B (使用线程)
    void use() {
        if (ready) {       // 3. 检查标志
            doSomething(config); // 4. 使用配置
        }
    }

            可见性保证了写线程变更标志位后读线程可以立刻检测;

            有序性保证了线程中代码逻辑能够按序执行;

    【注:】如果案例1和案例2不是多线程而是单线程,则无需担心可见性和有序性的问题;

    原子性

    定义

            一个操作(对于变量的读写操作具有原子性,包括cas写操作)或一组操作(通常是被锁保护的临界区)可以在并发安全的条件下执行。

    分类

            1.指令层面的原子性(天然具备):单个指令往往具有原子性特征,例如赋值、cas操作或者读取操作,CPU对这类指令的执行不会发生上下文切换,直到底层全部执行完成;

            2.代码层面的原子性:由多个指令组成的复杂指令,例如i++、new Object() 或者 一堆代码逻辑,这类复杂指令在执行当中虽然会上下文切换但是可以用锁实现并发安全。

    【注:】

            JMM原子性和数据库原子性定义不同,数据库强调要么执行完成,要么回退到原始状态;

            要仔细识别一条语句是否真的是原子性操作:例如i++,i++属于非原子性操作,是int tmp = i;i = tmp+1;的简化,这个流程分为了读-改-写,一共三个原子性操作;

            CPU指令集支持CAS操作,是指令层面的原子性;

            CAS操作流程为加缓存锁(硬件锁)—>数据校验—>数据变更—>刷回主存—>解缓存锁;

            普通赋值操作流程就只有数据变更—>刷回主存;

            稍提一嘴,硬件层面的阻塞是一种运行态下的耗时等待;        

    happens-before

    定义

    happens-before 是 JMM 定义的一种偏序关系,用来保证多线程环境下操作的内存可见性:

    • 如果线程 A happens-before 线程 B,那么 A 的执行结果(包括对共享变量的修改)对 B 是可见的,并且 A 的执行顺序排在 B 之前。

    • 这种关系不关心具体的时间点,只保证逻辑上的可见性。

    内存屏障

    定义

            内存屏障(Memory Barrier)是Happens Before规则的具体实现,用于控制指令的执行顺序,确保在多线程或多核环境下,内存操作的可见性和顺序性符合预期。它的核心作用是防止编译器和处理器对指令进行重排序,避免由此引发的并发问题。

    分类

    • Store-Store屏障:确保屏障前的写操作先于屏障后的写操作完成;
    • Store-Load屏障:确保屏障前的写操作先于屏障后的读 / 写操作完成;
    • Load-Load屏障:确保屏障前的读操作先于屏障后的读操作完成;
    • Load-Store屏障:确保屏障前的读操作先于屏障后的写操作完成;

    单线程(自产自销)

            单线程无需关心JMM特性,仅有线程自身操作数据且不需要跨线程协作,不需要可见性和原子性,指令重排只用于提升性能不会无脑影响语义,因此也不需要禁止指令重排(例如单线程创建实例时候可能发生指令重排,但是一定会在实例使用之前完成创建,以保证实例正常使用);

    volatile(一写多读)

    保守策略

            源自 JSR-133 规范以及 Doug Lea 的《JMM Cookbook》。它为 volatile 变量的读写定义了一组最基本的、无关平台的内存屏障集合,以保证在任何内存模型(包括弱模型)上都能正确实现 JMM 的 happens-before 语义。规则如下:

    • volatile 写之前:StoreStore 屏障(防止前面的普通写与 volatile 写重排);

    • volatile 写之后:StoreLoad 屏障(防止 volatile 写与后面的读/写重排,并保证可见性);

    • volatile 读之后:LoadLoad + LoadStore 屏障(防止后面的普通读/写与 volatile 读重排);

    屏障分类有序性可见性
    Store-Store

    确保volatile写和之前写的有序性 

    Store-Load

    确保volatile写操作和后续读、写的有序性

    刷新当前处理器的写缓冲区到主存,作废其它线程当前volatile变量缓存;

    其它线程前往主存读取变量;

    Load-Load

    确保volatile读操作和后续读的有序性

    volatile读会强制后续的读操作从主存获取,以此实现happen-before这种偏序关系

    普通读会通过mesi获取最新数据,没有再使用本地缓存

    Load-Store确保volatile读操作和后续写的有序性

            

    volatile通过以上的四个内存屏障工具实现了有序性和可见性。

    volatile使用场景

            通过保守策略设置的内存屏障,可以反向分析出volatile变量仅作用于一写多读的多线程处理场景,volatile 变量在这种场景下用来做处理开关。

    JIT 指令重排分析

            保守策略/模型中,volatile变量的读和写会自动添加相应的内存屏障来禁止JIT的指令重排优化。

       可是volatile 写之前没有 LoadStore,volatile 读之前没有loadload、storeload。JIT 会考虑在正确执行语义的前提下对这些场景进行重排指令优化,记住是保证正确语义的前提下执行,例如:

    1. 数据依赖
             如果普通写的结果(写入的某个值)被 volatile 读所依赖(例如:a = 1; int r = v + a; 或者 if (a == 1) r = v;),那么编译器为了正确性,不会重排序这两个操作。但注意这里的依赖是 普通写 → 后续的普通读(a 的读),而不是对 v 的 volatile 读。volatile 读本身只读取 v,不依赖 a 的值。
              所以,除非 volatile 读的代码逻辑中使用了被写入的普通变量(例如读 v 后再用 a 做计算),否则两者之间没有数据依赖,重排序是允许的。

    2. 控制依赖
             如果普通写发生在条件分支中,而 volatile 读的条件依赖于该普通写的结果,那么编译器也可能为了正确性保持顺序。但这种依赖仍然是普通写 → 条件判断 → volatile 读,而不是直接针对 volatile 读的地址。
             即便如此,JMM 允许这种重排序,因为 volatile 读的语义不要求禁止这类重排序。实际上,JIT 在实际优化中可能会保留顺序(因为太激进的重排序可能影响性能或正确性),但规范上允许重排序。

    3. JMM 的允许 vs. 实际优化

      • 规范上:普通读 / 写 与后续 volatile 读之间,普通读 / 写 与volatile 写之间没有顺序保证,JIT 可以自由重排序。

      • 实践中:大多数 JIT 对普通写和 volatile 读不做主动重排序,因为 volatile 读本身代价较高(可能触发缓存同步),重排序带来的收益有限。但这不是规范要求,只是实现选择。

      • 关键点:你不能依赖“JIT 会判断普通写必须在 volatile 读之前进行”,因为规范不保证这一点。如果你确实需要这个顺序,必须使用其他同步手段(如 synchronized、volatile 写、Lock)。

    锁(多写多读)

    1. 使用volatile修饰符可以实现可见性、有序性,适用于仅有一个线程修改,其它线程读取的场景(因为无原子性,必须保证并发安全);
    2. 如果还要实现原子性则可以加锁实现,例如synchronized或者Unsafe(cas指令,例如AtomicInteger的实现);

    【注:】

    1. synchronized允许代码块的指令重排,只要不破坏单线程语义,就允许。解锁会设置屏障,这保证了跨线程场景下持相同锁的代码块的执行的整体有序性;
    2. synchronized有可见性,加锁后可以保证变量读的可见性(主存加载),解锁会设置屏障,这保障普通变量写刷回主存,但是普通变量必须在持锁条件下才生效可见性,无锁条件下将失去可见性;
    3. synchronized有原子性,加锁后可以保证同一时刻仅有线程自身运行;
    4. 多线程协作必须关心JMM特性,原因是需要跨线程协作,其它线程操作数据不能用旧数据,并且还要分析是否需要保证数据的并发安全,这需要可见性、原子性;虽然指令重排提升了性能,但会导致其它线程根据可见性得出的观察结果不准而发生一些异常情况,这需要有序性(禁止指令重排);
    Logo

    AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

    更多推荐