Table of Contents

 

变量可见性问题

Java volatile关键字保证了可见性

volatile如何保证完全可见性

指令重排序面临的挑战

Java volatile保证了Happens-Before

volatile也不是每次都管用的

那什么时候volatile才能真正发挥作用?

volatile对性能的影响


 

Java的volatile关键字是用来表名一个变量是“存储在主内存中”的,更精确地说,每次都从计算机的主内存中读取一个volatile的变量,而不是从CPU cache中,同时,每次都把volatile变量写到主内存中,而非CPU cache.

变量可见性问题

Java 的volatile关键字保证了线程之间对变量的可见性,这可能听上去有点抽象,那就让我娓娓道来吧!

在多线程的应用中,当一个线程操作一个非volatile变量时,出于性能的原因,都会把变量从主内存中拷贝到CPU cache中进行读写操作。根据计算机存储结构的金字塔模型,越是靠近CPU的存储设备,读写速度越是快(译者注)。假设你的电脑有不止一个CPU,那每个线程都运行在不同的CPU上。这就意味着,每个线程都把变量拷贝到不同CPU的CPU cache上,如图所示:     

java volitile关键字 java中voliate关键字_java

 

对于非volatile变量而言,JVM无法保证何时将此变量从主内存中读取到CPU cache,也无法保证何时从CPU cache写入主内存。这会引发一系列的问题,接下来的部分将具体分析。

想象这种情形,两个或两个更多的线程来竞争如下SharedObject对象中的counter变量,同时,既有线程对counter做加法,又有线程在读取counter的值。

public class SharedObject {

    public int counter = 0;

}

 

 

如果counter变量没有被声明为volatile,那counter变量何时从CPU cache写回到主内存是无法保证的,这就意味着,counter变量在CPU cache中的值和在主内存中的值就有可能不相等。这种情形如图所示:

java volitile关键字 java中voliate关键字_多线程_02

这种情况会导致其他线程无法看到变量的最新值,是因为该变量没有被另一个写线程及时写回到主内存中,这种现象被称为“可见性”问题。也就是说,一个线程对变量的更新操作并未被其他线程所看见。

Java volatile关键字保证了可见性

Java volatile关键字最初就是被设计成解决变量可见性问题的。只要将counter变量声明成volatile,那所有往counter变量的写操作都会立即被写回到主内存中。同理,所有对counter变量的读操作也会被直接从主内存中读取。

counter变量被这样声明成volatile:

public class SharedObject {

    public volatile int counter = 0;

}

 

这样,把变量声明为volatile以后,凡是对这个变量的写操作,都具有可见性。

根据前文所述,当一个线程(T1)修改counter,而另一个线程(T2)读取counter(但并未修改它)时,把counter声明为volatile就足够保证T2对该变量的可见性了。

但如果,T1和T2都同时对counter做加法,那仅仅把counter定义成volatile是不够的。请接着看。

volatile如何保证完全可见性

实际上,Java volatile关键字对可见性的保证不仅仅体现在volatile变量本身,这种可见性的保证还体现在如下两个方面:

  • 当线程A写入一个volatile变量,紧跟着线程B读取同一个volatile变量时,那所有位于线程A写入的volatile变量之前的其他变量,对于读取volatile变量的线程B而言,也具有可见性。
  • 当线程A读取一个volatile变量,线程A除了会从主内存中读取这个volatile变量,所有具有可见性的变量,都会从主内存中被读取。

这两句话真他妈拗口。还是看后面的例子吧。

public class MyClass {
    private int years;
    private int months
    private volatile int days;


    public void update(int years, int months, int days){
        this.years  = years;
        this.months = months;
        this.days   = days;
    }
}

 

update()方法往三个变量里写,这三个变量里只有days是volatile的。

volatile的完全可见性的体现在这里:当days被写入一个值的时候,对当前线程可见的所有变量都被写入到主内存中。这意味着,只要当days(volatile变量)被写入,那years和months变量(非volatile变量)也一起被写入主内存。

可以这样来读取years,months和days的值:

public class MyClass {
    private int years;
    private int months
    private volatile int days;

    public int totalDays() {
        int total = this.days;
        total += months * 30;
        total += years * 365;
        return total;
    }

    public void update(int years, int months, int days){
        this.years  = years;
        this.months = months;
        this.days   = days;
    }
}

 

注意到,totalDays()方法先读取days的值到total中,当读取days值的时候,months和years的值也一起被读入到主内存中。因此,根据上面这种读取的顺序,days,months和years的值将被保证看到的是最新的值。

指令重排序面临的挑战

出于某种性能的考虑,Java虚拟机和CPU会重排序程序中的指令(代码),只要保证指令的语义不变即可。比如下面的例子:

int a = 1;
int b = 2;

a++;
b++;

 上面的代码片段变成下面的代码片段以后,语义并没有什么改变。

int a = 1;
a++;

int b = 2;
b++;

 

但是当存在volatile变量时,指令重排序会面临一定的挑战,让我们回到之前的MyClass类的例子:

