并发编程的艺术-线程可见性volatile
简介
在多线程环境下,两个线程对同一变量进行操作时,互相是不可见状态,不可见状态就会导致一个线程修改了这个共享变量,而另一个线程不知道你去修改了,就可能导致变量最终预期与实际不一致的情况。为了确保共享变量能够保证一致性,volatile
为此而来,
示例
在简介当中,我们知道了volatile
是为了解决不可见性而来,在实际应用中,会带来什么问题呢? 举个简单的示例如下:
public static Boolean TYPE = true;
public static void main(String[] args) throws Exception {
Thread thread = new Thread(() -> {
int i = 0;
while (TYPE) {
i++;
}
});
thread.start();
System.out.println("线程已启动");
Thread.sleep(1000);
TYPE = false;
}
代码中只创建了个简单的线程,当TYPE
为true时,子线程会一直执行++操作,主线程改变TYPE为false,我们预期这个main方法执行后,应该为自动停止。但是实际结果中你会发现,这个代码会一直执行,永远循环下去。导致这个问题的原因就是线程thread
并没有发现TYPE
已经被主线程改变。那么如何解决问题呢?
- 方案一 增加本文重点
volatile
- 方案二 活性失败,比如在while循环中加入io操作,例如
System.out.print
- 方案三 增加JVM启动参数
-Djava.compiler = NONE
方案是有了,那怎么产生的呢?它们又是怎么解决的呢?
原因
编译器重排序
说到原因,不得不说HosSpot虚拟机的两个即时编译器
- Client Compiler
- Server Compiler
程序具体使用哪种编译器由JVM虚拟机来决定。
其中,上述代码的问题点就出现与ServerCompiler编译器,它是一个面向服务端并且充分优化的高级编译器,包含不限于:
- 无用代码消除(Dead Code Elimination)
- 循环表达式外提(Loop Expression Hoisting)
- 循环展开(Loop Unrolling)
其中,循环表达式外提就是导致示例代码出现问题的根本原因,经过它编译后的代码变为了
Thread thread = new Thread(() -> {
int i = 0;
if (TYPE) {
while (true) {
i++;
}
}
});
由此可以发现,经过它优化后,TYPE进入时为true,后续根本不会变化,所以其他线程对TYPE变更时,示例线程根本不会知道,所以我们可以采用方案三进行编译优化禁用,但是为了这一个小细节去影响整个全局的优化,代价是否有点大呢?
CPU高速缓存
除编译器优化外,CPU的高速缓存机制也会导致可见性问题,但是在CPU层面又有总线锁,缓存锁的机制去解决这类问题,总线锁和缓存锁通过Lock
信号触发,如果当前CPU支持缓存锁,则不会在总线上声明Lock
信号,而是基于缓存一致性协议来保证缓存的一致性。如果CPU不支持缓存锁,则会在总线上声明Lock
信号锁定总线,从而保证同一时刻只允许一个CPU对共享内存的读写操作。volatile
就是通过此方式来解决可见性问题。
CPU指令重排序
指令重排序,通俗来讲就是CPU在保证单线程情况下,重排序之后的运行结果与程序本身预期结果一致的前提下,进行重排序的一种机制。例如下面这篇示例:
public static void main(String[] args) throws InterruptedException {
int i = 5;
int y = 13;
int t = i + y;
}
上述代码我们可以知道,代码会从上到下依次执行, 但是i与y的执行顺序对于最终t的结果毫无影响,这时候就有可能发生指令重排序变为:
int y = 13;
int i = 5;
int t = i + y;
Volatile
我们在TYPE加入volatile关键字
public static volatile Boolean TYPE = true;
我们通过汇编指令启动main方法,会得到下面的结果:
0x00000207c9066087: lock add dword ptr [rsp],0h ;*putstatic TYPE
; - com.timeroar.blog.concurrent.voliat.VloliateThreadTest::<clinit>@4 (line 10)
大家发现没,主线程去修改TYPE
变量的值时,在修改命令前面会增加一个Lock
信号,原因是volatile
关键字会在JVM层面声明一个C++的volatile
,得到这个声明后JVM会调用storeload()
内存屏障方法,此方法会执行lock
指令,将volatile
声明的变量在CPU层面从Store Buffers中刷新到缓存行,这样当其他线程再去读取``volatile`声明的变量的值时,会从内存中或者其他缓存了此变量的缓存行中重新
加载,使得线程能够获得此变量最新的值。
Volatile的重排序规则
volatile并不会在所有情况都限制重排序规则,我们用一张表来解释Volatile什么时候允许重排序,什么时候不允许
第一个操作\第二个操作 | 普通读 | 普通写 | Volatile读 | Volatile写 |
---|---|---|---|---|
普通读 | X | |||
普通写 | X | |||
Volatile读 | X | X | X | X |
Volatile写 | X | X |
由图中展示,其实我们只需要记住三点即可:
- 当第一个操作是
volatile
写时,且第二个操作是volatile
读或写时,这两个操作不允许重排序。 - 当第二个操作是
volatile
写时,不管第一个操作的读/写是普通变量还是volatile
修饰的变量,都不允许这两个操作重排序。 - 当第一个操作是
volatile
读时,不管第二个操作的读/写是普通变量还是volatile
修饰的变量,都不允许这两个操作重排序。