被 volatile 修饰的变量具备两种特性:
1、保证该变量对所有线程的可见性;
2、禁止指令重排序优化。
可见性是指,当一个线程修改了这个变量的值,volatile
保证了新值能立即同步到主内存,同时其他线程每次使用前都会从主内存读取,从而新值对于其他线程来说是可以立即得知的。但普通变量做不到这点,普通变量的值在线程间传递均需要通过主内存来完成。
在 Java 内存模型(深入理解Java虚拟机之----Java内存模型)中,可以知道,在多线程中,每一个线程都有各自的工作内存,工作内存是该线程使用到的变量的主内存副本拷贝,对变量的所有操作(读取、赋值等)都必须在工作内存中进行,并且不会立即更新至主内存中。这就可能造成一个变量在一个线程中修改了,还没来得及更新至主内存,主内存中该变量的值就被其他线程使用了,而此时变量的值为修改前的旧值。
例如:线程 A 和线程 B 都用到了变量 C,线程 A 中对变量 C 执行了 +1 操作,由于是在线程 A 的工作内存中修改的,并且没有立即回写至主内存,而在此时刻线程 B 从主内存读取变量 C 的值,得到的是过时的结果----即线程 A 对变量 C 的修改对线程 B 来说不可见。
volatile
变量不一样,它拥有特殊的访问规则:
(1)使用前必须先从主内存刷新最新的值;
(2)修改后必须立刻同步回主内存中。
以上两条特殊规则,给人以 volatile 变量不是存在于工作内存而是存在于主内存中
的假象,从而保证了 volatile
变量对所有线程的可见性。
虽然 volatile
变量对所有线程是立即可见的,但是,基于 volatile
变量的运算在并发下不一定是安全的。例如:(注意:该例在 IDEA 中只能以 debug 模式运行,以 run 模式运行会陷入死循环,原因可以参考一下这个:在阅读《深入理解Java虚拟机》一书中,执行代码清单12-1的例子时,疑似发现bug?)
public class VolatileTest {
private static final int THREADS_COUNT = 20;
private static volatile int race = 0;
private static void increase() {
race++;
}
public static void main(String[] args) {
Thread[] threads = new Thread[THREADS_COUNT];
for (int i = 0; i < THREADS_COUNT; i++) {
threads[i] = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 10000; i++) {
increase();
}
}
});
threads[i].start();
}
while (Thread.activeCount() > 1) {
Thread.yield();
}
System.out.println("race = " + race);
}
}
output:
//这个输出应该每次都不一样,都是一个小于200000的数字。
race = 189716
如果能够正确并发的话,最后输出结果应该是 200000,然后结果却是小于 200000 的一个数,问题出在哪呢?其实就在 race++
中,race++
看似只有一行代码,其实是有三个操作:
a)读取 race 的值;
b)将 race 值加一;
c)将 race 值写回内存。
volatile
关键字能够保证 a)操作读取的 race 的值在这一时刻是正确的,但在执行 b)、c)操作时,可能有其他的线程已经对 race 的值进行了修改,导致了 c)操作可能把较小的 race 值同步回主内存之中。所以要想保证结果的正确性,需要在 increase()
方法加锁才行。
以下是适用 volatile
变量的两种运算场景:
运算结果并不依赖变量的当前值,或者能够保证确保只有单一的线程修改变量的值。
变量不需要与其他的状态变量共同参与不变约束。
像下面的代码就很适合使用 volatile
变量来控制并发:
volatile boolean shutdownRequested;
public void shutDown() {
shutdownRequested = true;
}
public void doWork() {
while (!shutdownRequested) {
//do stuff
}
}
但遇到不符合上述两条规则的运算场景时,就需要加锁来保证原子性。
为了尽可能的减小内存操作速度远慢于 CPU 运行速度所带来的 CPU 空置的影响,虚拟机会按照自己的一些规则将程序编写顺序打乱,而这一打乱,就可能干扰程序的并发执行。
例如:
Map configOptions;
char[] configText;
//此变量必须定义为 volatile
volatile boolean initialized = false;
//假设以下代码在线程 A 中运行
//模拟读取配置信息, 当读取完成后将 initialized 设置为 true 以告知其他线程配置可用
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText, configOptions);
initialized = true;
//假设以下代码在线程 B 中运行
//等待 initialized 为 true, 代表线程 A 已经把配置信息初始化完成
while(!initialized) {
sleep();
}
//使用线程 A 中初始化好的配置信息
doSomethingWithConfig();
假如上面伪代码中 initialized 没有用 volatile 修饰,就可能由于指令重排序的优化,导致位于线程 A 中最后一句代码 initialized = true;
被提前执行,这样在线程 B 中使用配置信息的代码就可能出错。
再比如,double-check locking 中:
public class Singleton {
private static volatile Singleton instance;
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
public static void main(String[] args) {
Singleton.getInstance();
}
这里 volatile 修饰变量的作用在于禁止指令重排序,而不是它的可见性。
在 Double-checked_locking 中提到:
Due to the semantics of some programming languages, the code generated by the compiler is allowed to update the shared variable to point to a partially constructed object before A has finished performing the initialization. For example, in Java if a call to a constructor has been inlined then the shared variable may immediately be updated once the storage has been allocated but before the inlined constructor initializes the object.[6]
由于某些编程语言的语义,允许编译器生成的代码在 A 完成初始化之前更新共享变量以指向部分构造的对象。例如:在 Java 中,如果共享变量的引用已经和构造函数的调用内联了,即使构造器未完成初始化,共享变量也可能立即被更新。
对应于 double-check locking 例子来说就是:
instance = new Singleton();
这一行代码分三个步骤:
(1)在堆上分配内存;
(2)赋初值;
(3)将 instance 引用指向堆地址。
假如是以(1)(3)(2)的顺序执行,其他线程就可能得到一个未完成初始化的 instance ,导致程序报错。而添加 volatile
修饰之后可以阻止指令的重排序(Java 1.5 及以后的版本),从而避免了这种 case。
参考资料:
(1)《深入理解java虚拟机》周志明 著.
(2)并发关键字volatile(重排序和内存屏障)
(3)Double-checked_locking