自java 1.5版本起,volatile关键字所扮演的作用越来越重要。该关键字在并发包(JUC)中使用得非常广泛,因此掌握volatile对进一步提升技术大有裨益。所有的原子数据类型都以此作为修饰,相比synchronized关键字,volatile被称为"轻量级锁",能实现部分synchronized关键字的语义。
我们先看一个案例:两个线程,一个是Reader线程,一个是Updater线程,都访问共享变量init_value,看看会不会导致线程安全问题。
import java.util.concurrent.TimeUnit;
public class VolatileDemo {
final static int MAX = 5;
static int init_value = 0;
//static volatile int init_value = 0;
public static void main(String[] args) {
new Thread(() -> {
int localValue = init_value;
while (localValue < MAX) {
// 若 init_value的值发生变化,下面打印相应的信息
if (init_value != localValue) {
System.out.printf("The init_value is updated to [%d]\n", init_value);
// 对localValue重新赋值
localValue = init_value;
}
}
}, "Reader").start();
new Thread(() -> {
int localValue = init_value;
while (localValue < MAX) {
System.out.printf("The init_value will be updated to [%d]\n", ++localValue);
init_value = localValue;
// 短暂休眠,让Reader线程能够来得及输出变化后的内容
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
}, "Updater").start();
}
}
执行上面的代码,结果如下:
控制台仅仅输出Updater线程的内容,而Reader线程没有输出,说明Reader线程并没有感知到init_value的值发生变化。这和预期的结果不符。
// 用volatile关键字修饰
static volatile int init_value = 0;
用volatile关键字修饰init_value字段后,从新执行上面的案例,结果如下:
用volatile关键字修饰共享变量后,得到预期的结果。经过这个案例,我们初步了解到volatile关键字的作用了。下面带着疑问继续往下读吧。
注意点:volatile关键字只能修饰 类变量和实例变量,不能修饰方法参数、局部变量、实例常量和类常量。比如上面案例中的MAX就不能使用volatile修饰。
要弄清楚volatile关键字的来龙去脉,需要具备java内存模型(JMM)和CPU缓存模型等知识。
计算机中的所有运算操作都是由CPU的寄存器来完成的,CPU指令的执行过程需要涉及数据的读取和写入,这些数据只能来自于计算机主存(通常指RAM)。
CPU的处理速度和内存的访问速度差距巨大,直连内存的访问方式使得CPU资源没有得到充分合理的利用,于是产生了在CPU与主存之间增加高速缓存CPU Cache的设计。现在的缓存数量一般都可以达到3级,最靠近CPU的缓存称为L1,然后依次是L2,L3和主内存,CPU Cache模型如下图所示。
由于指令和数据的行为和热点分布差异很大,因此将L1按照用途划分为L1i(instruction)和L1d(data)。在多核CPU的结构中,L1和L2是CPU私有的,L3则是所有CPU共享的。
Cache的出现解决了CPU直接访问内存效率低下的问题。但同时也引入了缓存一致性(Cache Coherence) 的问题。比如i++操作的处理过程:
在多线程情况下,每个线程都有自己的工作内存(本地内存),共享变量i会在多个线程的本地内存中存储一个副本。如果两个线程同时执行i++操作,假设i的初始值是1,每一个线程都从主内存中获取i的值存入CPU Cache中,执行加1操作再写入到主存中,都自增1可能就会导致两次自增之后的结果是2,这就是典型的CPU 缓存不一致性问题。主流解决方案有如下两种:
java内存模型(Java Memory Mode,JMM),指定了Java虚拟机如何与计算机的主存(RAM)进行工作。
Java内存模型决定了一个线程对共享变量的写入何时对其他线程可见,Java内存模型定义了一个线程和主内存之间的抽象关系,具体如下:
并发编程有三个至关重要的特性,分别是:原子性、有序性、可见性,下面逐个介绍。
所谓原子性是指在一次的操作或者多次操作中,要么所有的操作都得到了执行并且不会受到任何因素的干扰或中断,要么所有的操作都不执行。(All Or Nothing)
两个原子性的操作结合在一起未必还是原子性的,比如:i++; volatile关键字不能保证原子性,synchronized关键字可以保证,自JDK1.5版本,java提供了原子类型变量可以保证原子性。
可见性是指:当一个线程对共享变量进行了修改,那么另外的线程可以立即看到修改后的最新值。这一点,可以回顾一下上面的案例。Reader线程将init_value缓存到CPU Cache中,Updater线程对init_value的修改对Reader线程是不可见的。
volatile关键字能保证可见性。
由于java编译器以及运行期间的优化,导致代码执行的顺序未必就是开发者编写代码时的顺序。比如:
int x=9; // 语句1
int y=8; // 语句2
y=10; // 语句3
x++; // 语句4
从编写代码的角度看上面的代码肯定是顺序执行的,但是在JVM真正执行这段代码的时候未必是这样的顺序,可能语句2在语句1的前面得到了执行,这种情况我们称为“指令重排序”(Instruction Reorder)。当然指令重排序要严格遵循指令之间的数据依赖关系,不能任意重排(如语句2和语句3不会发生指令重排序)。
单线程情况下,无论怎么样的重排序最终都会保证程序的执行结果和代码顺序执行的结果是完全一致的。但在多线程情况下,指令重排序可能会造成非常大的问题,如下面的代码片段:
private boolean initialized = false;
private Context context;
public Context load(){
if(!initialized ){
context=loadContext(); //语句1
initialized=true; //语句2
}
return context;
}
如果语句2的执行被重排序到语句1的前面,那么在高并发访问load方法时将会产生灾难性的后果。第一个线程判断initialized 为false执行了context的加载,但在执行loadContext()之前将initialized 置为true,另外一个线程也执行load方法,发现此时initialized 已经是true,则直接返回了未被加载成功的context,那么线程在后面的运行过程中势必会出现错误。
volatile关键字能保证有序性。
volatile关键字不具备保证原子性的语义。如果想使得某些代码具备原子性,需要使用关键字synchronized,或者JUC中的lock。如果想使int等类型的自增具有原子性,可以使用JUC包下的原子封装类java.util.concurrent.atomic.*.
java提供了三种方式来保证可见性。
java内存模型允许编译器和处理器对指令进行重排序。但在多线程环境下,重排序会影响程序的正确执行。java提供了三种方式来保证有序性。
java内存模型具备一些天生的有序规则,不需要任何同步手段就能够保证有序性,这些规则称为“Happens-before原则”。如果两个操作的执行的顺序无法从Happens-before原则推导出来,那么他们无法保证有序性,虚拟机可以任意对他们进行重排序处理。具体的Happens-before原则请点击链接。
volatile对顺序性的保证比较霸道,直接禁止JVM和处理器对volatile关键字修饰的指令重排序,但对volatile前后无依赖关系的的指令可以随便重排序。
int x=9; //语句1
int y=0; //语句2
volatile int z=8; //语句3
x++; //语句4
y--; //语句5
上面的程序中,语句1和语句2的执行顺序无关紧要,只要百分之百的保证在语句3之前执行即可;同理语句4和语句5必须在语句3之后。
volatile关键字的作用,是靠“内存屏障”的方式实现。内存屏障会为指令的执行提供如下几个保障: