Java volatile关键字解析

作者:薛8

来源:https://ddnd.cn/2019/03/19/java-volatile/

volatile简介

volatile被称为轻量级的synchronized,运行时开销比synchronized更小,在多线程并发编程中发挥着同步共享变量禁止处理器重排序的重要作用。建议在学习volatie之前,先看一下Java内存模型《什么是Java内存模型?》,因为volatile和Java内存模型有着莫大的关系。

Java内存模型

在学习volatie之前,需要补充下Java内存模型的相关(JMM)知识,我们知道Java线程的所有操作都是在工作区进行的,那么工作区和主存之间的变量是怎么进行交互的呢,可以用下面的图来表示。

http://static.cyblogs.com/e0e01e43ly1g186enjfwfj20k80degmr.jpg Java通过几种原子操作完成工作区内存主存的交互

  1. lock:作用于主存,把变量标识为线程独占状态。
  2. unlock:作用于主存,解除变量的独占状态。
  3. read:作用于主存,把一个变量的值通过主存传输到线程的工作区内存。
  4. load:作用于工作区内存,把read操作传过来的变量值储存到工作区内存的变量副本中。
  5. use:作用于工作内存,把工作区内存的变量副本传给执行引擎。
  6. assign:作用于工作区内存,把从执行引擎传过来的值赋值给工作区内存的变量副本。
  7. store:作用于工作区内存,把工作区内存的变量副本传给主存。
  8. write:作用于主存,把store操作传过来的值赋值给主存变量。

8个操作每个操作都是原子性的,但是几个操作连着一起就不是原子性了!

volatile原理

上面介绍了Java模型的8个操作,那么这8个操作和volatile又有着什么关系呢。

volatile的可见性

什么是可见性,用一个例子来解释,先看一段代码,加入线程1先执行,线程2再执行

//线程1
boolean stop = false;  
while (!stop) {  
    do();
} 

//线程2
stop = true;  

线程1执行后会进入到一个死循环中,当线程2执行后,线程1的死循环就一定会马上结束吗?答案是不一定,因为线程2执行完stop = true后,并不会马上将变量stop的值true写回主存中,也就是上图中的assign执行完成之后,storewrite并不会随着执行,线程1没有立即将修改后的变量的值更新到主存中,即使线程2及时将变量stop的值写回主存中了,线程1也没有了解到变量stop的值已被修改而去主存中重新获取,也就是线程1loadread操作并不会马上执行造成线程1的工作区内存中的变量副本不是最新的。这两个原因造成了线程1的死循环也就不会马上结束。 那么如何避免上诉的问题呢?我们可以使用volatile关键字修饰变量stop,如下

//线程1
volatile boolean stop = false;  
while (!stop) {  
    do();
} 

//线程2
stop = true;  

这样线程1每次读取变量stop的时候都会先去主存中获取变量stop最新的值,线程2每次修改变量stop的值之后都会马上将变量的值写回主存中,这样也就不会出现上述的问题了。

那么关键字volatie是如何做到的呢?volatie规定了上述8个操作的规则

  1. 只有当线程对变量执行的前一个操作load时,线程才能对变量执行use操作;只有线程的后一个操作是use时,线程才能对变量执行load操作。即规定了useloadread三个操作之间的约束关系,规定这三个操作必须连续的出现,保证了线程每次读取变量的值前都必须去主存获取最新的值
  2. 只有当前程对变量执行的前一个操作assign时,线程才能对变量执行store操作;只有线程的后一个操作是store时,线程才能对变量执行assign操作,即规定了assignstorewrite三个操作之间的约束关系,规定了这三个操作必须连续的出现,保证线程每次修改变量后都必须将变量的值写回主存

volatile的这两个规则,也正是保证了共享变量的可见性

volatile的有序性

有序性即程序执行的顺序按照代码的先后顺序执行,Java内存模型(JMM)允许编译器和处理器对指令进行重排序,但是规定了as-if-serial语义,即保证单线程情况下不管怎么重排序,程序的结果不能改变,如

double pi = 3.14;  //A  
double r = 1;     //B  
double s = pi * r * r; //C  

上面的代码可能按照A->B->C顺序执行,也有可能按照B->A->C顺序执行,这两种顺序都不会影响程序的结果。但是不会以C->A(B)->B(A)的顺序去执行,因为C语句是依赖于AB的,如果按照这样的顺序去执行就不能保证结果不变了(违背了as-if-serial)。

上面介绍的是单线程的执行,不管指令怎么重排序都不会影响结果,但是在多线程下就会出现问题了。 下面看个例子

double pi = 3.14;  
double r = 0;  
double s = 0;  
boolean start = false;  
//线程1
r = 10; //A  
start = true; //B

//线程2
if (start) {  //C  
    s = pi * r * r;  //D
}

线程1和线程2同时执行,线程1AB的执行顺序可能是A->B或者B->A(因为A和B之间没有依赖关系,可以指令重排序)。如果线程1按照A->B的顺序执行,那么线程2执行后的结果s就是我们想要的正确结果,如果线程1按照B->A的顺序执行,那么线程2执行后的结果s可能就不是我们想要的结果了,因为线程1将变量stop的值修改为true后,线程2马上获取到stoptrue然后执行C语句,然后执行D语句即s = 3.14 * 0 * 0,然后线程1再执行B语句,那么结果就是有问题了。

