volatile关键字是Java虚拟机提供的最轻量级的线程间同步机制,我们很容易在书籍或网络上了解到volatile关键字的作用,主要有两点:
1、当一个变量被volatile修饰时,将保证此变量对所有线程的可见性。 这里的“可见性”指的是,当对一个变量修改后,新值对于其他线程是立即可见的,注意这里的立即可见,并不是说其他线程能监听到变量值修改,而是说修改后的值能立即同步到主内存中(稍后介绍Java内存模型),保证其他线程能读取到的一定是最新值。
2、使用volatile修饰的变量能禁止指令重排优化。 什么是指令重排优化呢,Java源代码最终会编译成计算机能识别的机器码指令,为了提高执行效率,编译器和处理器可能会对指令进行优化重新排序,导致实际上指令执行的顺序可能会和源代码中想表达的顺序不一致。
到此如果能轻松的理解上面两句话的,可以跳过本文。如果不能理解,下面我们将通过代码来验证上面的定义,在此之前,先简单介绍下Java的内存模型和相关概念,有助于更好的理解上述内容。
Java内存分为主内存和工作内存。所有变量都存在主内存中,每个线程都有自己的工作内存(可理解为缓存,实际上底层实现大概就是寄存器或高速缓存),线程的工作内存中将会拷贝主内存中变量的副本,所有的读写等操作都在工作内存中完成,线程的工作内存不能被其他线程访问。
线程、工作内存、主内存的关系如下图:
这里介绍下Java内存操作的三个基本概念: 原子性、可见性、有序性。这里只是简单介绍,有兴趣自行查阅资料。
原子性,可以理解为对某一块内存的操作不可再细分,也就是中间不能再插入其他操作。符合原子性的操作称为原子操作,Java内存模型定义下列8种原子操作:
lock/unlock:将主内存的变量锁定/解锁为一条线程独占状态,例如使用synchronized关键字标识的代码块。
read、load:read将主内存中的变量值传输到线程工作内存中,load将得到的变量值存入工作线程的变量副本。
use、assign:使用和赋值。
store、write:store将工作内存中的副本变量值传送到主内存,随后write操作写入主内存。
例如有一个变量A和B,表达式A = B完整的操作,可能会是read A、read B、load B、load A、use B‘、assign A’(=B‘)、store A’、write A。这里只是辅助理解,实际操作可能更为复杂,读、写操作中间是可以插入其他操作的。
可见性,上面已经给出过解释,就是当修改一个共享变量时,其他线程能立即“主动”获得最新值,也就是保证拿到的一定是最新的值。
有序性,在单个线程内,所有的操作都是有序的,程序会按照代码的顺序执行。但是多个线程并发时,因为有指令重排序,所以会影响指令的执行顺序。
首先“可见性”很容易被我们误解为“volatile修饰的变量是线程安全的”,我们用下面的代码验证下:
public static volatile int count = 0;
public static void add() {
count++;
}
public static void main(String[] args) {
int defCount = Thread.activeCount();
// 10个子线程
for (int i = 0; i < 10; i++) {
new Thread(() -> {
for (int j = 0; j < 1000; j++) {
add();
}
}).start();
}
// 等待子线程执行完毕
while (Thread.activeCount() > defCount) {
Thread.yield();
}
System.out.println("end: " + count);
}
执行结果大概率是小于10000的,通过命令“javap -c 类名.class”查看字节码,结果如下:
public static void add();
Code:
0: getstatic #2 // Field count:I
3: iconst_1
4: iadd
5: putstatic #2 // Field count:I
8: return
可以看出一条“count++”语句,并不是原子操作,它最终编译成了4条字节码,并且一条字节码指令在执行时还有可能被解释成多条机器指令。因此可见,只要不是原子操作,volatile关键字修饰的变量,并不是线程安全的。那么,volatile关键字的“可见性”到底有什么用,这里直接说结论。
一般来说,Java基本数据类型的读写是原子操作(64位的long、double是分两次读写例外),也就是说多线程环境下,只要满足以下一种以上情况,即可满足线程安全,否则只能通过synchronized或者Lock加锁满足原子性。
第一个,变量操作不依赖其他变量(包括自己),这时候不会依赖主内存中的值,只有纯写入操作。
第二个,只有一个线程会改变变量值,不会存在资源竞争。
因此,关于可见性,volatile关键字的经典用法如下:
volatile boolean isFinished = false;
// 线程A执行
public void finish() {
isFinished = true;
}
// 线程B执行
public void loop() {
while (!isFinished) {
// do something
}
}
这里线程A中写入变量isFinished的值,线程B立即能获取到最新的值,因此说变量isFinished对线程B可见。
以下场景比较常见,在一个线程初始化配置,另一个线程去等待初始化完成并使用配置,我们首先不对“isInitial”使用volatile关键字,运行以下代码:
// 参数
static Map mConfigs = null;
static boolean isInitial = false;
static int times = 0;
public static void test() {
for (;;) {
System.out.println("测试次数:" + times++);
int defCount = Thread.activeCount();
// 线程A
new Thread(() -> {
// 等待B初始化完成
while (!isInitial) {
Thread.yield();
}
if(null == mConfigs) {
System.out.println("发生指令重排:isInitial = true, mConfigs == null");
System.exit(1);
}
}).start();
// 线程B
new Thread(() -> {
// 模拟读取配置、文件等
mConfigs = new HashMap();
isInitial = true;
}).start();
// 等待线程A、B执行完毕
while (Thread.activeCount() > defCount) {
Thread.yield();
}
isInitial = false;
mConfigs = null;
}
}
理论上isInitial = true之后,模拟配置的变量mConfigs不可能为空,但是执行结果如下图:
........
测试次数:27731
测试次数:27732
发生指令重排:isInitial = true, mConfigs == null
Process finished with exit code 1
出现这个结果的原因,就是因为指令重排序优化,使“isInitial = true”这条代码对应的机器码指令提前执行。举个例子,假设有两条指令T1、T2,正常的执行顺序应该是T1 -> T2,但是执行器认为T1和T2无依赖关系并且先后顺序不会影响结果,比如“i + 2 + 3”和“(i + 3) + 2”,因此优化后实际顺序可能是T2 -> T1。
而关键字volatile的作用,是给变量加了一个内存屏障,使重排序优化时不能把后面的指令重排序到前面的位置,这样保证了一致性。(有兴趣可以尝试给上述代码中的“isInitial”加上volatile关键字,理论上电脑炸了也不会停下来)
例子,单例模式经典写法:
public class SingleTon {
// 使用volatile
private volatile static SingleTon instance;
public static SingleTon getInstance() {
if (null == instance) {
synchronized (SingleTon.class) {
if (null == instance) {
instance = new SingleTon();
}
}
}
return instance;
}
}
作为最轻量级的同步机制,volatile的总体性能是好于synchronized的,因为volatile使用内存屏障来保证写入顺序正确,所以仅仅是写入操作相对较普通变量耗时,读取操作基本不受影响,而synchronized使用monitorenter和monitorexit两条指令无论读、写都会lock内存保证安全,因此理论上读写性能都会受影响。所以在满足场景需求的前提下,优先使用volatile关键字解决并发问题。
最后,如有错误欢迎指出,以免误导它人。