线程安全问题(一)之可见性
可见性的概念
当一个线程修改了共享变量的值,其他线程能够看到修改的值。从定义上可以发现可见性更多的体现在线程的读操作上,在一个线程修改了共享变量的前提下,怎样保证让其它的线程都知道数据发送了变化。
示例
public class VisibilityDemo {
private boolean flag = true;
public static void main(String[] args) throws InterruptedException {
VisibilityDemo demo1 = new VisibilityDemo();
Thread thread1 = new Thread(new Runnable() {
public void run() {
int i = 0;
while (demo1.flag) {
i++;
}
System.out.println(i);
}
});
thread1.start();
TimeUnit.SECONDS.sleep(2);
// 设置is为false,使上面的线程结束while循环
demo1.flag = false;
System.out.println("被置为false了.");
}
}
在以上的代码中,我们在main方法中new了另一个线程,在这个线程中它不断的对i变量进行自增操作。
然后,主线程在休眠2s后,将flag为false了。那么thread1线程能否立即知晓主线程将flag置为false了呢?
“神经大条”童鞋站起来了,他自信的说:thread1是能立即知道主线程更改了flag的值的,理由很简单,因为主线程和thread1线程都是用的同一个flag变量。
“眉头一皱”童鞋发现事情并不简单,他认为thread1并不能立即知道主线程更改了flag的值的,因为thread1会读取CPU缓存中的flag变量,这个过程是非实时的。
那么结果到底是怎样呢?Linux 的创始人 Linus Torvalds大佬说过"Talk is cheap. Show me the code",知道这句话的你,知道该怎么做了吧。不妨跑跑这段代码吧。
最终有结果打印的,但是似乎并不能验证以上的问题。那么,有没有其它办法能验证呢?
试试将运行模式设置为 -server,再run一下代码,看看结果会是怎样。
似乎,代码陷入了死循环…这又是什么原因导致的呢?
为了验证这个问题,我们需要借助一个工具——jitwatch(能看到java编译后的汇编的工具)
- 下载 https://github.com/AdoptOpenJDK/jitwatch
- 解压 通过maven运行
mvn clean compile exec:java - 页面选择 config, 配置要调试的项目src源码路径,和class编译路径
打开jit.log
点击start - 在分析的结果中,选中指定的类,再选择右侧的具体方法,则弹出jit编译结果
最终界面如下:
通过jitwatch工具,我们可以发现,以下代码实际被"修改"
Thread thread1 = new Thread(new Runnable() {
public void run() {
int i = 0;
while (demo1.flag) {
i++;
}
System.out.println(i);
}
});
实际运行代码是如下这样。在server模式下。编译器会对代码进行优化——指令重排序,而client模式下不会进行优化。
Thread thread1 = new Thread(new Runnable() {
public void run() {
int i = 0;
// 指令重排序后
while (true) {
i++;
}
System.out.println(i);
}
});
产生以上问题的原因是什么呢?
可见性问题原因总结
-
java内存结构模型
当线程1将flag的值由true改为false后,线程2读取的仍然读取的可能是CPU缓存中flag=false的值,使得线程2对flag不可见(见下图)。java的内存模型结构使得一个线程修改了共享变量的值时,存在着可见性问题。
-
指令重排序
Java编程语言的语义允许编译器和微处理器执行优化,这些优化可以与不正确的同步代码交互,从而产生看似矛盾的行为。
简单来说,java编译器在编译代码时并不是“老老实实”地把java代码一行一行的编译,它会偷偷对代码进行一下优化,在单线程情况下这种优化不会产生任何问题。但是多线程情况下的重排序,则可能会产生一些“意外惊喜”。
例如:
thread-1 | thread-2 |
---|---|
1: r2 = A; | 3: r1 = B; |
2: B = 1; | 4: A = 2; |
期望结果:r1 = 1,r2 = 2
优化后:
thread-1 | thread-2 |
---|---|
B = 1; | r1 = B; |
r2 = A; | A = 2; |
可能产生结果:r1 = 0,r2 = 0
重排序后的结果,可能并不是我们所期望产生的结果,在多线程情况下的重排序,可能使得java编译器“好心办坏事”。(以上案例来源于oracle官方,传送门)
那么,当线程对一个共享变量进行更改后,怎么让其它线程能立即知道变量进行了更改呢?Java引入了一个关键字——volatile。
volatile关键字
volatile这个英文单词的释义:挥发性的;不稳定的。它可以用来修饰变量,如下所示。
private volatile boolean flag = true;
当一个变量被volatile修饰时,编译器则会"小心翼翼"的对待这个 变量。试着在原先的代码上对flag变量加上volatile,看看最终结果是否会有所不同。
是不是感觉一切又恢复了本该有的模样。
在oracle的文档中,关于volatile有以下描述。传送门
使用volatile变量可以降低内存一致性错误的风险,因为对volatile变量的任何写入都会建立与之后读取同一变量的happens-before关系。这意味着对volatile变量的更改始终对其他线程可见。
- 它强制性的要求,当一个线程对共享变量(volatile修饰的)进行更改后,必须立即写入主内存。同时当线程读取一个共享变量时,必须从主内存中读取。
- 同时也不允许编译器对代码进行指令重排序
综上所述,volatile关键字可以解决线程之间的可见性问题。看起来是那么美好,似乎线程之间关于共享变量的矛盾烟消云散了。但是,volatile关键字是否真的有那么强大,强大到能够解决线程之间对共享变量的操作么?事实上并非如此。回到java内存模型那张图,线程将data更改为2,看起来似乎是一步简简单单的操作。其实不然。
实际的场景分为了以下3个步骤。尽管volatile保证了从内存中读取和写入,但是,其他线程可能步骤1,2,3中的某个步骤将data改为了3。此时的结果依旧是不准确的。只有保证步骤1,2,3是不可分割的才行,此时又引入了线程并发的另一个特性——原子性。敬请期待,下回分解。