一. volatile语义
1. 保证可见性
volatile保证变量对所有线程的可见性,“可见性”是指当某个线程修改了变量的值,新值对于其他线程来说是可以立即得知的,但是volatile变量无法保证“原子性”。
示例如下:
public class VolatileTest {
public static volatile int race = 0;
private static final int THREADS_COUNT = 20;
private static CountDownLatch countDownLatch = new CountDownLatch(THREADS_COUNT);
public static void increase() {
race++;
}
@SneakyThrows
public static void main(String[] args) {
Thread[] threads = new Thread[THREADS_COUNT];
for (int i = 0; i < THREADS_COUNT; i++) {
threads[i] = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 10000; i++) {
increase();
}
countDownLatch.countDown();
}
});
threads[i].start();
}
countDownLatch.await();
System.out.println(race);
}
}
如果能够正确并发的话,最后输出的结果应该是200000,但运行得到的结果小于200000。
问题就出在race++
并非原子操作,使用javap反编译这段代码,可以发现只有一行代码的increase
方法在Class文件中是由4条字节码指令构成(return指令不是由race++
产生)。
public static synchronized void increase();
Code:
0: getstatic #3 // Field race:I
3: iconst_1
4: iadd
5: putstatic #3 // Field race:I
8: return
从字节码层面上可以分析出并发失败的原因:当getstatic
指令把race
的值取到操作栈顶时,volatile
关键字保证了race
的值在此时是正确的,但是在执行iconst_1
, iadd
这些指令时,其他线程可能已经把race的值改变了,而操作栈顶的值就变成了过期的数据,所以putstatic
指令执行后就可能把较小的race
值同步回主内存中。
2. 保证有序性(禁止重排序)
普通变量仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。
在执行程序时为了提高性能,编译器和处理器通常会对指令做重排序:
- 编译器重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序;
- 处理器重排序。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序;
指令重排序对单线程没有什么影响,他不会影响程序的运行结果,但是会影响多线程的正确性。
二. 使用条件
由于volatile
变量只能保证可见性,在不符合以下两条规则的运算场景中,我们仍然要通过加锁(使用synchronized
, 锁或原子类)来保证原子性。
- 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
- 变量不需要与其他的状态变量共同参与不变约束。
volatile经常用于两个场景:状态标记、double check
三. 使用规则
- 要求在工作内存中,每次使用
volatile
变量前都必须先从主内存刷新最新的值,用于保证能看到其他线程对volatile变量所做的修改。 - 要求在工作内存中,每次修改
volatile
变量后必须立刻同步回主内存中,用于保证其他线程可以看到本线程对volatile
变量所做的修改。 - 要求
volatile
变量不会被指令重排序优化,从而保证代码的执行顺序与程序的顺序相同。
四. volatile
原理
volatile
可以保证线程可见性且提供了一定的有序性,但是无法保证原子性。在JVM底层volatile
是采用lock
指令来实现的。
此处使用HSDIP
和JITWatch
来分析volatile
的底层实现原理。
1. 示例代码
package org.example;
public class Singleton {
private volatile static Singleton instance;
public static Singleton getInstance() {
if (null == instance) {
synchronized (Singleton.class) {
if (null == instance) {
instance = new Singleton();
}
}
}
return instance;
}
public static void main(String[] args) {
Singleton.getInstance();
}
}
- 注意代码的包路径,下面会用到。
- 需要进行编译,此处使用
maven
编译,编译后的class
文件位于target
目录下。
2. 安装HSDIP
插件
链接: https://pan.baidu.com/s/1izSh7z9RRLn7ppt8SJFFMQ
密码: 9qpc
上述插件适用于MAC系统,下载完毕后将插件放入/Library/Java/JavaVirtualMachines/jdk1.8.0_251.jdk/Contents/Home/jre/lib
目录下。
执行以下指令可以在控制台输出汇编代码:
java -XX:+UnlockDiagnosticVMOptions
-XX:+PrintAssembly
-Xcomp
-XX:CompileCommand=dontinline,org/example/Singleton::getInstance
-XX:CompileCommand=compileonly,org/example/Singleton::getInstance
org/example/Singleton
如果需要使用JITWatch进行分析,则需要输出日志文件,以下指令可以将结果输出到test.log
文件中。
java -XX:+UnlockDiagnosticVMOptions
-XX:+PrintAssembly
-Xcomp
-XX:CompileCommand=dontinline,org/example/Singleton::getInstance
-XX:CompileCommand=compileonly,org/example/Singleton::getInstance
-XX:+TraceClassLoading
-XX:+LogCompilation
-XX:LogFile=./test.log
org/example/Singleton
3. 安装JITWatch
JITWatch
的安装启动可参见官方github
说明: https://github.com/AdoptOpenJDK/jitwatch
。
启动成功后,需要先配置源码路径以及class
路径,
本项目路径为/workspace/hello
,因此源码路径配置为/workspace/hello/src/main/java
,class
路径配置为/workspace/hello/target/classes
。
配置完成后,导入test.log
文件,并点击start
按钮,通过TriView
界面即可看到源码、字节码以及汇编代码。
接下来将上述代码的volatile
关键词去掉,重新编译并且导出文件,通过JITWatch
工具可再次获得汇编代码。
4. 原理分析
通过对比发现,关键变化在于有volatile
修饰的变量,赋值后多执行了一个lock addl $0x0,(%rsp)
操作,这个操作的作用相当于一个内存屏障(Memory Barrier
或Memory Fence
,指重排序时不能把后面的指令重排序到内存屏障之前的位置)。
指令中的lock addl $0x0,(%rsp)
(把RSP寄存器的值加0)显然是一个空操作,之所以用这个空操作而不是空操作专用指令nop
,是因为lock前缀不允许配合nop
指令使用。这里的关键在于lock前缀,它的作用是将本处理器的缓存写入内存,该写入动作也会引起别的处理器或者别的内核无效化其缓存,通过这样一个空操作,可让前面volatile
变量的修改对其他处理器立即可见。
那为何说它禁止指令重排序呢?从硬件架构上讲,指令重排序是指处理器采用了允许将多条指令不按程序规定的顺序分开发送给各个相应的电路单元进行处理,但并不是说指令任意重排,处理器必须能正确处理指令依赖保障程序能得出正确的执行结果。
所以在同一个处理器中,重排序过的代码看起来仍然是有序的。因此lock addl $0x0,(%rsp)
指令把修改同步到内存中,意味着所有之前的操作都已经执行完成,这样便形成了“指令重排序无法越过内存屏障”的效果。
五. 参考资料
《深入理解Java虚拟机第三版》