那么为了解决这个问题,我们可以在变量true加上关键字volatile

double pi = 3.14;  
double r = 0;  
double s = 0;  
volatile boolean start = false;  
//线程1
r = 10; //A  
start = true; //B

//线程2
if (start) {  //C  
    s = pi * r * r;  //D
}

这样线程1的执行顺序就只能是A->B了,因为关键字发挥了禁止处理器指令重排序的作用,所以线程2的执行结果就不会有问题了。

那么volatile是怎么实现禁止处理器重排序的呢? 编译器会在编译生成字节码的时候,在加有volatile关键字的变量的指令进行插入内存屏障来禁止特定类型的处理器重排序 我们先看内存屏障有哪些及发挥的作用 http://static.cyblogs.com/e0e01e43ly1g191y6o3paj21620f5qd7.jpg

  1. StoreStore屏障:禁止屏障上面变量的写和下面所有进行写的变量进行处理器重排序。
  2. StoreLoad屏障:禁止屏障上面变量的写和下面所有进行读的变量进行处理器重排序。
  3. LoadLoad屏障:禁止屏障上面变量的读和下面所有进行读的变量进行处理器重排序。
  4. LoadStore屏障:禁止屏障上面变量的读和下面所有进行写的变量进行处理器重排序。

再看volatile是怎么插入屏障的

  1. 在每个volatile变量的写前面插入一个StoreStore屏障。
  2. 在每个volatile变量的写后面插入一个StoreLoad屏障。
  3. 在每个volatile变量的读后面插入一个LoadLoad屏障。
  4. 在每个volatile变量的读后面插入一个LoadStore屏障。

注意:写操作是在volatile前后插入一个内存屏障,而读操作是在后面插入两个内存屏障。

http://static.cyblogs.com/e0e01e43ly1g186ext5z1j20h809vjsv.jpg

volatile变量通过插入内存屏障禁止了处理器重排序,从而解决了多线程环境下处理器重排序的问题

volatile有没有原子性?

上面分别介绍了volatile的可见性和有序性,那么volatile有原子性吗?我们先看一段代码

public class Test {  
    public volatile int inc = 0;

    public void increase() {
        inc++;
    }

    public static void main(String[] args) {
        final Test test = new Test();
        for(int i=0;i<10;i++){
            new Thread(){
                public void run() {
                    for(int j=0;j<1000;j++)
                        test.increase();
                };
            }.start();
        }

        while(Thread.activeCount()>1)  //保证前面的线程都执行完
            Thread.yield();
        System.out.println(test.inc);
    }
}

我们开启10个线程对volatile变量进行自增操作,每个线程对volatile变量执行1000次自增操作,那结果变量inc会是10000吗?答案是,变量inc的值基本都是小于10000。 可能你会有疑问,volatile变量inc不是保证了共享变量的可见性了吗,每次线程读取到的都是最新的值,是的没错,但是线程每次将值写回主存的时候并不能保证主存中的值没有被其他的线程修过过

http://static.cyblogs.com/WX20200212-225100@2x.png

如果所示:线程1在主存中获取了i的最新值(i=1),线程2也在主存中获取了i的最新值(i=1,注意这时候线程1并未对变量i进行修改,所以i的值还是1)),然后线程2将i自增后写回主存,这时候主存中i=2,到这里还没有问题,然后线程1又对i进行了自增写回了主存,这时候主存中i=2,也就是对i做了2次自增操作,结果i的结果只自增了1,问题就出来了这里。

为什么会有这个问题呢,前面我们提到了Java内存模型和主存之间交互的8个操作都是原子性的,但是他们的操作连在一起就不是原子性了,而volatile关键字也只是保证了useloadread三个操作连在一起时候的原子性,还有assignstorewrite这三个操作连在一起时候的原子性,也就是volatile关键字保证了变量读操作的原子性和写操作的原子性,而变量的自增过程需要对变量进行读和写两个过程,而这两个过程连在一起就不是原子性操作了。

所以说volatile变量对于变量的单独写操作/读操作是保证了原子性的,而常说的原子性包括读写操作连在一起,所以说对于volatile不保证原子性的。那么如何解决上面程序的问题呢?只能给increase方法加锁,让在多线程情况下只有一个线程能执行increase方法,也就是保证了一个线程对变量的读写是原子性的。当然还有个更优的方案,就是利用读写都为原子性的CAS,利用CASvolatile进行操作,既解决了volatile不保证原子性的问题,同时消耗也没加锁的方式大

volatile和CAS

学完volatile之后,是不是觉得volatileCAS有种似曾相识的感觉?那它们之间有什么关系或者区别呢。

  1. volatile只能保证共享变量的读和写操作单个操作的原子性,而CAS保证了共享变量的读和写两个操作一起的原子性(即CAS是原子性操作的)。
  2. volatile的实现基于JMM,而CAS的实现基于硬件。

参考

Java并发编程:volatile关键字解析 JAVA并发六:彻底理解volatile Java内存模型与volatile Java面试官最爱问的volatile关键字

如果大家喜欢我的文章,可以关注个人订阅号。欢迎随时留言、交流。

简栈文化服务订阅号