JMM内存模型

2024-01-07 17:45:02
JMM
JMM内存模型
  • 因为CPU的运算速度远高于内存,所以CPU的运行并不是直接操作内存,而是在CPU和内存之间设置了高速缓存,也就是CPU的一二三级缓存
  • 然而各个型号的CPU和内存包括操作系统都有可能存在差异,所以JVM规范中定义了一种java内存模型 JMM ,来屏蔽掉各种硬件和操作系统的内存访问差异
  • JMM 只是一种规范,描述了程序中各个变量的读写方式,并决定了一个线程对共享变量的写入何时变成另一个线程可见
  • JMM 的关键技术点是多线程的:原子性、可见性和有序性

原子性

  • 一个或多个操作,要么全部执行,要么全部不执行,并且执行过程中不会被任何因素打断
  • 在 java中,对基本数据类型的变量的读取和赋值操作都是真正的原子性(long、double例外),如果想要更大返回的原子性操作,就需要使用synchronized或者lock 通过加锁来实现,
  • 但是加锁其实是保证了一次只有一个线程执行同步代码,保证了程序的最终正确性,使程序不受原子性问题的影响,而不是真正的原子性操作

可见性

  • 当一个线程修改了某一个共享变量的值,其他线程是否能够立刻知道该变更

有序性

  • 编译器编译成机器码指令后,只要程序的最终结果与它顺序化执行的结果相等,那么指令的执行顺序可以与代码的顺序不一致,也就是指令可能会被重排序
  • 因为 JVM 会根据处理器的特性适当的对机器指令进行重排序,使机器指令更能符合CPU的执行特性,更好的发挥机器的性能
  • 但是执行重排只能保证串行语句语义一致,无法保证多线程情况下也能正确执行,所以在多线程情况下程序就会可能发生乱序的情况

共享变量的读取过程:

  • JVM运行程序的实体是线程,每个线程在创建时都会创建一个工作内存,工作内存是线程私有的
  • JMM规定所有的变量都存储在主内存当中,线程想要操作主内存中的共享变量 ,不管是读还是写,都需要先把共享变量拷贝到线程本地的工作内存,形成共享变量的副本,操作完成后再写回主内存
  • 而且线程之间是不能直接访问对方工作内存中的变量,线程间变量值的传递必须通过主内存来完成
    • 所以多线程情况下,都对共享变量进行修改,就很可能出现脏读,例如A和B同时读取到了共享变量到本地内存,又各自完成了运算,A先写回,B在写回,就会丢失A的操作

在这里插入图片描述

先行发生原则

多线程先行发生原则:happens-before

  • 在JMM中,如果一个操作执行的结果需要对另一个操作的可见性或者代码的重排序,那么这两个操作之间必须存在 happens-before原则
  • 如果一个操作 happens-before (先行发生)另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前
  • 无论AB是否在同一个线程里,A先行发生于B,那么A发生过的事件对于B来说是可见的
  • 两个操作之间存在 happens-before 关系,并不意味着一定要按照 happens-before 原则制定的顺序来执行,如果重排序之后的执行结果与按照 happens-before 关系来执行的结果一致,那么这种重排序并不非法

先行发生原则的八个原则:

  • 次序规则:一个线程内,按照代码顺序,写在前面的操作先行发生于写在后面的操作
  • 锁定规则:一个unlock操作先行发生于后面对同一个锁的lock操作,也就是后面的加锁操作,必须等到前面锁释放了在才能执行
  • volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作,从时间上的先后来说,前面的写对后面的读是可见的
  • 传递规则:如果操作A先于操作B,操作B先于操作C,那么操作A一定先于操作C
  • 线程启动规则:线程的start方法,先行发生于此线程的每一个动作,也就是start方法是线程的第一个操作
  • 线程中断规则:
    • 对线程的interrupt方法的调用先行发生于被中断线程的代码检测到中断事件的发生
    • 也就是只有先调用过 interrupt方法设置中断标记 ,才有可能被检测到
  • 线程终止规则:线程的所有操作都先行发生于对此线程的终止检测 (可以使用 isAlive 等手段检测线程是否已经终止执行)
  • 对象终结规则:一个对象的初始化完成,先行发生于它的 finalize 方法的开始
volatile
  • volatile 是一种轻量级的同步手段,一个共享变量被volatile修饰之后,可以保证可见性和有序性,但是无法保证 JMM 的原子性

volatile 使用场景

  • 单一赋值,如果是 i++就不可以

  • 状态值,例如消息队列的消费者,队列没有消息可消费就去休眠

  • 使用volatile 减少锁,例如使用volatile 来代替对读操作的加锁

  • 双检测单例(DCL)的优化,

    • 如果不加volatile,因为初始化一个对象分为:分配内存空间,初始化对象,将对象指向分配的内存空间这三步,

    • 某些编译器可能会把第二和第三步重排序,多线程情况下,就可能导致线程可能获得一个未初始化的实例

    • 所以需要给变量加上 volatile,延迟对象的初始化,禁止指令重排序

可见性是指:

  • JMM规定所有的变量都存储在主内存当中,线程想要操作主内存中的共享变量 ,不管是读还是写,都需要先把共享变量拷贝到线程本地的工作内存,形成共享变量的副本,操作完成后再写回主内存
  • 当写一个被 volatile 修饰的变量时,JMM 会把该线程本地内存中的共享变量的值立即刷回主内存中,读一个被 volatile 修饰的变量时,JMM 会把该线程本地内存设为无效,重新读取主内存中最新的共享变量
  • 也就是一个变量被 volatile 修饰后,写操作回立刻刷新到内存,读操作是直接从内存中读取,这样任意线程对这个变量进行修改,其他线程都能立马可见

