JMM内存模型

JMM内存模型

CPU内存模型

CPU 缓存模型示意图

可以看到CPU有三级缓存,都是用于解决CPU处理速度和访问速度不匹配的问题,因为硬盘数据访问速度过慢了,所以建立了缓存来提升性能。打开你的任务管理器中的性能,你就可以看到你的电脑上一般都会有L1,L2,L3缓存,越上面靠近寄存器的越快,但是缓存大小越小,造价越贵。

image-20230721091359689

一般CPU Cache的工作方式都是先建立一个数据副本也就是拷贝一份数据到缓存当中进行处理计算,最后写回CPU的主存中,但是这样多个线程并发处理这个数据的时候,就会导致主存缓存中数据不一致的情况,最后写回主存也就可能会产生数据不一致的问题。

CPU为了解决内存缓存数据不一致的问题一般是通过定制缓存一致性协议比如常见的MESI

缓存一致性协议

他的工作方式是,当你拷贝数据到缓存中后,处理完需要立即写回主存,他一定会经过总线,总线会开启一个嗅探的机制,对于监听这个数据的所有线程,他会立即让缓存失效,而线程获取数据的时候发现缓存失效了,就会重新去主存读取数据。

JMM

JMM和JVM可以完全是两种不同的概念,JMM可以认为是java多线程内存模型,他是在并发、多线程的前提下的。

JMM跟cpu缓存模型类似,是基于cpu缓存模型来建立的。

image-20230721092600647

你会认为类中的成员变量,在多个线程中是共享的,其实他只是逻辑上共享,实际线程还是需要去拷贝一份数据到他自己的工作内存中运行(提升性能),而线程什么时候把数据写回主存,这件事是不一定的。有下面这几种情况,会把数据写回主存

  1. 如果是被volatile修饰的,他会修改后立马写回主存,然后总线嗅探机制会失效缓存。
  2. 进行synchronized的代码块或者方法,他会先把共享变量,重新从主存中拷贝一份进入工作内存然后进行操作。

这里来举例。

    private static boolean flag = false;
    //private static volatile boolean flag = false;
    public static void main(String[] args) throws InterruptedException {
            new Thread(() -> {
                while (!flag) {
                    //System.out.println(flag);
                }
                System.out.println("flag is true");
            }).start();

            Thread.sleep(2000);

            new Thread(() -> {
                System.out.println("set flag true");
                flag = true;
                System.out.println("set flag true success");
            }).start();
    }

等待了好久好久,执行结果如下:

image-20230721094201468

可以看到flag变量一直没有被修改。我们尝试在里面打印一下这句话,执行结果如下:

image-20230721114439944

诶为什么打印了一句话就能把变量更新了,可以想想System.out.println性能低原因是什么,就是因为里面会一直获取锁,所以加锁后,进入方法区就会重新加载主存的数据。

现在我们注释打印语句,把变量加上volatile的关键字,然后看看执行后是什么效果。

image-20230721115026620

为什么加了这个关键字之后,另一个线程就能感知到变量改变了呢?其实和cpu的缓存一致性协议类似,就是当数据发生改变后,会立刻写回主存,然后通过总线嗅探的机制,去使监听该变量的缓存失效。

JMM内存模型底层八大原子操作

image-20230721115544899

当数据修改之后,线程就会对数据进行store操作后进入主存write即给主存中的变量赋值,而进入主存的过程中需要经过总线,

image-20230721115811750

Volatile缓存可见性实现原理

Volatile加上之后,缓存数据变动就会立刻写回主存,到底他在底层是怎么实现的呢?

底层实现主要是通过汇编lock前缀指令,它会锁定这块内存区域的缓存(缓存行锁定)并回写到主内存

1A-32和Intel64架构软件开发者手册对lock指令的解释:

  1. 会将当前处理器缓存行的数据立即写回到系统内存。

  2. 这个写回内存的操作会引起在其他CPU里缓存了该内存地址的数据无效(MES协议)

  3. 提供内存屏障功能,使lock前后指令不能重排序

指令重排序

为了提升执行速度/性能,计算机在执行程序代码的时候,会对指令进行重排序。

Java 源代码会经历 编译器优化重排 —> 指令并行重排 —> 内存系统重排 的过程,最终才变成操作系统可执行的指令序列。

指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致 ,所以在多线程下,指令重排序可能会导致一些问题。

指令是随便排的吗,那岂不是很危险,其实这里遵循两个规范