public class MyClass {
    private int years;
    private int months
    private volatile int days;


    public void update(int years, int months, int days){
        this.years  = years;
        this.months = months;
        this.days   = days;
    }
}

 


当update()方法将值写入days,那在此之前对months和years写入的值也将一起被写入主内存。但是,如果一旦Java虚拟机重排序了指令,像这样:


public void update(int years, int months, int days){
    this.days   = days;
    this.months = months;
    this.years  = years;
}

 

当days的值被修改时,months和years的值还是会被写入到主内存中,但由于这里将所有值写入主内存的操作发生在更新months和years的值之前,导致months和years写入的新值并无法及时被其他线程看见,所以,经过指令重排序以后的语义就变了

Java有解决这个问题的办法,接着看下一部分。

Java volatile保证了Happens-Before

为了解决指令重排序带来的挑战,Java volatile关键字对"happens-before"的情况做出可见性的保证,这种机制如下:

  • 如果代码中对非volatile变量的读、写的指令发生在对volatile变量的写指令之前,这些指令将确保不会被重排序。换言之,只要满足以下条件:非volatile变量的读写操作只要发生在volatile变量的写操作之前,代码都不会被重排序。即保证了对非volatile变量的操作“happen before”对volatile变量的操作。但请注意,如果对非volatile变量的读写操作发生在对volatile变量的写操作之后,还是有可能被重排序到对volatile变量的写操作之前的。总之,从后到前是允许的,但从前到后是不允许的。

因此,强制加上volatile关键字保证的可见性,是通过上述的这种“happens-before‘机制所保证的。

volatile也不是每次都管用的

即便volatile关键字可以保证,所有对volatile变量的读操作都是从主内存中直接读取的,所有对volatile变量的写操作也都是直接写入主内存,但在有些情况下,仅仅声明变量为volatile还是不够的。

正如前文描述的情形:只有一个线程1往共享的counter变量中写入值,那只要把counter声明为volatile,就可以足够保证另一个线程2可以看到counter的最新值。

事实上,对一个共享变量而言,如果新写入的值不依赖于之前的值,那么多个线程写入一个volatile变量的时候,都可以从主内存中读取到它正确的结果。换言之,只要在一个线程写入一个共享的volatile变量的时候,不需要根据他先前的值计算他的最新值,都不会有问题。

但是,一旦一个线程需要先读取一个volatile变量的值,再根据它读取到的结果,产生一个新的值然后再写入这个volatile变量,光靠volatile声明是不能保证变量正确的可见性的。从读取volatile变量的值到写入一个新值的过程中会产生一个时间间隔,这会造成一个”竞争条件“(race condition),从而多个线程同时读取到相同的volatile变量的值,然后各自产生一个新值写回到主内存中,这样这些线程就会相互覆盖别人的值,这会造成一系列问题。

比如说当多个线程都对同一个counter做加法,就是一种典型的光靠声明volatile变量是远远不够的情况。下面将举例详细说明。

想象一下,线程1读取了共享变量counter的值0到它所在的CPU cahce中,然后对它加1,但还没有写回到主内存中,此时,线程2也从主内存中读取到了共享变量counter的值(依然为0)到他的CPU cache中,线程2仍然会对counter加1,然后也没有写回到主内存中。如图所示:

java volitile关键字 java中voliate关键字_可见性_03

此时线程1和线程2处于不同步的状态,counter的值按理说应该为2,因为有两个线程对它进行加1操作,但由于这两个线程对它的加1操作还停留在线程各自的CPU cache里面,而此时主内存中的值依然为0,这就会造成混乱!即便这两个线程最终都把共享变量counter的值写回到主内存,结果依然是错误的(应该为2,但实际上可能是1)。

那什么时候volatile才能真正发挥作用?

如前文所述,如果两个线程都要对同一个共享变量进行读、写操作,仅仅使用volatile关键字是不够的。在这个例子中,需要使用synchronized关键字来保证读、写变量的原子性。一个线程读写volatile变量,并不会阻塞其他变量对它的读写操作,因此,必须使用synchronized关键字来保护临界区(critical section),这里就指对counter变量的读和写操作。

也可以使用java.util.concurrent包里面的许多原子数据类型来代替synchronized的阻塞代码块。比如AtomicLong或者AtomicReference等其他类型。

假设只有一个线程对一个volatile变量读、写,而其他线程只对该volatile变量读,那么,这些读线程被确保能看到该volatile变量的最新值,但如果不将变量声明为volatile,将无法保证可见性。

volatile关键字对32位和64位变量都起作用。

volatile对性能的影响

将变量声明为volatile,会使得变量的读写都在主内存中,但从主内存中读写变量比起直接在CPU cache中读写要开销大一些。指令重排序机制是一种比较常见的性能优化技术,但由于volatile变量的出现,阻止了重排序的发生,也会对性能多少产生一些影响。因此,只有当你真的需要强制保证变量的可见性的时候,才将变量声明成volatile。