美文网首页并发java学习快到碗里来Java
java并发编程(十六)带你了解volatile原理

java并发编程(十六)带你了解volatile原理

作者: 我犟不过你 | 来源:发表于2022-01-04 15:08 被阅读0次

    还记得上一篇文章当中提到的内存屏障(Memory Fence)吗?其实Volatile的实现原理就是通过内存屏障来实现的。

    • 对于volatile修饰的变量:

      • 在该变量的写指令后,会加入写屏障
      • 在该变量的读指令前,会加入读屏障

    上面先放个结论,后面我们逐步的看它是什么意思。

    我们看下有如下的代码,主要是为了理解写屏障和读屏障是如何添加,且填在的位置在何处:

    public class VolatileTest {
    
        /**
         * 定义一个volatile修饰的共享变量
         */
        volatile static boolean flag = false;
    
        /**
         * 定义全局变量num
         */
        static int num = 0;
    
        public static void test1() {
            num = 2;
            // 此处修改数据ready,会增加一个写屏障,从而num、ready在修改数据后,都会添加到主存当中
            flag = true;
        }
    
        public static void test2() {
            // 此处读取数据ready,会增加一个读屏障,保证后面的ready和num都会从主存当中获取数据
            if (flag) {
                System.out.println(num);
            }
        }
    
        public static void main(String[] args) {
    
            new Thread(() -> {
                test1();
            }, "t1").start();
    
            new Thread(() -> {
                test2();
            }, "t2").start();
        }
    }
    

    如上所示,有volatile修饰的变量flag,假设上述代码t1先执行,t2后执行,会有如下过程:

    • t1执行test1方法,此时将num赋值称为2,num此时可能没有推送到主存当中。之后又执行了对flag赋值的操作,因为flag是volatile修饰的,所以一定会将flag更新到主存,同时将num也会更新到主存。

    • t2执行test2方法时,首先会读取flag的值,由于flag是有volatile修饰,此时会从主存拉取flag的值,同时num也会从主存获取。

    一、可见性如何保证?

    前文说到,写屏障对于共享变量的所有修改,在写屏障前的所有共享变量,都需要同步到主内存当中。

    读屏障对于共享变量的所有修改,在读屏障后的所有共享变量,都需要同从主存当中获取。

    在文章开始的例子当中已经阐述了流程:

    • 在修改flag的值时,所依靠的是写屏障,会在flag被修改后的位置添加一个写屏障,在写屏障之前的的num、和flag修改后的值都会同步到主存当中。

    • 在读取flag的值时,所依靠的是读屏障,在flag读取之前增加一份读屏障,在读屏障后读取的flag和num都会从主存当中获取。

    二、有序性如何保证?

    • 写屏障保证在发生指令重排序时,不会将写屏障之前的代码放在写屏障之后。

    • 读屏障会确保指令重排序时,不会将读屏障后的代码放在读屏障之前。

    假设在volatile关键字之前有多个变量被修改的语句,那么volatile是不能保证其执行的顺序,能保证的仅仅是在写屏障前的所有代码都执行完毕,并且写屏障前的修改对于读屏障后代码一定是可见的。

    假如读取在写屏障之前,那么则不能保证了。

    另外需要注意的是,有序性只保证在当前线程内的代码不被重排序。

    三、happens-before原则

    happens-before 规定了对共享变量的写操作对其它线程的读操作可见,可以说它是可见性与有序性的一套规则总结。

    JMM(java memory model,java内存模型)在以下的情况可以保证,线程对共享变量的写,对于其他线程是读可见的,最常见的有以下两种:

    • 使用synchronized关键字

      前面的文章提到过,当使用重量级锁时,对于共享变量的修改时要同步到主存的。

    • 使用volatile修饰的共享变量

    还有以下场景(更多的不在下面举例了):

    • 当线程修改共享变量的值,其结束后,其他线程对于修改后的值是可见的。

    • 线程start()之前,对于变量修改后的值,对其是可见的。

    • 线程t1修改变量的值,随后对正在读取该变量的t2进行打断,此时t1打断线程t2,则t2对于修改后的变量读可见。

    四、Double-Checked Locking

    相信同学们都学习过单例模式,应该都知道其有很多种实现方式,其中有一种就是double-checked locking(双重检查锁)的方式,如下所示:

    public class Singleton {
    
        /**
         * volatile 解决指令重排序导致的问题
         */
        private static volatile Singleton instance;
    
        public static Singleton getInstance() {
            if (instance == null) {
                synchronized (Singleton.class) {
                    if (instance == null) {
                        instance = new Singleton();
                    }
                }
            }
            return instance;
        }
    
        private Singleton() {
        }
    }
    

    通过我们的尝试知道DCL一定要加上volatile关键字去修饰实例变量instance,那么是为什么呢?

    我们先假设没有加volatile关键字的情况,这种情况下砸多线程情况下是会存在问题的。

    如下所示,是在没有添加volatile关键字时的字节码文件:

    public class com.cloud.bssp.designpatterns.singleton.lazy.dcl.Singleton {
      public static com.cloud.bssp.designpatterns.singleton.lazy.dcl.Singleton getInstance();
        Code:
           0: getstatic     #1                  // Field instance:Lcom/cloud/bssp/designpatterns/singleton/lazy/dcl/Singleton;
           3: ifnonnull     37
           6: ldc           #2                  // class com/cloud/bssp/designpatterns/singleton/lazy/dcl/Singleton
           8: dup
           9: astore_0
          10: monitorenter
          11: getstatic     #1                  // Field instance:Lcom/cloud/bssp/designpatterns/singleton/lazy/dcl/Singleton;
          14: ifnonnull     27
          17: new           #2                  // class com/cloud/bssp/designpatterns/singleton/lazy/dcl/Singleton
          20: dup
          21: invokespecial #3                  // Method "<init>":()V
          24: putstatic     #1                  // Field instance:Lcom/cloud/bssp/designpatterns/singleton/lazy/dcl/Singleton;
          27: aload_0
          28: monitorexit
          29: goto          37
          32: astore_1
          33: aload_0
          34: monitorexit
          35: aload_1
          36: athrow
          37: getstatic     #1                  // Field instance:Lcom/cloud/bssp/designpatterns/singleton/lazy/dcl/Singleton;
          40: areturn
        Exception table:
           from    to  target type
              11    29    32   any
              32    35    32   any
    }
    

    我们需要了解的是,jvm创建一个完整的对象实例需要两个步骤:

    • 实例化一个对象,即new 出来的对象,此时是一个默认的空对象,其属性等并没有赋值,只是创建了引用,我们可以认为此时是一个半初始化对象。

    • 初始化步骤,此时需要去调用对象的构造方法,完成属性的赋值等操作,只有经过此步骤才是一个完成的对象。

    对应到上面的字节码文件,分别是以下的代码:

    • 17:创建一个引用,将引用入栈
    • 20:复制地址引用,用于后面使用
    • 21:通过前面复制的地址引用,调用对象的构造方法
    • 24:将引用赋值到静态变量instance上

    相信同学们应该能够对应的上的。

    在jvm中呢,如果完全按照上面的步骤执行则不会有问题,但是jvm会优化为先执行24步骤,再执行21步骤,那么结果可想而知,此时静态变量是一个半初始化的对象。

    当另外的线程来执行getInstance方法时,获取静态实例对象instance,即字节码文件的第0行,此行代码是在锁synchronized(管程monitorenter)之外,谁来都可以执行,那么获取到了就是半初始对象,不是null,那么一定是有问题的。

    通过我们前面的学习,就可以用volatile来解决DCL的这个问题:

    这个volatile关键字在字节码是体现不出来的,但是手动标记一下它的位置,只保留主要位置:

           0: getstatic     #1                  // Field instance:Lcom/cloud/bssp/designpatterns/singleton/lazy/dcl/Singleton;
           --------------------- 此处加入读屏障 --------------------
           3: ifnonnull     37
           6: ldc           #2                  // class com/cloud/bssp/designpatterns/singleton/lazy/dcl/Singleton
           8: dup
           9: astore_0
          10: monitorenter
          11: getstatic     #1                  // Field instance:Lcom/cloud/bssp/designpatterns/singleton/lazy/dcl/Singleton;
          14: ifnonnull     27
          17: new           #2                  // class com/cloud/bssp/designpatterns/singleton/lazy/dcl/Singleton
          20: dup
          21: invokespecial #3                  // Method "<init>":()V
          24: putstatic     #1                  // Field instance:Lcom/cloud/bssp/designpatterns/singleton/lazy/dcl/Singleton;
          --------------------- 此处加入写屏障 --------------------
          27: aload_0
          28: monitorexit
    

    但是根据我们前面学习的,写屏障似乎并不能保证21和24的顺序不变啊,因为都是在写屏障之前,它只能保证写屏障之前的代码不会被放到写屏障后。那么它是如何解决的呢?

    其实在更加底层volatile转成汇编语言,是在该代码上增加了lock前缀,此时会将其之前的代码锁住,直到执行到这个lock,此时前面的代码都一定执行完了。

    从根本说volatile的实现是是一条CPU原语 lock addl。

    太过底层就不多赘述了,毕竟我也没学到位呢!!!!

    相关文章

      网友评论

        本文标题:java并发编程(十六)带你了解volatile原理

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