volatile是JVM中最轻量级的同步机制,当一个变量被定义为volatile变量之后,它将具备两个特性,一是保证此变量对所有线程的可见性,而是禁止指令重排序。
1、可见性
变量对所有线程的可见性指的是,当一个线程修改了这个变量的值,新的值对于其他线程来说是可以立即可见的,也就是其他线程再读取这个变量时,总是能读取到这个变量的最新值。而普通变量由于Java内存模型中线程的工作内存的存在,是做不到这点的。比如一个普通变量,线程A修改了它的值,线程B只有等待线程A将普通变量的值回写到主内存中之后,然后再从主内存中读取这个变量的值,这个变量的新值才会对线程B可见。
volatile的可见性可以理解为,对volatile变量的写操作,会使线程将其直接写回主内存,并通知其他线程之前读取的值已经失效;对volatile变量的读操作,会使线程直接从主内存里面读取值。
volatile变量对所有线程是立即可见的,对volatile变量的所有写操作都能立刻反应到其他线程之中,但是由于Java里面的运算并非原子操作,所以volatile变量在并发场景下并不是安全的。
对于一个volatile变量,在Java语言层面可以简单理解为,对此变量的读和写的原子操作是线程安全的,但是对a++这种复合操作是不安全的,代码层面可以理解如下两个示例是等效的:
class VolatileExample {
volatile int a;
private void setA(int n) {
a = n;
}
private int getA() {
return a;
}
private void increaseA() {
a++;
}
}
// 假设有多个线程调用上面的3个方法,那么在代码语义上,上述程序和以下程序是等价的
class VolatileExample {
int a;
private synchronized void setA(int n) {
a = n;
}
private synchronized int getA() {
return a;
}
private void increaseA() {
int temp = getA();
temp++;
setA(temp);
}
}
所以,在如下代码中,main线程中起了20个线程,每个线程都对变量a进行了10000次+1,但是最终的运行结果却总是一个小于200000的不确定的值。
public class VolatileTest {
public static volatile int a = 0;
public static void increase() {
a++;
}
public static void main(String[] args) throws InterruptedException {
for (int i=0; i<20; i++) {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
for(int i=0; i<10000; i++) {
increase();
}
}
});
thread.start();
}
// idea中运行main方法时,会起两个线程
// 除了main线程,还起了一个其他的线程(不知道是干啥的)
while(Thread.activeCount() > 2) {
Thread.yield();
}
System.out.println("a = " + a);
}
}
运行结果:
a = 192341
出现这样的结果的原因就是,虽然变量a是volatile变量,但是,increase()方法中,a++并不是一个原子性操作。从字节码层面来看,a++这一行语句是由4条字节码指令执行的:
首先getstatic指令把a的值取到操作栈顶时,volatile关键字保证了a的值此时是正确的,但是在执行iconst_1,iadd这些指令的时候,其他指令可能已经把a的值又加大了,而在操作栈顶的值就成为了过期的数据,所以putstatic指令执行后就可能把较小的值同步回了主内存之中,所以最后的值小于20000。
上述使用字节码分析这个问题并不严谨,但是在一定层面上已经能够说明这个问题。
虽然volatile在并发场景下并不能够保证线程安全的,但是在以下两种场景中,使用volatile就可以满足我们的需求:
- 只有一个线程会修改变量,其他线程只会读变量
- 变量无需与其他的状态变量共同参与不变约束
典型的适合使用volatile的场景:
volatile boolean shutdownRequested;
public void shutdown() {
shutdownRequested = true;
}
public void doWork() {
while (!shutdownRequested) {
// do something
}
}
2、禁止指令重排序
通过以下伪代码示例来进行说明:
Map configOptions;
char[] configText;
volatile boolean initialized = false;
// 假设以下代码在线程A中执行
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText, configOptions);
initialized = true;
// 假设以下代码在线程B中执行
while(!initialized) {
sleep();
}
doSomethingWithConfig(); //使用线程A中配置好的信息
如果initilized变量没有使用volatile修饰,由于指令重排序优化的原因,就有可能导致initialized = true被提前执行,在配置信息初始化完成之前就被置为了true,这样就导致线程B在执行doSomethingWithConfig()方法时,配置信息的初始化并没有完成。而volatile关键字则可以避免此类情况。
下面是一个实际例子,双重检查的单例模式(非线程安全情况):
public class Singleton {
private static Singleton instance;
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
上述代码不是线程安全的,这是因为,instance = new Singleton();这行代码在内存中实际上有如下3个步骤,而且步骤2和步骤3之间有可能会发生重排序:
memory = allocate(); // 1、为对象分配内存;
ctorInstance(memory); //2、初始化对象;
instance = memory; //3.将对象内存地址赋给引用变量
在单线程场景中,由于步骤4使用对象总是在步骤2和步骤3之后,因此就算是步骤2、3重排序了也没有问题。但是在多线程运行中,如果发生了重排序,就导致可能出现以下情况:线程B拿到对象开始使用时,该对象尚未完成初始化。
由于volatile的禁止指令重排序特性,因此,只需要将instance设置为volatile变量就可以解决以上问题。当instance为volatile变量时,步骤2、3不会发生指令重排序,因此多线程场景下,每个线程获取到对象的引用时,该对象一定是已经初始化完成了,不会存在线程不安全的情况。
双重检查的单例模式(线程安全情况):
public class Singleton {
private volatile static Singleton instance;
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
编译后,这段代码对Instance变量赋值部分如下所示:
从字节码层面分析,关键点在于,对于volatile修饰的instance变量,在其赋值后,多执行了一个lock操作,这个操作相当于是一个内存屏障。而在指令重排序优化过程中,是不能越过内存屏障的,也就是屏障后面的指令无法优化到内存屏障前面的位置来,屏障前面的指令也无法优化到内存屏障后面的位置去。因此,其赋值前的初始化对象的指令,不会被重排序到赋值指令的后面,也就是保证了赋值完成时,对象的初始化也一定已经完成。