Java“Volatile”关键字浅析

volatile关键字浅析

前言:
在并发编程中分析线程安全的问题时往往需要切入点,那就是两大核心:JMM抽象内存模型以及happens-before规则(在这篇文章中已经经过了),三条性质:原子性,有序性和可见性。
volatile关键字实现和Java内存模型、重排序息息相关比如:内存模型的缓存和volatile关键字的可见性,也涉及到并发的三个性质:可见性、有序性和原子性。本文我们先了解java内存模型的基础(Memory model),然后分析volatile关键字。

线程级重排序

在执行程序时,为了提高性能,编译器和处理器经常对代码进行重排序。重排序主要包括三类:编译器优化的重排序,指令级并行的重排序和内存系统的重排序,其中指令级并行的重排序和内存系统的重排序属于处理器重排序。
对于编译器重排序,Java虚拟机可能会禁止部分类的编译器重排序。处理器重排序则是通过内存屏障临时禁止。

/**
 * @program: 个人demo
 * @description: 指令重排序和Volatile证明
 * @author: Mr.Hu
 * @create: 2019-01-06 18:23
 */
public class Main {
    static int a=-2,b=-1,x1,x2;         //类变量(静态变量和常量)系统默认初始化为0,为了区别我们赋值 -1 -2
    public static void main(String[] args)throws Exception {
//        while(x1<=0||x2<=0) {
//            a=-2;b=-1;
//            x1=x2=0;
            Thread one = new Thread(new Runnable() {
                @Override
                public void run() {
                    x1 = a;           //1
                    b = 1;            //2
                }
            });
            Thread two = new Thread(new Runnable() {
                @Override
                public void run() {
                    x2 = b;           //3
                    a = 2;            //4
                }
            });
            one.start();
            two.start();
            one.join();
            two.join();          //等待进程执行结束否则输出为0(初始值)
            System.out.println("x1:" + x1);
            System.out.println("x2:" + x2);
//        }
    }
}

以上代码执行结果是什么?

  1. 假设执行顺序:1-2-3-4 输出x1:-2 x2:1
  2. 假设执行顺序:3-4-1-2 输出x1:2 x2:-1
  3. 假设执行顺序:1-3-2-4、1-3-4-2、3-1-2-4、3-1-4-2 输出x1:-2 x2:-1

但是当我们去掉注释循环跑程序的过程中出现了另一种结果:x1:2 x2:1如图:
Java“Volatile”关键字浅析_第1张图片
这是怎么回事呢?原因是我们没有考虑到刚才说的重排序。单看本线程内,如果语句的顺序调整不会引发结果错误,那么这种重排序就是可能发生的。也就是说,虽然线程1中的代码是 1->2 这样的执行顺序,实际上 2->1 这样的执行顺序也是可能发生的,同理,4->3 也可能发生。如果重排序之后变成:4-3-2-1,就会输出如图的结果。

内存模型(Memory model)

说起内存模型,我们要学会区分jvm里面的内存区域和内存模型。jvm的内存区域就是我们平时说的包含堆、方法区(永久带)、java虚拟机栈、本地方法栈、程序计数器等。内存模型与内存区域不是同一层次的划分,这两者基本上没有关系。
为了解决CPU运算速度与内存存储速度之间的几个数量级的差距,引入了高速缓冲(catch)。只要系统只有一个 CPU 核在工作,一切都没问题。如果有多个核,每个核又都有自己的缓存,那么我们就遇到问题了:如果某个 CPU 缓存段中对应的内存内容被另外一个 CPU 偷偷改了,会发生什么?缓存和内存不一致了。为了解决一致性问题我们需要各个处理器在访问缓存的时候都要遵循一些缓存一致性协议协议(MESI 以及衍生协议:MESI 是 Modified、Exclusive、Shared、Invalid 的首字母缩写,代表四种缓存状态),每个处理器通过在总线上嗅探传播的数据检查自己的缓存是否过期,当处理器发现自己的缓存对应地址被修改将当前缓存行设置成无效状态,当处理器对着数据进行操作时,从新从内存中读入。不同的体系结构的物理机提供不同的内存模型,比如ARM 和 POWER 体系结构的机器拥有相对较弱的内存模型:这类 CPU 在读写指令重排序(reordering)方面有相当大的自由度,这种重排序有可能会改变程序在多核环境下的语义。通过“内存屏障(memory barrier)”,程序可以对此加以限制:“重排序操作不允许越过这条边界”。而X86拥有较强的内存模型。

问题:既然有MESI协议可以是保证cache一致性,为什么还需要volatile来保证可见性(内存屏障)?

  • 答:首先volatile不单单能够保证可见性,还能保证有序性。此外,缓存一致性只作用于缓存,即L1、L2、L3 cache,不能作用于寄存器。

