JMM内存模型

JMM

JMM(java内存模型Java memory model,简称JMM)本身是一种抽象的概念并不真实存在它仅仅描述的是一组规范或约定,通过这组规范定义了程序中(尤其是线程)各个变量的读写访问方式并决定一个线程对共享变量的写入何时以及如何变成对另一个线程可见,关键技术点是围绕多线程的原子性可见性有序性展开的。通过JMM来实现线程和主内存之间的抽象关系,屏蔽各个硬件平台和操作系统的内存访问差异以实现Java程序在各种平台下能达到一致的内存访问效果。

Java内存模型示意图如下:

在这里插入图片描述


数据读取过程

由于JVM运行程序的实体是线程而每个线程创建时JVM都会为其创建一个工作内存,工作内存是每个线程的私有数据区域,而Java内存模型中规定所有变量都存储在主内存中,主内存是共享内存区域,所有线程都可以访问,但是线程对变量的操作必须在自己的工作内存中进行,首先将变量从主内存拷贝到自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存中,不能直接操作内存中的变量,各个线程中的内存中心存储着主内存的变量副本拷贝,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来进行


JMM三大特性

可见性:是指当一个线程修改了某一个变量的值其他线程是否能够立即知道该变更,JMM规定了所有的变量都存储在主内存中。对于串行程序,前面修改的值后面代码读到的肯定是最新的值,所以不存在可见性问题,但是对于并行的线程,A线程修改了值未必其他线程能够马上知道值被修改了

可见性问题的产生:在Java中造成可见性问题的原因是Java内存模型(JMM),在Java内存模型中,规定了共享变量是存放在主内存中,然后每个线程都有自己的工作内存,而线程对共享变量的操作,必须先从主内存中读到工作内存中去,至于什么时候写回到主内存是不可预知的(很可能发生脏读),这就导致每个线程之间对共享变量的操作是封闭的,其他线程不可见的。

在这里插入图片描述

**原子性:**是指一个操作时不可被打断的,即多线程环境下,操作不能被其他线程干扰

有序性:对于一个线程的执行代码而言,我们总是习惯性认为代码的执行总是从上到下有序执行的,但为了提高性能,编译器和处理器通常会对指令序列进行重新排序,Java规范规定JVM线程内部维持顺序化语义,即只要程序的最终结果与它顺序话执行的结果一样,那么指令的执行顺序可以与代码执行顺序不一致,此过程叫做指令重排序

线程脏读主内存中有变量X,初始值为0,线程A要将X加1,先将X=0拷贝到自己的私有工作内存中,然后更新X的值,线程A将更新后的X的值回刷到主内存的时间是不固定的,刚好在线程A没有回刷到主内存时,线程B同样从主内存读取X,此时为0,进行和线程A一样的操作,最后期盼的X=2就会变成X=1;


指令重排序

JVM能根据处理器特性适当的对机器指令进行重排序,使机器执行更能符合CPU的执行特性,最大限度地发挥机器性能,但是指令重排可以保证串行语义一致,但没有义务保证多线程间的语义也一致(即可能产生脏读),简单说:两条以上不相干的代码在执行的时候有可能先执行的不是第一条,不见得是从上到下执行的,执行顺序会被优化

从源码到最终执行的示意图

image-20221031144257341

指令重排序约束

单线程环境里面确保程序最终执行结果和顺序执行结果一致

处理器在进行重排序时必须考虑指令之间的数据依赖性(若两个操作访问同一变量,且这两个操作中有一个为写操作,此时两操作间存在数据依赖性),在不改变数据依赖性的前提下允许指令重排

int x=5;
int y=2;
int z=x+y;

这种情况,执行的时候可能时先对y赋值也不一定,x,y前后赋值并不会改变z的值,但是

int z=x+y;
int x=5;
int y=2;

这种情况就违背了数据依赖性

多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致是无法确定的,结果无法预测


先行发生原则happens-beforr

在JVM中,一个操作执行结果需要对另一个操作可见性,或者代码重排序,那么这两个操作之间必须存在happens-before原则。逻辑上的先后顺序,happens-before原则是判断是否存在竞争,线程是否安全的非常有用的手段,依赖这个原则,我们可以通过几条简单的规则解决并发环境下两个操作之间是否存在冲突的所有问题

happens-before原则总概述

如果一个操作happens-before另一个操作,那么第一个操作的执行结果对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。两个操作之间存在happens-before原则,并不意味着一定按照happens-before原则制定的顺序来执行,如果重排序之后的执行结果与按照happens-before关系执行的结果一致,那么这种重排序并不非法

happens-before八条原则
  • 次序规则:一个线程内,按照代码顺序,写在前面的操作先行发生于些在后面的操作
  • 锁定规则:一个unlock操作先行发生于后面(这里的后面是指实践上的先后)对同一个锁的lock操作
  • volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作,前面的写对后面的读是可见的,这里的后面同样是指时间上的先后
  • 传递规则:如果操作A先行发生于操作B,而操作B先行发生于操作C,则A操作先行发生于C操作
  • 线程启动规则:线程的start方法先行发生于线程内的每一个动作
  • 线程中断规则:对线程的interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,也就是说需要先调用interrupt设置中断标志位才能检测中断发送
  • 线程终止规则:线程中的所有操作都先行发生于对此线程的终止检测,可以通过isAlive()方法检测线程是否终止执行
  • 对象终结规则:一个对象的初始化操作(构造函数执行结束)先行发生于他的finalize方法的开始,即对象还没完成初始化之前,是不能调用finalize方法的
