1. 什么是 volatile 关键字
关键字 volatile 可以说是 Java 虚拟机提供的最轻量级的同步机制,当一个变量被关键字 volatile 修饰时,它将具备两项特性:
1. 保证变量对所有线程的可见性:可见性是指当一条线程修改了这个变量的值,修改后的新值可以立刻被其它线程知晓。
2. 禁止指令重排序优化:普通的变量仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致,而 volatile 则可以保证这一点。
2. 为什么可见性不代表线程安全
对于 volatile 的可见性,经常有这样的误解:“ volatile 变量在各个线程中是一致的,所以基于 volatile 变量的运算在并发下是线程安全的”。这句话的前半部分没什么问题,volatile 变量在各个线程中的确是一致的,但由于 Java 程序中的一条语句映射到物理机器上可能(或者说是基本上)会被转化成若干条本地机器码,也就是说它并不具有原子性,因此线程中变量的一致性并不能得出在并发运行时是线程安全的这一结论(即变量运算的过程中,内存中的变量可能就已经被其它线程修改了)。
为了证明关键字 volatile 并不能保证线程安全,这里给出了一段测试代码:
/**
* 在30个线程中让volatile变量自加1000次,等待所有线程执行结束后输出累加结果
* 以验证volatile变量是否线程安全
*/
public class VolatileTest {
public static volatile int counter = 0;
public static final int NUMBER_OF_THREADS = 30;
public static final int COUNT_VALUE = 1000; // 每个线程中都让counter自加1000次
public static void main(String[] args) {
Thread[] threads = new Thread[NUMBER_OF_THREADS];
for (int i = 0; i < NUMBER_OF_THREADS; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < COUNT_VALUE; j++) {
counter++;
}
});
threads[i].start();
}
for (Thread thread : threads) {
try {
thread.join(); // 等待线程结束
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println(counter);
}
}
/*
* 执行结果:
* 23015
*/
可以看到,程序的执行结果为23015(每次执行的结果会有所不同),与预期值不符(预期值为:30 * 1000 = 30000)这验证了前面的说法——关键字 volatile 并不能保证线程安全。
造成这一结果的原因在前面已经有所提及,就拿上面这个例子来说,加法运算在虚拟机中一般是先将操作数入栈,之后在对栈顶的元素执行相加的操作,这一系列的操作并不是原子的,即中途可以被打断。那么试想如果遇到下面这种情况,会产生什么结果?
1.线程A执行counter++操作,首先将线程内存中的counter入栈(假设此时counter = 0),然后再将被加数1入栈;
2.当线程A准备对栈顶两元素执行加法操作时,被线程B打断;
3.线程B顺利的完成了counter++的操作,并更新了内存中counter的值(counter = 1);
4.线程A继续执行剩下的操作,由于变量counter被volatile修饰,因此在线程内存中counter的值被更新为新值(内存中counter = 1),但堆栈中的值确不会被更新,依旧为原值(堆栈中counter = 0);
5.线程A执行完后续操作后,将counter写回线程内存中(写回堆存中的counter = 1),并通知所有线程更新counter的值;
6.最终的结果是两次累加操作,最终只让counter的值增加了1,显然这不是线程安全的。
我们知道字节码指令并不都具有原子性,也就是说,即使编译出来只有一条字节码指令,解释器也可能需要运行许多行代码才能实现它的语义;如果是编译执行,一条字节码指令也可能转化成若干条本地机器码指令。这里单是从字节码层面上就已经可以证明关键字 volatile 并不能保证线程安全,更不必说更底层的机器码了。
3. 指令重排序
在文章的一开始就提到过:普通的变量仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。也就是说,我们所编写的代码在执行的时候,并不一定是按照我们所编写的顺序一行行的执行的,它们的执行顺序可能是经过重排序的。
3.1 流水线机制
也许有人会纳闷,好好的代码为什么要对它进行重排序呢?这就涉及到了CPU的流水线机制,现代CPU几乎都采用流水线机制加快指令的处理速度,那么什么是流水线机制呢?这里用一个经典的洗衣机与干衣机的例子进行说明:
- 首先假设洗衣机洗衣服需要花费1个小时,干衣机烘干衣物需要半小时;
- 正常情况下,一个人先洗衣在干衣需要1.5个小时,但我们注意到,在洗衣机运转的时候干衣机是空闲的,反之亦然,这种利用效率无疑是很低的;
- 现在我们这么做,一个人洗好衣服后,进行干衣的时候,就让下一个人去洗衣服,这样一轮洗衣+干衣的时间就被压缩到了1小时(由耗时最长的操作决定);
- 为进一步提升效率,可以再购置台洗衣机,并让它们有序的运行(两台洗衣机洗衣机结束的时间相差半小时,并和干衣机正确的衔接),这样一轮洗衣+干衣的时间就被压缩到了半小时。
- 这就是流水线的思想,当然上述例子只涉及两个操作,当有多个操作的时候利用多级的流水线,也可以达到上面类似的效果。
通过指令重排序,可以根据流水线的需求,合理的修改语句的执行顺序,在保证执行结果正确的前提下(当然这里指的是串行的情况下),尽可能的减少流水线中的空操作,大大的提升了执行效率。
3.2 指令重排序带来的问题
虽然指令重排序看上去很美好,但在并行的环境中就有可能会出现问题。
- 假设在线程A中有一段初始化方法,方法在初始化结束之后,会修改变量A的值(用于判断是否初始化结束);
- 由于在串行语义中,只是在方法中对变量A进行了赋值,并没有其它与其相关联的操作,因此赋值操作很可能会被重排序(初始化还未完成时就被置位);
- 这时候另一个线程需要判断初始化是否结束,就去查询了变量A的值,由于指令重排序,此线程可能会在初始化还未结束的时候,就得到一个标识着初始化结束含义的变量的值,那么线程后续的操作必然会受到影响。
而利用关键字 volatile 就可以避免这种情况的发生,被 volatile 修饰的变量可以禁止指令重排序优化。