volatile简介及可见性、有序性的保证

volatile简介

volatile是jvm提供的最轻量级的同步机制(相比于synchronized,其要轻量很多)

当一个变量定义为volatile后,其具备两种特性:

  • 此变量对所有线程的可见性
    • 可见性:当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。
  • 禁止指令重排序优化
    • 指令重排序:JVM为了进行优化,会对变量赋值等操作进行一系列的优化,其只保证了所有依赖赋值结果的地方都能获取到正确的结果,但不能保证该变量赋值操作的顺序与程序代码中的执行顺序一致。
    • 注意:重排序优化是机器级的优化操作,不是是Java源代码层面进行的。

可见性

普遍变量

首先,为什么普通变量不能做到可见性呢?

这里需要引入JMM(Java内存模型)。

  • 什么是JMM?

    由于在不同平台上内存模型的差异,可能同一个程序在一个平台上并发情况下可以正常运行,而在另一个平台上并发访问就出错,因此需要针对各种平台来实现一个统一的规范。由此,JVM规范了自身的JAVA内存模型(即JMM)来屏蔽操作系统的内存访问差异。

  • JMM简介

    JMM的知识点较多,这里只做简单介绍。

    首先上图
    在这里插入图片描述
    JMM规定了所有的变量都存储在主内存,而每条线程有自己的工作内存;

    线程的工作内存保存了该线程所使用到的变量的拷贝(注意:线程的工作内存只拷贝了对象的引用、和正在访问的对象中的某个字段,并不会完全拷贝此对象),线程都所有操作都是在自己的工作内存中进行的;

    (注意:JMM与JVM中内存区域的堆栈等区域不是一个层次的内存划分,读者不要混淆)

okay,引入完毕,回到刚才的问题,为什么普通变量不能做到可见性呢?

由上图及介绍可以知道,普通变量的值传递需要通过主内存来完成,例如:线程A修改一个变量的值,需要先向主内存回写后,另一个线程B等到A回写完成后再读取,才能够读取到变量的新值。

volatile修饰的变量

volatile怎样实现可见的呢?

有如下java代码

public class Test16 {
    private volatile int a=0;
    public void update() {
        a = 1;
    }
    public static void main(String[] args) {
    }
}

通过hsdis+jitwatch工具查看其汇编码(查看步骤见:here),如下:

......
  0x000000000295156d: lock addl %rdi,(%rdx)  
......

可以看到在volatile修饰的变量处,执行了lock addl....步骤,这个操作的lock作用把主内存的变量标示为独占内存的变量,此时会使得本CPU的Cache写入内存,同时令其他CPU或别的内核其cache失效,当其他CPU发现cache失效后,会从内存中重读该变量数据,即可以获取当前最新值。

通过以上的步骤,使用前面的volatile变量的修改对其他CPU立即可见。

  • 除了volatile之外,synchronizedfinal关键字也可以保证可见性

    synchroinzed:变量执行unlock操作(将处于锁定状态的变量释放出来,释放后其他线程才可以使用此变量)之前,必须先把此变量同步到主内存中。

    final:保障构造函数中对象不溢出的情况下,其他线程拿到的是初始化后的final对象。

volatile保证有序性就安全了吗

有如下例子:

package com.hpsyche;

/**
 * @author hpsyche
 * Create on 2019/12/24
 */
public class Test17 {
    public static volatile int race=0;

    public static void main(String[] args) throws InterruptedException {
        Thread[] threads=new Thread[50];
        for(int i=0;i<50;i++){
             threads[i]=new Thread(new Runnable() {
                @Override
                public void run() {
                    for(int i1 = 0; i1 <1000; i1++){
                        race++;
                    }
                }
            });
            threads[i].start();
        }
        for(int i=0;i<50;i++){
            threads[i].join();
        }
        System.out.println(race);
    }
}

运行结果:发现race最终变量小于50000;

通过javap反编译,查看字节码:

         0: iconst_0
         1: istore_1
         2: iload_1
         3: bipush        50
         5: if_icmpge     31
         8: new           #2                  // class java/lang/Thread
        11: dup
        12: new           #3                  // class com/hpsyche/Test17$1
        15: dup
        16: invokespecial #4                  // Method com/hpsyche/Test17$1."<init>":()V
        19: invokespecial #5                  // Method java/lang/Thread."<init>":(Ljava/lang/Runnable;)V
        22: invokevirtual #6                  // Method java/lang/Thread.start:()V
        25: iinc          1, 1
        28: goto          2
        31: getstatic     #7                  // Field java/lang/System.out:Ljava/io/PrintStream;
        34: getstatic     #8                  // Field race:I
        37: invokevirtual #9                  // Method java/io/PrintStream.println:(I)V
        40: return

可以看到,race++被不是一个原子操作,其有istore\iload\iinc等组成,执行这些指令是,其他线程有可能已经将race的值改变了,导致最终getstatic时同步到主内存的数据偏小。

此时我们可以将race++操作加上synchroinzed,或者使用jdk提供的AtmoicInteger类来确保race++的线程安全。

有序性

volatile怎么实现有序性

上文已经提过:重排序优化是指过程不保证,结果保证的一系列优化过程。而volatile关键字禁止了指令优化,那么其是怎样实现的呢?

在上文举例中提到汇编码:lock addl...,其中的lock还有一个作用,其相当于一个内存屏障(重排序时不能将后面的指令排序到内存屏障之前),内存屏障其通过一系列的屏障策略来实现有序。

关于内存屏障的更多细节可见:here

  • 除了volatile外,synchroinzed也可实现线程间操作的有序性,因为加了synchroinzed后,一个变量在同一个时刻只允许一个线程对其进行lock,也就保证了访问的先后性。

volatile典型使用

DCL实现的单例模式(双重检查加锁)

public class Singleton{
    private volatile static Singleton instance=null;
    private Singleton(){}
    public static Singleton getInstance(){
        //先检查实例是否存在,如果不存在才进入下面的同步块
        //避免synchroinzed资源的消耗
        if(instance==null){
            //同步块,线程安全的创建实例
            synchronized(Singleton.class){
                //检查实例是否存在,如果不存在才真正的创建实例
                if(instance==null){
                    instance=new Singleton();
                }
            }
        }
        return instance;
    }
}

上面的例子已经似乎可以保证单例了,那为什么还需要加volatile呢?

首先要理解new Singleton()做了什么。new一个对象有几个步骤。

1.看class对象是否加载,如果没有就先加载class对象;
2.分配内存空间,初始化实例;
3.调用构造函数;
4.返回地址给引用。

而cpu为了优化程序,可能会进行指令重排序,打乱这3,4这几个步骤,导致实例内存还没分配,就被使用了,当在并发的情况下,就可能出现线程B引用了线程A中还没有被完全初始化的变量。

而加了volatile之后,就保证new 不会被指令重排序。

总结

关于volatile关键字还有很多深入的细节,由于才疏学浅,这里我也只是简单的聊了下其特性,如果不足之处欢迎评论指出。

参考文献

《深入理解Java虚拟机》(第二版)——周志明

发布了63 篇原创文章 · 获赞 29 · 访问量 2万+

猜你喜欢

转载自blog.csdn.net/Hpsyche/article/details/103690885
今日推荐