美文网首页
JMM 简介及 volatile 的说明

JMM 简介及 volatile 的说明

作者: yuhuanxi | 来源:发表于2017-02-08 11:16 被阅读0次

    个人博客地址:https://huanxi.pub

    为了屏蔽各个操作系统和硬件的差异,使得 Java 程序在所有平台下都能达到一致的内存访问效果,所以 Java 虚拟机定义了一种 Java 内存模型。

    Java 内存模型的作用

    我们写代码,说到底就是在操作内存。

    Java 内存模型主要定义了程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量的底层细节。(这里的变量不包括局部变量和方法参数,因为那是线程私有的,不会产生竞争)

    Java 虚拟机规定所有的变量都存储在主内存(Main Memory),每个线程都有自己的工作线程(Work Memory)。

    线程的工作内存中保存了使用到的变量的主内存副本拷贝,线程对变量的操作是在自己的工作内存中,而不能直接对主内存的变量进行读取赋值。

    不同线程之间无法直接访问对方工作内存中的变量,需要通过主内存来进行传递。

    线程、主内存、工作内存之间的关系如下图所示


    main_work_memory

    volatile 关键字

    volatile 变量具备两种特性,其一是保证该变量对所有线程可见,这里的可见性指的是当一个线程修改了变量的值,那么新的值对于其他线程是可以立即获取的。其二 volatile 禁止了指令重排。

    虽然 volatile 变量具有可见性和禁止指令重排序,但是并不能说 volatile 变量能确保并发安全。

    public class VolatileTest {
    
        public static volatile int a = 0;
        public static final int THREAD_COUNT = 20;
    
        public static void increase() {
            a++;
        }
    
        public static void main(String[] args) throws InterruptedException {
    
            Thread[] threads = new Thread[THREAD_COUNT];
    
            for (int i = 0; i < THREAD_COUNT; i++) {
                threads[i] = new Thread(new Runnable() {
                    public void run() {
                        for (int i = 0; i < 1000; i++) {
                            increase();
                        }
                    }
                });
                threads[i].start();
            }
    
            while (Thread.activeCount() > 2) {
                Thread.yield();
            }
    
            System.out.println(a);
        }
    }
    

    按照我们的预期,它应该返回 20000 ,但是很可惜,该程序的返回结果几乎每次都不一样。

    问题主要出在 a++ 上,复合操作并不具备原子性, 虽然这里利用 volatile 定义了 a ,但是在做 a++ 时, 先获取到最新的 a 值,比如这时候最新的可能是 50,然后再让 a 增加,但是在增加的过程中,其他线程很可能已经将 a 的值改变了,或许已经成为 52、53 ,但是该线程作自增时,还是使用的旧值,所以会出现结果往往小于预期的 2000。如果要解决这个问题,可以对 increase() 方法加锁。

    volatile 适用场景

    volatile 适用于程序运算结果不依赖于变量的当前值,也相当于说,上述程序的 a 不要自增,或者说仅仅是赋值运算,例如 boolean flag = true 这样的操作。

        volatile boolean shutDown = false;
    
        public void shutDown() {
            shutDown = true;
        }
    
        public void doWork() {
            while (!shutDown) {
                System.out.println("Do work " + Thread.currentThread().getId());
            }
        }
    

    当调用 shutDown() 方法时,能保证所有的线程都停止工作。

    指令重排

    int a = 1;
    int b = 2;
    int c = a * b;
    

    CPU 为了提升效率,允许将多条指令进行重新排序。
    比如上述 就有可能先执行
    int b = 2;
    然后执行
    int a = 1;
    但是 int c = a * b 是不会进行重排的,它必须在 a、b 之后,因为 c 对 a、b 有所依赖。
    上面的代码,在单线程中,即使经过指令重排,但是并不会影响最终的结果,所以是不会出问题的,但是在多线程中,就会引发问题。

    public class Singleton {
      private static Singleton instance = null;
      private Singleton() { }
      public static Singleton getInstance() {
         if(instance == null) {
            synchronized(Singleton.class) {
               if(instance == null) {
                   instance = new Singleton();  //非原子操作
               }
            }
         }
         return instance;
       }
    
    }
    

    看似简单的一段赋值语句:instance= new Singleton(),但是很不幸它并不是一个原子操作,其实际上可以抽象为下面几条JVM指令:

    memory =allocate(); //1:分配对象的内存空间
    ctorInstance(memory); //2:初始化对象
    instance =memory; //3:设置instance指向刚分配的内存地址

    上面操作2依赖于操作1,但是操作3并不依赖于操作2,所以JVM是可以针对它们进行指令的优化重排序的,经过重排序后如下:

    memory =allocate(); //1:分配对象的内存空间
    instance =memory; //3:instance指向刚分配的内存地址,此时对象还未初始化
    ctorInstance(memory); //2:初始化对象

    可以看到指令重排之后,instance指向分配好的内存放在了前面,而这段内存的初始化被排在了后面。

    在线程A执行这段赋值语句,在初始化分配对象之前就已经将其赋值给instance引用,恰好另一个线程进入方法判断instance引用不为null,然后就将其返回使用,导致出错。

    instance 加上 volatile 即可防止指令重排。

    本文参考:《深入理解 Java 虚拟机第二版

    最后一个例子及说明摘自:Java并发:volatile内存可见性和指令重排

    相关文章

      网友评论

          本文标题:JMM 简介及 volatile 的说明

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