[外链图片转存失败(img-TFfBnZcJ-1565319138791)(http://henan-hu.top/小书匠/1546759421132.svg)]
Java虚拟机想要创建一个屏蔽掉各种硬件和操作系统差异的内存模型,以期望实现“一处编写,出处使用”的效果。这个内存模型定义的必须足够严谨,可以使java程序并发访问时不会产生歧义,同时也必须足够宽松可以使虚拟机利用各种硬件平台的特性提高执行速度。同时为了获得更好地执行速度,Java内存模型没有限制即时编译器进行代码执行顺序调整这类优化措施(重排序),也没有限制有限制执行引擎使用处理器的寄存器或者高速缓存。
java内存模型主要目标是定义程序中变量的访问规则,规定了:

  1. 所有的变量存储在主内存(变量不包括局部变量与方法参数,因为它们是线程私有的,不会被共享,不会产生竞争问题,也就不需要操心)
  2. 线程对变量的所有操作都在工作内存中进行。
    线程、工作内存、主内存的关系如图所示:

[外链图片转存失败(img-w3HyTF8z-1565319138794)(http://henan-hu.top/小书匠/1546760842157.svg)]

从新考虑上面的代码:有了缓存之后,ab的值可能在缓存中,a的值可能为2-2,b的值可能为1-1如果执行顺序为:1-2-3-4但是,执行完1-2缓存并未更新到内存中,这样线程two无法获取值结果就变成了x1:-2 x2:-1。结果和上面已经存在的结果相同,但是造成的原因不同。

并发的三个性质:原子性、有序性和可见性

  1. 原子性:
    原子是元素能保持其化学性质的最小单位,在化学反应中不可分割。对应软件开发里面的一个完整的不可拆分的逻辑过程如事务,一个事务(transaction)中的所有操作,或者全部完成,或者全部不完成,不会结束在中间某个环节。
    java里面看着简单的操作不见得是原子操作:比如自增++ 编译成字节码后它分为三步:ILOAD(加载)-IINC(自增)-ISTORE(存储)。另一方面更复杂的CAS(Compare And Swap)操作却具有原子性。注意即使编译后只有一条字节码也不能保证这个操作一定具有原子性,一条字节码也可能转化成多条机器码。此处字节码已经能够说明问题,所以使用字节码分析。

  2. 有序性
    有序性即程序执行的顺序按照代码的先后顺序执行。
    在Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。(例如:重排的时候某些赋值会被提前)
    在Java里面,可以通过volatile关键字来保证一定的“有序性”(具体原理在下一节讲述)。另外可以通过synchronized和Lock来保证有序性,很显然,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性

  3. 可见性
    可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

volatile

Java 语言中的 volatile 变量可以被看作是一种 “轻量级synchronized”;与 synchronized 块相比,volatile 变量所需的编码较少,并且运行时开销也较少,但是它所能实现的功能也仅是 synchronized 的一部分。

一个变量定义为volatile之后就具备了两层语义:

  1. 保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
  2. 禁止进行指令重排序。
可见性

以下代码在高并发时可能存在问题,先运行线程一,在运行程序线程二,线程一的while理应立即停下。

boolean shutUp;
/********线程1*********/
private void speak(){
        while (!shutUp){
            System.out.println(1);
        }
    }
/*********线程2************/
shutUp=true;

但是在高并发场景中,可能存在线程二将shutUp在缓存中赋值为true后,时间片结束或者忙其他事情去了,没有将缓存回写到主内存,那么线程一将会继续循环输出。

原子性

接下来我们创建20个线程每个线程对 x 进行1000次自增,输出x的最后的值。x是volatile 变量初始值为0,如果volatile 变量具有原子性那么结果应该一直是20000。

/**
 * @program: 个人demo
 * @description: 原子性和volatile 
 * @author: Mr.Hu
 * @create: 2019-01-07 10:55
 */
public class atom {
    private volatile static int  x=0;
    public static void main (String[] args) throws Exception{
        for (int i = 0; i <20 ; i++) {
             new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int j = 0; j <1000 ; j++) {
                        x++;
                    }
                }
            }).start();
        }
        /********sleep等待所有线程执行完毕********/
        for (int i = 0; i <3 ; i++) {
            Thread.sleep(500);
            System.out.println(x);
        }

    }
}
/*****输出结果*******/
19238
19238
19238

Process finished with exit code 0

有多次输出结果均小于20000,主要原因在于x++这步中,自增++ 编译成字节码后它分为三步:ILOAD(i加载)-iconst_1(将1放到栈中)-Iadd(栈顶俩相加)-ISTORE(存储栈顶元素),ILOAD是正确的,但是在iconst_1和iadd命令执行时其它线程可能已经将x的值加大了。总之不是所有的volatile 变量具有原子性。

禁止重排序优化

volatile关键字禁止指令重排序有两层意思:

  1. 当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;
  2. 在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。
/**
 * @program: 个人demo
 * @description: 指令重排序和Volatile证明
 * @author: Mr.Hu
 * @create: 2019-01-06 18:23
 */
