首页 > 技术文章 > volatile实现原理

semi-sub 2020-05-10 18:43 原文

     在Java中我们都知道synchronized是一个重量级的锁,尽管JVM对synchronized关键字做出了许多优化,但是在多线程的情况下,synchronized的并发效率还是低下,而volatile是synchronized的轻量级的实现,在多线程编程中,能用volatile关键字解决的问题不用synchronized去解决。

      如果要讲volatile的实现原理,必须要从两个两个方面进行分析,一个是可见性(跟Java内存模型有关)另外一个是有序性(跟指令重排序有关)

     在Java并发编程中,存在三大特性:原子性可见性有序性

原子性

     这里的原子性其实跟我们数据库中的原子性有点类似,表示所有的操作要么全部执行,全部不执行。我们来看一个操作i++,表面上看就是一个自增操作,其实里面包含了三个指令操作,先从内存中read读操作,再+1赋值(assign)操作,最后写(write)操作写回内存中,原子性指这三个操作要么全部执行,要么全部不执行。

可见性

     在内存中,线程A修改了共享变量的值,那么线程B能够立即得知这个共享变量的修改,能够获得最新的值,这就表示对其他线程是可见的。

有序性

     程序按照我们所编码的先后顺序执行这就叫做有序性。但是,在JVM中,存在一种指令优化的技术即指令重排序技术,会使得程序不一定按照代码的顺序执行。比如:int a = 1; int b = 2; int c = a + b;那么int a = 1一定会在int b = 2;代码前执行吗,不一定,因为JVM认为a先执行和b先执行对线程结果没有影响,会存在指令优化,所以可能会是b先执行,但是c一定是在a和b的执行后面。


volatile
保证了三大特性中的可见性有序性。但是不保证其原子性,还是会存在并发问题。

我们先看volatile如何解决可见性问题,看一张Java内存模型图:

​  主内存:堆区 + 方法区。存放共享变量的地方,每次线程需要读取共享变量的值都会先从主内存中读取。

​  总线:也就是负责主内存和CPU之间的数据传输工作,就是我们拆开主机后备箱一根一根线。

​  工作内存:虚拟机栈。每个线程执行时,都会创建一个属于自己的工作内存,每次从主内存读取共享变量,便会放到自己的工作内存创建一个工作副本,线程对变量进行了修改,修改的也是自己工作内存中的变量副本,并不会立即同步到主内存中。

我们再来看一下主内存,总线,CPU之间的一个数据交互图:

​ 首先主内存中存在一个共享变量:flag = false;

​   (1).然后图上面有两个CPU表示有两个线程都从主内存中进行read操作,read操作表示把主内存中的值传输到线程的工作内存中,以便后续的load操作。

​   (2).read操作过后,load(载入)操作把read操作读取到的主内存变量值放入工作内存的变量副本。

​   (3).这时工作内存中已经存在变量副本,可以进行use(使用)操作了,use操作将工作内存变量值传递给执行引擎,给执行引擎进行使用。

​   (4).执行引擎修改了变量值后,需要通过assign(赋值)操作将修改后的值赋值给工作内存的变量副本。

​   (5).这是工作变量副本已经被修改,通过store(存储)操作将工作内存变量的值传送到主内存中,以便后续的write操作使用。

​   (6).store操作过后,write(写入)操作将store操作得到的变量值写入到主内存的变量中

以上(1)~(6)操作是主内存,工作内存之间的数据交互步骤。图中的右边的CPU修改了flag的值,并写入了主内存中,左边的CPU是如何读取到修改后的值了。

​    我们可以看到左边的CPU有一个箭头叫做cpu总线嗅探机制,也就是监听着总线上的数据变化,这个箭头是变量加了volatile修饰才有的。其实有很多都会采用这种监听机制来监控数据的变化,像我们的zookeeper注册中心,客户端都会有一个线程去监听注册中心上服务的变化。

​   当右边的CPU发现write操作,这时cpu总线嗅探机制便会感觉到数据发现变化,会使自己的工作内存中的变量副本失效,重新从主内存中读取。这就保证了变量的可见性,一个线程修改了共享变量,对其他线程是可见的。

  这里又有个小问题,细心的网友会发现如果左边的CPU重新读操作在右边的CPU写操作前面,那左边的CPU又会读到旧值。其实不会,看我们图中有个lock(锁定操作)表示一个变量被一个线程所独占。也就是我们右边的CPU去write操作时,左边的cpu并不能进行读取。直到unlock(解锁)操作


下面再来看下volatile是如何解决特性中的有序性问题

首先用volatile修饰的变量会禁止指令重排序优化,也就是会在指令加一层内存屏障,指令排序时不能把后面的指令排序到内存屏障之前的位置,也就是禁止了指令的重排序。

首先看几个语义:

    as-if-serial: 单线程运行下不允许改变执行结果

    happens-before: 如果线程B的执行结果会影响到线程A的执行结果,那么线程B和线程A之间就存在happens-before原则,线程B happens-before 线程A


我们首先看一张加和没加volatile关键字变量生成的字节码:

加了volatile关键字生成的字节码

没加volatile关键字生成的字节码

发现它们所生成的字节码是一样的,说明Java不是在编译阶段解决指令重排的,而是在运行期加上Lock汇编指令去完成指令重排的

我们来看一段代码编译成的汇编指令:

public class Test {

    private static volatile Boolean flag = false;

    public static void main(String[] args) throws InterruptedException {

        new Thread(new Runnable() {
            @Override
            public void run() {
                while (!flag) {
                }
                System.out.println("结束了");
            }
        }).start();
        Thread.sleep(2000);

        new Thread(new Runnable() {
            @Override
            public void run() {
                setFlag();
            }
        }).start();
        System.out.println("123213");
    }

    public static void setFlag() {
        flag = true;
    }
}

这段代码部分汇编指令如下:

  其中我们可以看到有一个lock汇编前缀指令,其实这个汇编lock指令就相当于一个内存屏障,防止后面的指令放到内存屏障之前的位置。Java代码要执行成汇编指令的话,可以去下载hsdis-amd64.dll文件,让我们的程序输出具体的汇编指令。这里不再具体讲解怎么操作了。


内存屏障又分为两种:Load Barrier(读屏障),Store Barrier(写屏障)

    对于Load Barrier来说,在指令前插入Load Barrier强制从主内存加载数据

    对于Store Barrier来说,在指令后插入Store Barrier能让写入的数据同步更新到主内存中,对其他线程可见

两种内存屏障又可以两两组合,最后形成四种组合方式:

LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。

StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。

LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。

StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。


用volatile修饰的变量:

    在这个变量写操作的时候,都会在写操作之前插入StoreStore屏障,在写操作后插入StoreLoad屏障

    在这个变量读操作的时候,都会在读操作之前插入LoadLoad屏障,在读操作后插入LoadStore屏障


总结

​     volatile修饰的变量主要保证了可见性,有序性。但是不保证其原子性。还是有可能存在并发问题。能用volatile解决的并发问题尽可能不要去使用synchronize。

推荐阅读