volatile硬核剖析

一. 前言

  volatile在JAVA和C/C++中均有使用,而含义不同。不论是前者还是后者,在网上都流传着诸多误解,为此我们抛开一切中文资料,从JAVA、C/C++的文档手册、开发者的博客、stack overflow的高赞问题回复等源头去着手了解,并结合实际代码测试来验证其说法的可靠性。

二. Cache和内存

  众所周知,CPU有着高速缓存,而程序执行的时候,首先触发缺页中断加载如物理内存,然后由物理内存映射至CPU缓存,最后予以执行。而对于值保持不变的,我们会直接从缓存中读取,而不需要再去内存中重新读取,从而加快访问速度。而volatile,则是告诉编译器,该变量最好还是去内存读取,因为可能会被外部改变。 但是同样的语义,因为JVM和汇编的区别导致了其实现效果存在差异。

三. JAVA的volatile

  首先来看一个来自于DZone社区的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public class VolatileTest {
private static final Logger LOGGER = MyLoggerFactory.getSimplestLogger();

private static volatile int MY_INT = 0;

public static void main(String[] args) {
new ChangeListener().start();
new ChangeMaker().start();
}

static class ChangeListener extends Thread {
@Override
public void run() {
int local_value = MY_INT;
while ( local_value < 5){
if( local_value!= MY_INT){
LOGGER.log(Level.INFO,"Got Change for MY_INT : {0}", MY_INT);
local_value= MY_INT;
}
}
}
}

static class ChangeMaker extends Thread{
@Override
public void run() {

int local_value = MY_INT;
while (MY_INT <5){
LOGGER.log(Level.INFO, "Incrementing MY_INT to {0}", local_value+1);
MY_INT = ++local_value;
try {
Thread.sleep(500);
} catch (InterruptedException e) { e.printStackTrace(); }
}
}
}
}

  其输出如下,不难理解。每次MY_INT的改变会被ChangeListener ()监听并输出。

1
2
3
4
5
6
7
8
9
10
Incrementing MY_INT to 1
Got Change for MY_INT : 1
Incrementing MY_INT to 2
Got Change for MY_INT : 2
Incrementing MY_INT to 3
Got Change for MY_INT : 3
Incrementing MY_INT to 4
Got Change for MY_INT : 4
Incrementing MY_INT to 5
Got Change for MY_INT : 5

  而如果我们去掉了volatile,则输出会变成了

1
2
3
4
5
Incrementing MY_INT to 1
Incrementing MY_INT to 2
Incrementing MY_INT to 3
Incrementing MY_INT to 4
Incrementing MY_INT to 5

  造成该问题的核心点在于,volatile会告诉JVM,该变量可能会被别的线程修改,因此它会确保我们对于这个变量的读取和写入,都一定会同步到内存里,而不是从CPU的 Cache 里面读取。如果不加该关键字,则JVM可能会对程序进行一些优化,如从Cache中获取值(尤其是在看起来不会发生变化时),这就导致该线程会一直在死循环中忙忙碌碌。

四. C/C++的volatile

  同样的我们先来看一段代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// global shared data
bool flag = false;

thread1() {
flag = false;
Type* value = new Type(/* parameters */);
thread2(value);
while (true) {
if (flag == true) {
apply(value);
break;
}
}
thread2.join();
if (nullptr != value) { delete value; }
return;
}

thread2(Type* value) {
// do some evaluations
value->update(/* parameters */);
flag = true;
return;
}

  这段代码将 thread1 作为主线程,等待 thread2 准备好 value。因此,thread2 在更新 value之后将flag 置为真,而 thread1 死循环地检测 flag。但是,这段代码是有问题的。

  • thread1 中,flag = false 赋值之后,在 while 死循环里,没有任何机会修改flag 的值,因此在运行之前,编译器优化可能会将if (flag == true) 的内容全部优化掉
  • thread2 中,尽管逻辑上 update() 需要发生在 flag = true 之前,但编译器和 CPU 并不知道;因此编译器优化和 CPU 乱序执行可能会使 flag = true 发生在 update() 完成之前,因此thread1 执行 apply(value) 时可能 value 还未准备好。

  加上volatile是否可行呢?如果只对flag加上关键词是不够的,虽然if判断不会被优化掉了,但是编译器仍有可能在优化时将thread2中的 update 和对 flag 的赋值交换顺序。

  如果给value也加上呢?理论上来说是可以的,因为x86 和 AMD64 架构的 CPU(大多数个人机器和服务器使用这两种架构的 CPU)只允许 store-load 乱序,而不会发生 store-store 乱序。但是对于其他架构则无法保证,因此依然存在乱序执行的可能。

  通过该例子,我们学到了两件事

  • C/C++的volatile用于告诉编译器不要对该变量进行优化,从而避免了一些编译器优化导致的隐性问题。
  • C/C++直接面向汇编,因此在多线程中volatile不像JAVA的volatile有JVM兜底,可以保证多线程的准确性。因此不建议在多线程中采用

总结

  对于JAVA来说,volatile告诉了编译器每次都从内存中读取数值以保证其准确性,可以适当的使用。对于C/C++来说,多线程有太多的更好的选择,volatile的使用场景应局限于嵌入式系统或设备驱动中,需要读/写内存映射硬件设备时,告诉编译器不要将该部分进行了错误优化。

参考文献

[1] Java Volatile Keyword Explained by Example

[2] Why the “volatile” type class should not be used

[3] Should volatile Acquire Atomicity and Thread Visibility Semantics?

[4] What’s the difference of the usage of volatile between C/C++ and C#/Java?

[5] 谈谈 C/C++ 中的 volatile

坚持原创,坚持分享,谢谢鼓励和支持