Java的volatile关键字用于标记一个Java变量为“在主存中存储”。更确切的说,对volatile变量的读取会从计算机的主存中读取,而不是从CPU缓存中读取,对volatile变量的写入会写入到主存中,而不只是写入到CPU缓存。
实际上,从Java5开始,volatile关键字不只是保证了volatile变量在主存中写入和读取,我回在后面的部分做相关的解释。
Java的volatile关键字保证了多个线程对变量值变化的可见性。这听起来有点抽象,让我来详细解释。
在一个多线程的程序中,当多个线程操作非volatile变量时,出于性能原因,每个线程会从主存中拷贝一份变量副本到一个CPU缓存中。如果你的计算机有多于一个CPU,每个线程可能会在不同的CPU中运行。这意味着每个简称拷贝变量到不同CPU的缓存中,如下图:
对于非volatile变量,并没有保证何时JVM从主存中读取数据到CPU缓存,或者从CPU缓存中写出数据到主存。这会导致一些问题。
想象一种情况,多于一个线程访问一个共享对象,这个共享对象包含一个计数变量如下声明:
public class ShareObject { public int counter = 0; }
考虑只有一个线程Thread1增加counter这个变量的值,但是Tread1和Thread2可能有时会读取counter变量。
如果counter变量没有被声明为volatile,就不能保证何时这个变量的值会从CPU缓存写回主存,这意味着,在CPU缓存中的counter变量的值可能和主存中的不一样。如下图所示:
线程没有看到一个变量最新更新的值的原因是这个变量还没有被一个线程写回到主存,这被称为“可见性”问题。一个线程对变量的更新对其他线程不可见。
Java的volatile关键字想要解决变量可见性问题。通过声明counter变量为volatile,所有对counter变量的写入都回立即写回到主存,同时所有对counter变量也都会从主存中读取。
西面的代码展示了如何把counter变量声明为volatile:
public class SharedObject { public volatile int counter = 0; }
声明一个变量为volatile保证了对变量的写入对其他线程的可见性。
在上面的场景中,一个线程(T1)修改了counter变量的值,另一个线程(T2)读取counter变量(但是不修改它),声明counter变量为volatile足以保证对counter变量的写入对T2可见。
但是,如果T1和T2都去增加counter变量的只,name声明counter变量为volatile是不够的,后面会说明。
实际上,Java的volatile的可见性保证不止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变量,则years和months的只也被写入到主存。
当读取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的最新的值,前提是保证上面的读取顺序。
出于性能的考量,JVM和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变量写入一个值,years和months新写入的只也刷入到主存,但是,如果有JVM指令重排序,像下面这样:
public void update(int years, int months, int days) { this.days = days; this.months = months; this.years = years; }
months和years的只在days变量修改的情况下依然会写入到主存,但是这时将years和days变量值刷入主存这件事发生在对months和years写入新值之前,则对years和days的更新对其他线程来说就不可见了。这下指令重排序就改变了程序的语义。
Java有一个应对此问题的解决方案,下面会讲到。
为了解决指令重排序的挑战,Java的volatile关键字除了可见性保证之外,给出了一个“happens-before”的保证。happens-before保证如下情况:
上面的happens-before保障保证的volatile关键字的可见性是强制的。
尽管volatile关键字保证了所有对一个volatile变量的读取都是从主存中读取,所有对volatile关键字的写入都是直接到主存,但是仍有其他情况使得声明一个变量为volatile是不足够的。
在前面解释的情况,也就是只有Thread1写共享变量counter,声明counter变量为volatile足以保证Thread2总是看到最新写入的值。
实际上,多线程都可以写一个共享的volatile变量,并且仍然在主存中存储正确的值,前提是写入变量的新值不依赖于它之前的值。也就是说,如果一个线程写入一个值到共享的volatile变量不需要先去读它的值去产出下一个值。
只要一个线程需要首先读取一个volatile变量的值,基于这个值生成一个新值,则一个volatile关键字不足以保证正确的可见性。在读取volatile变量然后写入新值的短暂的间隙,会产生竞态条件(race condition),这时多个线程可能读取到相同的volatile变量的值,生成这个变量的新值,当将新值写回主存时,会覆盖彼此的值。
多线程增加相同计数器的值就是这种情况,导致一个volatile声明不足够。下面详细解释这种情况。
想象如果Thread1读取一个值为0的共享的counter变量到它的CPU缓存,增加1并且不将这个改变的值写回主存。Thread2然后从主存中读取相同的值仍为0counter变量到它的CPU缓存。Thread2也为它增加1,也不写回主存。这种情况如下图所示:
Thread1和Thread2此时实际上已经不同步了。共享变量counter的值应该为2,但是每个线程在CPU缓存中的这个变量的值都为1,在主存中的值仍为0,这就乱了!尽管这两个线程最终会将值写回主存中的共享变量,这个值也是不正确的。
正如前面所说,如果两个线程都去读写同一个共享变量,只对这个共享变量使用volatile关键字是不够的。你需要使用一个 synchronized
关键字去保证读写相同变量是原子的。读写一个volatile变量不会阻塞线程的读写。
作为synchronized块替代方法,你可以使用 java.util.concurrent
包中的众多原子数据类型。比如,AtomicLong或者AtomicReference或其他的类型。
只有一个线程读写一个volatile变量值,其他线程只读取变量,则这些读线程能够保证看到写入这个volatile变量的最新值,如果不声明为volatile,则这种情况不能保证。
读写volatile变量会导致变量被读写到主存。读写主存比访问CPU缓存开销更大。访问volatile变量也会禁止指令重排序,而指令重排序是一个正正常的性能优化技术。因此,你应该只在真正需要保证变量可见性的时候使用volatile变量。