as-if-serial

不管怎么排序,单线程程序执行的结果是不会改变的,比如说

//这两句话,调换顺序后,对于结果没有任何的影响,那么就可能会进行指令重排序
a = 1;
b = x;
y = a + b;

//这个如果调换顺序后,语义就会发生变化,所以就会禁止重排序
a = x;
x = 1;

happens-before

表示语义不能被改变,举一个例子,lock unlock lock unlock,如果这四个命令重排序之后,如果变为了lock lock unlock unlock,是不是语义完全变了,所以这个lock 和unlock 是不能重排序的。

具体的一些规则如下:

  1. 程序顺序规则:一个线程内,按照代码顺序,书写在前面的操作 happens-before 于书写在后面的操作;
  2. 解锁规则:解锁 happens-before 于加锁;
  3. volatile 变量规则:对一个 volatile 变量的写操作 happens-before 于后面对这个 volatile 变量的读操作。说白了就是对 volatile 变量的写操作的结果对于发生于其后的任何操作都是可见的。
  4. 传递规则:如果 A happens-before B,且 B happens-before C,那么 A happens-before C;
  5. 线程启动规则:Thread 对象的 start()方法 happens-before 于此线程的每一个动作。

如果两个操作不满足上述任意一个 happens-before 规则,那么这两个操作就没有顺序的保障,JVM 可以对这两个操作进行重排序

对于指令重排序问题,很常见的一个场景就是阿里巴巴开发规范中的DCL(双重检查锁下的指令重排序,单例模式延迟加载方案),可能会因为指令重排序导致空指针的问题。以下是单例的模式的代码:

public class LazySingleton {
    private LazySingleton() {

    }

    /**
     * 注意需要添加volatile的关键字,保证指令有序性,防止指令优化带来的空指针问题
     */
    private static volatile LazySingleton lazySingleton;

    public static LazySingleton getInstance() {
        if (lazySingleton == null) {
            synchronized (LazySingleton.class) {
                if (lazySingleton == null) {
                    lazySingleton = new LazySingleton();
                }
            }
        }
        return lazySingleton;
    }
}

为什么会有空指针的问题,这里涉及到对象new的一个过程,一个对象的new,其实会涉及到非常的多的步骤,new在编译之后的jvm执行的代码就是以下的这些步骤,可以看一下这个步骤。

image-20230721143243780

对于一个简单成员变量的初始化操作。

Integer i = 6000;

底层其实是先会初始化这个变量,即分配内存后初始化零值,i = 0,所以i这个变量也就不会null了,但是实际里面是没有数据的,可以认为内部其实还是为null,最后才会把6000进行init方法的调用,也就是对这个变量设置为6000,最后才会把这个内存地址赋值给i这个成员变量。

注意这里三个步骤,一是初始化零值,二是执行init方法,三是对象内存地址的赋值

如果当发生了指令的重排序,顺序变为一、三、二、那么变量在判断if == null的时候,就会false,但是其实内部是null的,那么也就会出现一个空指针的问题。

那这么底层的实现细节,我们要怎么办呢?对于指令重排,我们一般插入内存屏障的方式去避免并发安全问题,而volatile关键字可以很方便地为我们插入内存屏障内存屏障其实就是一个标记,如果系统在进行重排的时候,发现了这个标记,那么他就不会进行重排

内存屏障

image-20230721144203599

image-20230721144337253

而volatile就会对变量设置内存屏障,然后底层也是通过添加lock命令实现的。

总结

  • Java 是最早尝试提供内存模型的语言,其主要目的是为了简化多线程编程,增强程序可移植性的。
  • CPU 可以通过制定缓存一致协议(比如 MESI 协议open in new window)来解决内存缓存不一致性问题。
  • 为了提升执行速度/性能,计算机在执行程序代码的时候,会对指令进行重排序。 简单来说就是系统在执行代码的时候并不一定是按照你写的代码的顺序依次执行。指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致 ,所以在多线程下,指令重排序可能会导致一些问题。
  • 你可以把 JMM 看作是 Java 定义的并发编程相关的一组规范,除了抽象了线程和主内存之间的关系之外,其还规定了从 Java 源代码到 CPU 可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范,其主要目的是为了简化多线程编程,增强程序可移植性的。
  • JSR 133 引入了 happens-before 这个概念来描述两个操作之间的内存可见性。

参考

JavaGuide——JMM

JMM内存模型

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