美文网首页
JAVA内存模型(JMM) && Volitale 详解

JAVA内存模型(JMM) && Volitale 详解

作者: MrZhang2019 | 来源:发表于2019-08-14 20:16 被阅读0次

    什么是JMM模型

    Java内存模型(Java Memory Model简称JMM)是一种抽象的概念,并不真实存在,它描 述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构 成数组对象的元素)的访问方式。JMM屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能得到一致效果的机制及规范。目的是解决由于多线程通过共享内存进行通信时,存在的原子性、可见性(缓存一致性)以及有序性问题。

    JMM是围绕原子性,可见性,有序性展开,那么什么是原子性,可见性,有序性?
    原子性

    原子性指的是一个操作是不可中断的,即使是在多线程环境下,一个操作一旦开始就不会被其他线程影响。

    多线程中可能出现的问题:
    线程是CPU调度的基本单位。CPU有时间片的概念,会根据不同的调度算法进行线程调度。所以在多线程场景下,就会发生原子性问题。因为线程在执行一个读改写操作时,在执行完读改之后,时间片耗完,就会被要求放弃CPU,并等待重新调度。这种情况下,读改写就不是一个原子操作。即存在原子性问题。

    可见性

    可见性指的是当一个线程修改了某个共享变量的值,其他线程是否能够马上得知这个修改的值。

    对于串行程序来说,可见性是不存在的,因为我们在任何一个操作中修改了某个变量的值,后续的操作中都能读取这个变量值,并且是修改过的新值。
    但在多线程环境中可就不一定了,由于线程对共享变量的操作都是线程 拷贝到各自的工作内存进行操作后才写回到主内存中的,这就可能存在一个线程A修改了共享 变量x的值,还未写回主内存时,另外一个线程B又对主内存中同一个共享变量x进行操作,但此时A线程工作内存中共享变量x对线程B来说并不可见,这种工作内存与主内存同步延迟现象 就造成了可见性问题,另外指令重排以及编译器优化也可能导致可见性问题,无论是编译器优化还是处理器优化的重排现象,在多线程环境下,确实会导致程 序轮序执行的问题,从而也就导致可见性问题。

    有序性

    程序执行的顺序按照代码的先后顺序执行。

    我们总是认为代码的执行是按顺序依次执行的,这样 的理解并没有毛病,毕竟对于单线程而言确实如此,但对于多线程环境,则可能出现乱序现象,因为程序编译成机器码指令后可能会出现指令重排现象,重排后的指令与原指令的顺序未必一致。

    多CPU多级缓存会导致缓存一致性问题,CPU时间片会导致原子性问题,指令重排会出现有序性问题,所以,为了保证并满足并发编程中的原子性、可见性、有序性,就产生了一个重要的概念————内存模型

    一个变量如何从主内存拷贝到内存,如何从工作内存到主内存之间的同步实现细节,Java内存模型定义了以下八种操作来完成:

    1. lock(锁定):作用于主内存的变量,把一个变量标记为一条线程独占状态
    2. unlock(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的 变量才可以被其他线程锁定
    3. read(读取):作用于主内存的变量,把一个变量值从主内存传输到线程的工作内存中, 以便随后的load动作使用
    4. load(加载):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作 内存的变量副本中
    5. use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎
    6. assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存 的变量
    7. store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中, 以便随后的write的操作
    8. write(写入):作用于工作内存的变量,它把store操作从工作内存中的一个变量的值传送 到主内存的变量中

    如果把一个变量从主内存中复制到工作内存中,就需要按顺序的执行read和load操作,如果需要把工作内存中的变量同步回主内存中,则需要按照顺序的执行store、write操作,但是Java内存模型只要求上述操作必须按照顺序执行,没有要求必须连续执行。


    JMM示意图

    如上图所示,必须按照 read — load — use 顺序执行,但是不一定会连续执行。

    同步规则

    1、不允许一个线程无原因的(没有发生任何assign操作)把数据从工作内存同步回主内存中。
    2、一个新变量只能在主内存中产生,不允许工作内存中直接使用一个未被初始化的(没有被load或者assign)变量。即:对一个变量use或者store之前,必须先自行load或者assign操作。
    3、一个变量在同一时刻只允许被一个线程lock,但lock操作可以被同一个线程重复执行多次,多次执行lock操作后,必须执行相同次数的unlock,变量才会被解锁。即:lock和unlock必须成对出现。
    4、如果对一个变量执行lock时,将会清空工作内存中此变量的值,在执行引擎使用此变量之前,需要重新load或者assign操作重新初始化变量的值。
    5、如果一个变量事先没有被lock操作,则不允许对它执行unlock操作,也不允许去unlock一个被其他线程lock的变量。
    6、对一个变量执行unlock操作之前,必须先把此变量同步到主内存中,即:必须执行store和write操作。

    JMM如何解决原子性&可见性&有序性问题

    • 原子性问题
      在Java中,提供了两个高级的字节码执行:monitorenter 和 monitorexit来保证原子性。在Java中可以通过synchronized 和 Lock 实现原子性。synchronized 和 lock 可以保证任何时候只有一个线程访问该代码块。
    • 可见性问题
      volatile关键字保证可见性。当一个共享变量被volatile修饰时,它会保证修改的值立即被其他的线程看到,即修改的值立即更新到主存中,当其他线程需要读取时,它会去内存中读取新值。synchronized 和 Lock 也可以保证可见性,因为它们可以保证任一时刻只有一个线程能访问共享资源,并在其释放锁之前将修改的变量刷新到内存中。
    • 有序性问题
      在Java里面,可以通过volatile关键字来保证一定的代码执行的有序性(在编译器编译时,会加上#Lock的字节码保证编译顺序)。另外可以通过 synchronized 和 Lock 来保证有序性,很显然,synchronized 和 Lock 保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就 保证了有序性。

    到这里我们可以发现,好像 synchronized 和 Lock 是万能的,他们可以同时满足以上三种特性,所以就导致了现在很多人滥用synchronized 和 lock的原因。但是synchronized比较影响性能,虽然编译器提供了很多种锁优化技术,但还是不建议过度使用。

    总结:

    JMM是一种规范,用来解决多线程通过共享内存进行通信时,与存在的本地内存中数据不一致,编译器会对代码进行指令重排、处理器会乱序执行代码等带来的问题,目的就是保证并发场景中原子性,可见性,有序性。

    volitale

    volatile 是Java虚拟机提供的轻量级的同步机制。volatile关键字有如下两个作用
    1、保证被volatile修饰的共享变量对所有线程总数可见的,也就是当一个线程修改了一个被volatile修饰共享变量的值,新值总是可以被其他线程立即得知。
    2、禁止指令重排序优化。

    volitale的可见性

    关于volatile的可见性作用,我们必须意识到被volatile修饰的变量对所有线程总数立即可见的,对volatile变量的所有写操作总是能立刻反应到其他线程中。

        private volatile boolean initFlag = false;
        private static Object object = new Object();
    
        public void refresh(){
            this.initFlag = true;
            String threadName = Thread.currentThread().getName();
            System.out.println("线程:"+threadName+": 修改共享变量initFlag");
        }
    
        public void load(){
            String threadName = Thread.currentThread().getName();
            int i = 0;
            // 程序在此空跑,由于 volatile 修饰 initFlag,所以会一直嗅探其他线程对 initFlag 的修改
            while (!initFlag){
                /*
                synchronized (object){
                    i++;
                }
                System.out.println(i);
                */
            }
            System.out.println("线程:"+threadName+" 当前线程嗅探到initFlag的状态值改变");
        }
    
        public static void main(String[] args) {
            TestThread testThread = new TestThread();
            Thread threadA = new Thread(()->{
               testThread.refresh();
            },"threadA");
    
            Thread threadB = new Thread(()->{
                testThread.load();
            },"threadB");
    
            threadB.start();
    
            try {
                Thread.sleep(2000);
            }catch (Exception e){
                e.printStackTrace();
            }
            threadA.start();
        }
    

    上面的示例可以看到,一共创建两个线程,线程A(threadA)去修改initFlag的值,线程B(threadB)在等其他线程修改initFlag后,跳出自旋(while (!initFlag)),线程A改变initFlag属性之后,线程B马上感知到。

    volitale无法保证原子性

        public static volatile int i = 0;
    
        public static void increase(){
            i++;
        }
    

    在并发场景下,i变量的任何改变都会立马反应到其他线程中,但是如此存在多条线程同时 调用increase()方法的话,就会出现线程安全问题,毕竟i++;操作并不具备原子性,该操作是 先读取值,然后写回一个新值,相当于原来的值加上1,分两步完成,如果第二个线程在第一 个线程读取旧值和写回新值期间读取i的域值,那么第二个线程就会与第一个线程一起看到同一 个值,并执行相同值的加1操作,这也就造成了线程安全失败,因此对于increase方法必须使 用synchronized修饰,以便保证线程安全,需要注意的是一旦使用synchronized修饰方法 后,由于synchronized本身也具备与volatile相同的特性,即可见性,因此在这样种情况下就 完全可以省去volatile修饰变量。

    volatile禁止重排优化

    volatile关键字另一个作用就是禁止指令重排优化,从而避免多线程环境下程序出现乱序执行的现象,下面主要简单说明一下volatile是如何实现禁止指令重排优化的。先了解一个概念,内存屏障(Memory Barrier)。
    内存屏障,又称内存栅栏,是一个CPU指令,它的作用有两个,一是保证特定操作的执行 顺序,二是保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)。由于编译 器和处理器都能执行指令重排优化。如果在指令间插入一条Memory Barrier则会告诉编译器 和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说通过插入内存屏 障禁止在内存屏障前后的指令执行重排序优化。Memory Barrier的另外一个作用是强制刷出 各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。总之, volatile变量正是通过内存屏障实现其在内存中的语义,即可见性和禁止重排优化。下面看一个非常典型的禁止重排优化的例子DCL

        public static DoubleCheckLock instance;
        
        private  DoubleCheckLock(){}
        
        public static DoubleCheckLock getInstance(){
            // 第一次判断
            if(instance == null){
                // 同步代码块
                synchronized (DoubleCheckLock.class){
                    if(instance == null){
                        // 多线程环境下可能会出现问题的地方
                        instance = new DoubleCheckLock();
                    }
                }
            }
            return instance;
        }
    

    上述代码一个经典的单例的双重检测的代码,这段代码在单线程环境下并没有什么问题,但如果在多线程环境下就可以出现线程安全问题。原因在于某一个线程执行到第一次检测,读取到的instance不为null时,instance的引用对象可能没有完成初始化。
    因为instance = new DoubleCheckLock();可以分为以下3步完成:
    1、分配队形内存空间。
    2、初始化对象。
    3、设置instance指向刚分配的内存地址。此时instance! =null
    由于步骤1和步骤2间可能会重排序,如下:
    1、分配对象内存空间
    3、设置instance指向刚分配的内存地址,此时instance! =null,但是对象还没有初始化完成!
    2、初始化对象
    由于步骤2和步骤3不存在数据依赖关系,而且无论重排前还是重排后程序的执行结果在单 线程中并没有改变,因此这种重排优化是允许的。但是指令重排只会保证串行语义的执行的一 致性(单线程),但并不会关心多线程间的语义一致性。所以当一条线程访问instance不为null 时,由于instance实例未必已初始化完成,也就造成了线程安全问题。
    那么该如何解决呢,很简单,我们使用volatile禁止instance变量被执行指令重排优化即可。

        // 禁止指令重排优化
        public volatile static DoubleCheckLock instance;
    

    为了实现volatile内存语义,JMM会分别限制这两种类型的重排序类型。 下图是JMM针对编译器制定的volatile重排序规则表。

    是否能重排 第二个操作 第二个操作 第二个操作
    第一个操作 普通读/写 volitale读 volitale写
    普通读/写 NO
    volitale读 NO NO NO
    volitale写 NO NO

    举例来说,第三行最后一个单元格的意思是:在程序中,当第一个操作为普通变量的读或 写时,如果第二个操作为volatile写,则编译器不能重排序这两个操作。

    从上图可以看出:

    • 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保 volatile写之前的操作不会被编译器重排序到volatile写之后。
    • 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保 volatile读之后的操作不会被编译器重排序到volatile读之前。
    • 当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。


      volatile写插入内存屏障后生成的指令序列示意图

      上图中StoreStore屏障可以保证在volatile写之前,其前面的所有普通写操作已经对任意处理器可见了。这是因为StoreStore屏障将保障上面所有的普通写在volatile写之前刷新到主 内存。
      这里比较有意思的是,volatile写后面的StoreLoad屏障。此屏障的作用是避免 volatile写与 后面可能有的volatile读/写操作重排序。因为编译器常常无法准确判断在 一个volatile写的后面是否需要插入一个StoreLoad屏障(比如,一个volatile写之后方 法立即return)。为了保证能正确 实现volatile的内存语义,JMM在采取了保守策略: 在每个volatile写的后面,或者在每个volatile读的前面插入一个StoreLoad屏障。从整体执行效率的角度考虑,JMM最终选择了在每个 volatile写的后面插入一个StoreLoad 屏障。因为volatile写-读内存语义的常见使用模式是:一个写线程写volatile变量,多个读线程读同一个volatile变量。当读线程的数量大大超过写线程时,选择在volatile写 之后插入StoreLoad屏障将带来可观的执行效率的提升。从这里可以看到JMM在实现上的一个特点:首先确保正确性,然后再去追求执行效率。

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

    上图中LoadLoad屏障用来禁止处理器把上面的volatile读与下面的普通读重排序。LoadStore屏障用来禁止处理器把上面的volatile读与下面的普通写重排序。
    上述volatile写和volatile读的内存屏障插入策略非常保守。在实际执行时,只要不改变volatile写-读的内存语义,编译器可以根据具体情况省略不必要的屏障。

    相关文章

      网友评论

          本文标题:JAVA内存模型(JMM) && Volitale 详解

          本文链接:https://www.haomeiwen.com/subject/wyonjctx.html