本文转载于我的个人公众号“阿东编程之路”
volatile关键字在JUC并发包下随处可见,AtomicInteger等原子类就是的volatile加上cas同步操作实现的。
之前在《深入理解Java内存模型》里分析想要线程安全,就需要保证三大特性:原子性,有序性,可见性。而当一个变量被 volatile 关键字修饰是可以保证有序性和可见性的,这里的有序性就是防止处理器进行指令重排优化的乱序执行,而可见性是指当一条线程修改了这个变量的值,新值对于其他线程是立即可见的。
一. volatile的有序性
volatile是如何保证有序性的?
就拿DCL单例模式的代码进行分析:
public class Singleton {
private static volatile Singleton singleton;
private Singleton(){}
public static Singleton getSingleton() {
if (singleton == null) {
synchronized (Singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
public static void main(String[] args) {
Singleton.getSingleton();
}
}
直接看class字节码文件已经不够用了,需要再深入看下反汇编内容才能看到volatile的原理:
java -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly Singleton
(这里用的是HotSpot虚拟机即时编译代码的反汇编插件-HSDIS,直接在网上搜这个插件或者在github上下载:github.com/importsourc…
重点就在这个lock前缀指令上
执行逻辑精简如下:
-
lock之前有个mov的赋值操作,就对应着代码里的singleton = new Singleton(); 这个lock前缀的指令就相当于一个内存屏障,在进行指令重排时lock后面的指令(lock后面就是将对象返回的指令,反汇编的内容太多没截图出来,大家感兴趣可以使用JITWatch工具分析汇编内容)不允许在lock前缀指令之前执行。
-
在《单例模式那些事儿》里我们分析因为指令重拍导致的问题就是在内存中分配内存并赋值之前将singleton返回导致空指针异常,现在在这两步中间加了一层lock前缀指令(内存屏障),保证返回singleton之前分配内存赋值等操作执行完就可以防止指令重拍造成的问题了。
二. volatile的可见性
那线程的可见性又是如何保证的呢?
保证可见性的关键也在lock前缀指令上,当cpu执行到lock指令时不仅会防止指令重排,并且会将更新的值立即刷新回主内存,且写入操作会引起别的处理器或内核失效其缓存(总线嗅探),这种操作也就相当于之前说的Java内存模型的store和write操作。所以对于volatile修饰的变量,每次使用前都必须从主内存刷新最新的值,用于保证能看见别的线程对该变量的修改;而每次修改该变量后都立刻同步回主内存中,用来保证其他线程可以看到自己对该变量的修改。
三. volatile能保证原子性吗?
能保证有序性和可见性,那只用volatile关键字修饰的变量是线程安全的吗?
直接上代码:
public class VolatileDemo {
private static volatile int count = 0;
private static void increase() {
count ++;
}
public static void main(String[] args) {
for (int i = 0; i < 100; i ++) {
new Thread(() -> {
for (int m = 0; m < 10000; m ++) {
increase();
}
}).start();
}
System.out.println(count);
}
}
这个Demo是对 volatile 修饰的 count 变量进行自增的操作,启100个线程,每个线程对 count 变量自增10000次,正常情况最终的结果应该是1000000,但是每次执行 count 的值都是小于这个正确结果的。
第一次执行结果:981547
第二次执行结果:981012
第三次执行结果:988928
你可能认为 count++ 只有一行代码是可以保证原子性的,我们在上篇文章《深入理解Java内存模型》也分析了这个 count++ 反编译后是多条指令,再编译成机器码又会更多,count++ 的操作其实是先 load + read 到本地内存后,进行+1,再 store + write 到主内存,尽管我们在上面说了volatile修饰的变量会生成一个lock前缀指令,每次使用(use)都从主内存刷新新值,赋值(assign)都立刻写会主内存,但是 count++ 是一个先读值再赋值的两步操作,是不能保证原子性的,所以就会出现上面的多线程自增操作线程安全问题。
但是如果count ++ 的操作换成直接赋值就没问题了,所以volatile保证线程安全的前提是操作不依赖于原始值只有一步赋值操作。
所以volatile在很多场景下是无法替代锁的,那volatile有什么应用场景吗 ?
- 上面的DCL单例模式其实也是其中一个应用场景,这个场景用到的是volatile的防止指令重排序,因为我们为了减小锁的粒度提升性能用了synchronized代码块,如果是synchronized方法的话就不会出现多线程下无序性,自然也不用加volatile。还有应用场景也是并发去赋值,然后多线程去判断变量做一些业务逻辑的操作。
加了volatile会影响性能吗?
volatile变量读操作性能消耗和普通变量相差无几,写操作可能慢那么一丢丢,因为需要插入内存屏障(lock前缀指令),所以一般不会去考虑volatile造成的性能问题。
四. 总结
本篇文章讲了volatile是通过lock前缀指令(内存屏障)来保证有序性,写操作后立刻刷新回主内存和读操作每次都从主内存刷新来保证可见性,又论证了为什么保证不了原子性和volatile的一些常用的应用场景。
如果觉得文章不错可以点个赞和关注编辑**!**
参考书籍:
1.《深入理解Java虚拟机》 作者:周志明