volatile详解

Posted by New Boy on February 14, 2018

前言

说到volatile关键字,应该是个很古老的关键字,然而这是一个很关键的一个关键字,从暑假接触到开始,看了许多的文章,也渐渐有了一些自己的理解。就来讲讲volatile这个东西。参考:Java内存访问重排序的研究

volatile特性

总的来说,volatile具有两个特性,一个是可见性,一个是禁止指令重排序

可见性

关于可见性的,之前忘记在哪本书上截了一张图,讲的是工作内存和主内存的关系以及volatile的可见性实现。

以下是测试可见性的一个程序

public class volatileTest {

    public static boolean flag = true;

    static class Task implements Runnable {

        @Override
        public void run() {
            int i = 0;
            while (flag) {
                i ++;
            }
            System.out.println("finished......");
        }
    }

    public static void main(String[] args) throws InterruptedException {
        new Thread(new Task()).start();
        Thread.sleep(1000);
        flag = false;
    }
}

上述程序一眼望去,完全ok,1s后,控制台输出“finished……”,然而,事实是程序无法终止。其原因就是Task线程没有读取到主内存中已经修改的flag变量,换言之Task线程的工作内存中flag变量依然是true,一直处于while循环当中。

要让程序正常结束,给flag变量添加volaatile关键字即可

另外在测试时还碰到一个奇怪的现象,如果在while(flag) {}循环体中添加System.out.println()或者Thread.sleep(),那么无论volatile关键字添加与否,程序均可正常退出。经过查阅资料与自己理解,究其原因,是在于工作内存和主内存之间的数据交换是基于线程有空闲的时候,如果说只是while(flag){i++;},那么就虚拟机就无法找到空闲的时间去进行数据交换,而对于Thread.sleep()和System.out.println(),即可进行内存数据的交换。

禁止指令重排序

还是先放张问题出现的情况吧:

public class volatileTest {

    static int a = 0, a1 = 0;
    static int b = 0, b1 = 0;

    static class TaskA implements Runnable {

        @Override
        public void run() {
            a = 1;
            a1 = b;
        }
    }
    static class TaskB implements Runnable {

        @Override
        public void run() {
            b = 1;
            b1 = a;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        int time = 0;
        while (true) {
            Thread aTask = new Thread(new TaskA());
            Thread bTask = new Thread(new TaskB());
            aTask.start();
            bTask.start();
            aTask.join();
            bTask.join();
            System.out.println(time++ + ": a1 = " + a1 + "and b1 = " + b1);
            if (a1 == 0 && b1 == 0) {
                break;
            }
            a = 0;
            a1 = 0;
            b = 0;
            b1 = 0;
        }
    }
}

以上代码经过测试,在执行到第100w+次时,程序结束。显然,按照正常逻辑不可能出现a1,b1均等于0的情况,然而它就是这样发生了,这简直比“墨菲定律”还不靠谱,你觉得不可能发生的事它都发生了,哈哈。其实这就是指令重排序做的梗。在机器看来,a=1和a1=b实际上是两个互不干涉的指令,于是它擅作主张,将a1=b提到了a=1之前执行。此时完整的执行过程是这样的:(a1=b, b=1, b1=a, a=1)。

说到指令重排序,其实涉及到的东西还是比较多的,所谓指令重排,顾名思义,重新编排指令执行顺序,java的JIT即时编译器也会有指令重排,其主要目的是在条件允许的情况下,执行之后的指令,以提高处理器的执行效率。

以上是指令重排的简单概念,为了实现禁止指令重排,JMM为此规定了一个happens-before的规则,happens-before的前后两个操作不会被重排序且后者对前者的内存可见,而对于具体的实现,则是通过了内存屏障(Memory 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读同步块入口 volatile写同步块出口
普通读       LoadStore
普通写       StoreStore
volatile读同步块入口 LoadLoad LoadStore LoadLoad LoadStore
volatile写同步块出口     StoreLoad StoreStore

当然以上内容为CPU中的指令,回到java volatile中,用张表格来说明一下(列代表第一个操作,行代表第二个操作):

是否允许重排序 普通读写 volatile读 volatile写
普通读写     不允许
volatile读 不允许 不允许 不允许
volatile写   不允许 不允许

初见此表,必有疑惑。首先是一个读写的概念。例如a++这个操作,首先是取出a对应的地址中的值,再通过运算器进行加1操作,再写回内存。其中第一步称为读,第三步称为写。

比如第二行第四列,普通读不能与其之后的所有volatile写重排序

知识误区

原子性的问题:看到很多文章中都提到了这么一个问题,估计踩到这个坑的人不少,volatile修饰的变量无法保证原子性,所谓原子性,就如a++,分为读,运算,写,三个部分,在多线程下是不安全的,volatile也无法保证这三个操作的原子性。可以使用concurrent下的atomic实现原子性。

和synchronized的区别的问题:synchronized属于重量级锁,volatile非常适用于单线程写,多线程读的场景。volatile更多的是去解决一个可见性的问题,在多线程问题上还是有很大的局限性的。

使用场景

其实随着synchronized近几年的优化,volatile对于性能的提升没有这么大了,但作为java底层关键字,掌握当然是必须的。另外,在netty中,volatile也是有着大量的应用,主要是和读写锁,cas一起使用,大大降低了锁的粒度,提升了netty的性能,也是netty成为现在非常流行的通行框架的原因之一。还有就是上面提到的,单线程写多线程读的场景。