总结

在Java语言中,happens-before的语义实质上是一种可见性,A happens-before B意味着A发生过的事情对于B是可见的,无论A事件和B事件是否在同一个线程中,JMM的涉及分为两部分,一部分是面向程序员提供的,也就是Happens-before规则,它通俗易懂的像我们阐述了一个强内存模型,我们只要理解happens-before规则,就可以编写并发安全的程序。另一部分是针对JMM实现的为了尽可能少的对编译器和处理器做约束从而提高性能,JMM不影响程序执行结果的前提下对其不做要求,即允许优化重排序,我们只需要关注前者就好了。也就是理解happens-before原则即可,其他的繁杂的内容由JMM解决


volatile

特点:被volatile修饰的变量禁止重排序(有序性),并且具有可见性

内存语义

当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量的值立即刷新回主内存中

当读一个volatile变量时,JMM会把该线程对应的本地内存设置为无效,重新回到主内存中读取最新的共享变量


内存屏障

内存屏障(也称内存栅栏,屏障指令等,是一类同步屏障指令)是CPU或编译器在对内存随机访问得操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作,避免代码重排序,内存屏障其实就是一种JVM指令,Java内存模型的重排规则会要求Java编译器在生成JVM指令时插入特定的内存屏障指令,通过这些内存屏障指令,volatile实现了Java内存模型中的可见性和有序性(禁重排),但是volatile无法保证原子性。

内存屏障之前所有写操作都要回写到内存中,内存屏障之后的所有读操作都能获得内存屏障之前的所有写操作的最新结果(实现了可见性)

在Java字节码层面,加了volatile的变量会添加一个ACC_VOLATILE标识,在JVM把字节码生成机器码时,发现如果时volatile变量的话,会按照JMM规范在相应的位置插入内存屏障

内存屏障分类

总体划分为两大类:写屏障和读屏障

  • 写屏障(Store Memory Barrier):在写屏障之前将所有存储在缓存中的数据同步到主内存,也就是说当看到Store屏障指令时,就必须把该指令之前写入指令执行完成才能继续往下执行
  • 读屏障(Load Memory Barrier):在读屏障之后的读操作都在读屏障之后执行,保证后面的所有读取数据指令一定能够获取到最新的数据。在读指令之前插入读屏障,让工作内存或CPU高速缓存中的缓存数据失效,重新去读取主内存中的最新数据

细分为以下四种类型的内存屏障

屏障类型指令示例说明
LoadLoadLoad1;LoadLoad;Load2保证Load1的读取操作在Load2及后续读操作之前执行
StoreStoreStore1;StoreStore;Store2在Store2及其后的写操作执行前,保证Store1的写操作已经刷新到主内存中
LoadStoreLoad1;LoadStore;Store2在Store2及其后的写操作执行前,保证Load1的读操作已经读取结束
StoreLoadStore1;StoreLoad;Load2保证Store1的写操作已经刷新到主内存之后,Load2及其后的读操作才能执行

volatile读插入内存屏障后生成的指令序列示意图如图:volatile读指令示意图所示

  • 在每个volatile读操作后面插入LoadLoad屏障,禁止处理器把上面的volatile读与下面的普通读进行重排序
  • 在每个volatile读操作的后面插入一个LoadStore屏障,禁止处理器把上面的volatile读与下面的普通读进行重排序

image-20221104094329539

volatile读指令示意图

volatile写操作内存屏障后生成的指令序列示意图如图:volatile写指令示意图

  • 在每个volatile写操作的前面插图一个StoreStore屏障,可以保证在volatile写之前,其前面的所有普通写操作都已经刷新到主内存中
  • 在每个volatile写操作的后面插入一个StoreLoad屏障,作用是避免volatile写与后面可能出现的volatile读/写操作进行重排序

image-20221104095615623

volatile写指令示意图
happens-before之volatile变量规则
  • 当第一个操作为volatile读时,不论第二个操作是什么,都不能进行重排序。这个操作保证了volatile读之后的操作不会被重排序到volatile读之前
  • 当第二个操作为volatile写时,不论第一个操作是什么,都不能重排序,这个操作保证了volatile写之前的操作不hi被重排序到volatile写之后
  • 打那个第一个操作为volatile写时,第二个操作为volatile读时,不能重排序

可见性案例详解
public class VolatileVisibilityTest{
    //不使用volatile修饰时
    private static boolean flag=true;

    //使用volatile修饰时
    //private static volatile boolean flag=true;

    public static void main(String[] args) throws InterruptedException {
        new Thread(()->{
            while(flag){

            }
        },"A").start();
        //暂停的意义是避免main主线程执行过快,导致线程A还没启动就修改了flag的值为false了
        TimeUnit.SECONDS.sleep(2);

        flag=false;
    }
}