不加volatile ,对变量的修改,其他线程不可见,下面代码不加volatile 会卡死,加上才能正常执行

    //默认值为 true
    private static Boolean iSValid = true;
    public static void test() throws Exception {
        new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + "开始执行,此时iSValid为:" + iSValid);
            //线程1读取的变量iSValid值为初始值 true,即使后续被变更,线程1也不知道,所以这里会卡死
            //加上volatile 之后,每次读取都会去主内存读取最新的值
            while (iSValid) {
            }
            System.out.println(Thread.currentThread().getName() + "执行结束,此时iSValid为:" + iSValid);
        }, "线程1").start();

        Thread.sleep(1000);
        //并且对工作存中变量副本的修改,会立即刷新到主内存
        iSValid = false;
        System.out.println("主线程执行结束,此时iSValid为:" + iSValid);
    }

volatile

  • 加上volatile 之后,每次读取都会去主内存读取最新的值
  • 并且对工作存中变量副本的修改,会立即刷新到主内存

有序性

  • 编译器编译成机器码指令后,只要程序的最终结果与它顺序化执行的结果相等,那么指令的执行顺序可以与代码的顺序不一致,也就是指令可能会被重排序
  • 因为 JVM 会根据处理器的特性适当的对机器指令进行重排序,使机器指令更能符合CPU的执行特性,更好的发挥机器的性能
  • 但是执行重排只能保证串行语句语义一致,无法保证多线程情况下也能正确执行,所以在多线程情况下程序就会可能发生乱序的情况
  • volatile 通过内存屏障禁止了指令重排序,保证了有序性

volatile 变量重排序规则

  • volatile 读之后的操作,禁止重排到volatile之前
  • volatile 写之前的操作,禁止重排到volatile之后
  • volatile 写之后的 volatile读,禁止重排序
  • 其他情况可以重排

在这里插入图片描述

volatile 无法保证原子性

  • 在 Java 中,原子性是指一个操作是不可中断的,要么都执行要么都不执行。

  • 但是 volatile 修饰的变量,只是保证了从主内存加载到工作内存的值是最新的,并不能保证对变量的操作是原子性的

  • 变量的写操作和读操作之间是可以被中断的,也就是在读取或者修改 volatile 变量的过程中,其他线程可能会对这个变量进行修改,

  • 所以在多线程环境下,对线程的操作结果可能会丢失,想要在多线程情况下修改主内存的共享变量必须加锁来保持同步

例如:

  • 一个变量i被volatile修饰,两个线程想对这个变量修改,都对其进行自增操作也就是i++,i++的过程可以分为三步,首先获取i的值,其次对i的值进行加1,最后将得到的新值写会到缓存中。
  • 线程A首先得到了i的初始值100,但是还没来得及修改,就阻塞了
  • 这时线程B开始了,它也得到了i的值,由于i的值未被修改,即使是被volatile修饰,主存的变量还没变化,那么线程B得到的值也是100,之后对其进行加1操作,得到101后,将新值写入到缓存中,再刷入主存中。根据可见性的原则,这个主存的值可以被其他线程可见。
  • 但是线程A已经读取到了i的值为100,也就是说读取的这个原子操作已经结束了,所以这个可见性来的有点晚,线程A阻塞结束后,继续将100这个值加1,得到101,再将值写到缓存,最后刷入主存,所以即便是volatile具有可见性,也不能保证对它修饰的变量具有原子性。
内存屏障
  • 内存屏障是一种 jvm 指令,可以阻止屏障前后的指令重排序
  • 内存屏障指令是CPU或者编译器对内存随机访问的操作中的一个共同点,此点之前的所有读写操作都执行后,才可以执行此点之后的操作
    • 内存屏障之前的所有写操作都要写回到主内存
    • 内存屏障之后的所有读操作,都能获得内存屏障之前的所有写操作的最新结果,也就实现了可见性
    • 例如对于一个volatile 变量的写,先行发生于任意后续对这个volatile 变量的读
  • 所以通过这些内存屏障指令,volatile 实现了可见性和有序性

内存屏障分为:

  • 写屏障:loadFence

    • 告诉处理器在写屏障之前将所有存储在缓存中的数据同步到主内存,也就是说看到写屏障,就必须把该指令之前的所有写人指令执行完毕,写入主内存后才能往下执行
    • 写指令之后插入写屏障,强制把写缓冲区的数据刷回主内存
  • 读屏障:storeFence

    • 读指令之前插入读屏障,让工作内存或者CPU高速缓存当中的缓存数据失效,重新去主内存中获取数据

    • 读屏障之后的所有读操作一定要等到所有load指令完成,所以一定能够读取到最新的数据

  • 全屏障:fullFence 全凭证就是 写屏障、读屏障的合体

JDK:Unsafe类,下面的三个方法分别是:写屏障、读屏障和全屏障

在这里插入图片描述

四大屏障类型如下:

在这里插入图片描述

读屏障:在读取 volatile 变量时

  • 会在后面插入一个LoadLoad屏障,禁止下面所有的普通读操作和上面的 volatile 读重排序
  • 和一个LoadStore屏障,禁止下面的所有普通写操作和上面的 volatile 读重排序
    在这里插入图片描述

写屏障:在 每个 volatile 写操作时

  • 会在前面插入一个 StoreStore 屏障,可以保证在 volatile 写操作之前,其前面所有的普通写操作都已经刷新到主内存
  • 在后面插入一个 StoreLoad 屏障,用于避免 volatile 写操作与后面可能有的 volatile 读写操作指令的重排序,必须要先写完才能执行后续指令

在这里插入图片描述

文章来源:https://blog.csdn.net/persistence_PSH/article/details/135441565
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。