public class Main {
    static int a=-2,b=-1;         //类变量(静态变量和常量)系统默认初始化为0,为了区别我们赋值 -1 -2
    volatile  int x1,x2;			//x1 x2定义为volatile变量
    private void test()throws Exception{
        while(x1<=0||x2<=0) {
            a=-2;b=-1;
            x1=x2=0;
        Thread one = new Thread(new Runnable() {
            @Override
            public void run() {
                x1 = a;           //1
                b = 1;            //2
            }
        });
        Thread two = new Thread(new Runnable() {
            @Override
            public void run() {
                x2 = b;           //3
                a = 2;            //4
            }
        });
        one.start();
        two.start();
        one.join();
        two.join();          //等待进程执行结束否则输出为0(初始值)
        System.out.println("x1:" + x1);
        System.out.println("x2:" + x2);
        }

    }

    public static void main(String[] args)throws Exception {
        Main main= new Main();
        main.test();
    }
}

代码只是将/x1 x2定义为volatile变量,这个while循环再也没有停止过,说明重排序被禁止了。
实现原理:
有volatile关键字修饰的变量进行写操作时,会在多核处理器中的汇编指令前加上LOCk前缀,intel手册对lock前缀的说明如下:

  1. 确保对内存的读-改-写操作原子执行。在Pentium及Pentium之前的处理器中,带有lock前缀的指令在执行期间会锁住总线,使得其他处理器暂时无法通过总线访问内存。很显然,这会带来昂贵的开销。从Pentium 4,Intel Xeon及P6处理器开始,intel在原有总线锁的基础上做了一自增++ 编译成字节码后它分为三步:ILOAD(加载)-IINC(自增)-ISTORE(存储)个很有意义的优化:如果要访问的内存区域(area of memory)在lock前缀指令执行期间已经在处理器内部的缓存中被锁定(即包含该内存区域的缓存行当前处于独占或以修改状态),并且该内存区域被完全包含在单个缓存行(cache line)中,那么处理器将直接执行该指令。由于在指令执行期间该缓存行会一直被锁定,其它处理器无法读/写该指令要访问的内存区域,因此能保证指令执行的原子性。这个操作过程叫做缓存锁定(cache locking),缓存锁定将大大降低lock前缀指令的执行开销,但是当多处理器之间的竞争程度很高或者指令访问的内存地址未对齐时,仍然会锁住总线。
  2. 它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
  3. 把写缓冲行写回到内存中,这个操作会使其它CPU里缓存了该内存地址的数据无效。-- 这步操作代表上面的指令都已经回写,所有线程都已经知道,上面的指令已经执行完毕!这就造成了一种后面指令无法超越的“内存屏障”的效果。

上面的第1点保证了对单个Volatile 变量的读写操作是一个原子操作,但是类似Volatile 变量++这种复合操作整体上不具有原子性,第2点和第3点所具有的内存屏障效果,保证了volatile关键字变量具有上面1、 2点的内存语义。

总结:

  1. 变量定义为volatile:
    a. 只能保证此变量对所有线程的可见性(java运算并非原子操作,导致volatile在并发情况下不安全)
    b. 禁止指令重排序优化 (指令重排序优化:普通的变量只能保证拿到正确的结果,无法保证变量赋值操作顺序与程序代码中执行的顺序一样)

  2. volatile的原理和实现机制
    volatile到底如何保证可见性和禁止指令重排序的。
    “观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令”
    lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:

    1. 它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
    2. 它会强制将对缓存的修改操作立即写入主存;
    3. 如果是写操作,它会导致其他CPU中对应的缓存行无效。
  3. volatile能保证有序性吗?
    在前面提到volatile关键字能禁止指令重排序,所以volatile能在一定程度上保证有序性。
    volatile关键字禁止指令重排序有两层意思:

    1. 当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;
    2. 在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。
  4. 使用volatile关键字的场景
    synchronized关键字是防止多个线程同时执行一段代码,那么就会很影响程序执行效率,而volatile关键字在某些情况下性能要优于synchronized,但是要注意volatile关键字是无法替代synchronized关键字的,因为volatile关键字无法保证操作的原子性。通常来说,使用volatile必须具备以下2个条件:

    1. 对变量的写操作不依赖于当前值
    2. 该变量没有包含在具有其他变量的不变式中

    实际上,这些条件表明,可以被写入 volatile 变量的这些有效值独立于任何程序的状态,包括变量的当前状态。
    事实上,我的理解就是上面的2个条件需要保证操作是原子性操作,才能保证使用volatile关键字的程序在并发时能够正确执行。

很多程序员认为:“有volatile关键字修饰的变量具有可见性,其所有的变化可以立即被其它线程感知,所以volatile变量的运算在并发下是安全的。”这句话的论据正确但是结论是错误的。java里面的运算并非原子操作,volatile变量的运算在并发下一样不安全。

参考链接:
缓存一致性:https://www.infoq.cn/article/cache-coherency-primer
volatile和synchronized的实现分析:https://zhuanlan.zhihu.com/p/64532433
volatile与内存屏障总结:https://zhuanlan.zhihu.com/p/43526907

你可能感兴趣的:(后端)