通过上述代码案例可以发现,当不使用volatile关键字修饰时,线程A会一直处于死循环状态,即使main线程已将flag的值修改为了false,但是线程A并不知道,还是拿着自己工作内存空间中的那份值。替换为使用volatile修饰之后,main线程修改了之后线程A就能立刻感知到并结束死循环了


不保证原子性案例详解
public class AtomicityTest {
    //使用同步方法保证原子性。
    private static int num=0;
    public synchronized static void add(){
        num++;
    }

    //使用volatile无法保证原子性
//    private static volatile int num=0;
//    public  static void add(){
//        num++;
//    }

    public static void main(String[] args) throws InterruptedException{
        for(int i=0;i<10;i++){
            new Thread(()->{
                for(int j=0;j<1000;j++){
                    add();
                }
            }).start();
        }
        TimeUnit.SECONDS.sleep(2);
        System.out.println(num);
    }
}

上述案例的测试结果可以看到,当使用同步方法对num变量进行自增时,能够保证每次的运行结果都是10000,但是使用volatile时则无法保证每次的结果都是10000。

不保证原子性的原因:对于volatile变量具备可见性,JVM只是保证从主内存加载到线程工作内存的值是最新的,也仅仅只是数据加载时时最新的,但是多线程环境下,数据计算和数据赋值可能多次出现,若数据加载之后,若主内存的变量发生变化,线程工作内存中的操作将会作废,重新去读取主内存中的最新值,可能线程正在执行赋值操作,接下来就是写入主内存了,但是此时放弃这一步操作导致了写操作丢失,即各线程私有内存和主内存中变量不同步,进而导致数据不一致,由此可见,volatile解决的时变量读时的可见性问题,但是无法保证原子性,对于多线程修改主内存共享变量的场景必须使用加锁同步。

在不符合以下两条规则的运算场景夏,依然要使用加锁或原子类来保证原子性:

  • 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值
  • 变量不现需要与其他的状态变量共同参与不变约束

禁止指令重排序案例详解

理解了数据依赖性和内存屏障之后就可以了,具体的案例就不写了


volatile变量读写过程

read(读取)—> load(加载)—>use(使用)—>assign(赋值)—>store(存储)—>writer(写入)—>lock(锁定)—>unlock(解锁)

示意图如图:volatile变量读写示意图所示
在这里插入图片描述

volatile变量读写示意图

read:作用于主内存,将变量的值从主内存传输到线程工作内存

load:作用于工作内存,将read从主内存传输的变量值放入工作内存变量副本,即数据加载

use:作用于工作内存,将工作内存变量副本的值传递给存储引擎,每当JVM遇到需要该变量的字节码指令时会执行该操作

assign:作用于工作内存,将从执行引擎收到的值赋值给工作内存变量,每当JVM遇到变量1赋值字节码时会执行该操作

store:作用于工作内存,将赋值完成的工作变量的值反写会主内存

writer:作用于主内存,将store传输过来的变量值赋给主内存中的变量

lock:作用于主内存,将变量标记为一个线程独占的状态,只是锁了写变量的过程

unlock:作用于主内存,把一个处于锁定状态的变量释放,然后才能被其他线程占用


使用场景
  • 单一赋值场景,避免含有复合运行赋值例如i++

  • 状态标志,判断业务是否结束,如代码块:volatile可见性代码案例

  • 开销较低的读,写锁操作,对于写操作使用锁机制保证原子性,对于读操作使用volatile关键字保证有序性

    public class Test{
        private volatile int value;
        
        public int getValue(){
            return value;
        }
        
        public synchronized void setValue(){
            value++;
        }
    }
    
  • 单例模式双重校验锁使用(DCL双端锁)

    在单例模式的双重校验锁中,内层synchronized对实例对象进行了初始化,这个步骤是这样的,1.分配内存空间,2.初始化对象,3.指针指向对象内存地址。但是由于指令重排序的存在,有可能2和3步骤对调了顺序,导致指针指向的还是未完成初始化的对象(也就是null)


(面试)Volatile怎么保证可见性和有序性,为什么无法保证原子性

volatile通过内存屏障禁止了指令重排序从而保证了有序性,通过底层的缓存一致性协议当线程修改共享变量之后理解刷新到主内存中,并发出通知使其他线程的私有内存中的共享变量失效,重新从主内存中读取最新的变量值。常见的i++操作实际上不是一步到位的,从字节码角度来看分成了好几步(1.读变量,2.运算,3.反写回主内存),当A线程修改完值之后正准备刷新回主内存时,被B线程捷足先登了,把值先刷新回去了,此时主内存中的共享变量值已经发生改变,由于volatile具有可见性,当前A线程中的变量就失效了,需要重新获取值,在这一步A线程发生了写丢失的操作。所以可以看见当使用多线程执行i++时,即使添加了volatile关键字,实际的运算结果总会比预期结果要小一些


总结

volatile写之前的操作,都禁止重排序到volatile之后

volatile读之前的操作,都禁止重排序到volatile之前

volatile写之后的volatile读,禁止重排序

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值