关于线程可见性一个“诡异”的问题
我在之前的文章中提到过一个关于线程可见性例子:
static boolean keepRunning=true;
public static void main(String[] args) throws InterruptedException {
new Thread(()->{
while (keepRunning){
System.out.println();
}
}).start();
Thread.sleep(1000);
keepRunning=false;
如果执行上面的代码,大多人可能觉得会死循环,因为这里没有任何的同步策略,比如synchronized,Lock,atomic,volatile等关键字,也就是说没有任何同步策略保证,也就没有任何可见性,所以在主线程里面修改的变量,在另外一个线程里面可能看见也可能看不见,所以结果是不确定的,但实际上它总是停止的,不会陷入死循环,至于为什么,这个先不着急,我们接着再看下面的一段代码:
private static boolean flag=true; // main thread will call flag=false
private final static Object lock=new Object(); // lock condition
public static void thread1(){
while (flag){
synchronized (lock){
// some work
}
}
}
public static void main(String[] args) throws Exception {
Thread t1=new Thread(()->{
thread1();
});
t1.start();
Thread.sleep(1000);
flag=false;
// The program can stop normally
}
上面的这段程序其实跟我发的第一段代码类似,这里仅仅有一个同步块,但是程序也可以正常停止,看起来是非常诡异的,因为在JMM内存模型里面,没有volatile修饰的变量是不保证线程可见性的,此外我们发现这个变量也不在synchronized同步块里面,也就是说也不保证可见性,但程序为什么会终止呢?因为程序一旦终止,就意味着这个变量是具有可见性的,那么究竟是怎么回事?
其实这里是受happens-before关系的影响,看下面的一个例子:
public class Shared {
public int a;
public int b;
public volatile int c;
}
然后接着,我们在线程A里面给上面的变量赋值:
shared.a = 1;
shared.b = 2;
shared.c = 3;
然后我们在B线程里面我们访问这些值:
display(c);
display(b);
display(a);
如果c的值打印3,那么即使a和b没有volatile修饰,那么线程B里面也可以访问到其最新的变化分别是2和1,因为根据happens-before关系,如果线程A的写操作发生在线程B的读操作之前,那么写操作之前的所有的数据都会同步到内存,然后在屏障后的读操作会从主内存读取所有的最新的数据,所以a和b的值也会被另外一个线程可见,这其实一定程度上增强了volatile关键字的作用。
在java里面,我们都知道synchronized关键字拥有volatile关键字所有的功能,那么他们有一样的影响,接着我们分析上一个例子,因为jit的优化,如果没有happens-before关系,上面的循环语句:
while (flag){
synchronized (lock){
// some work
}
}
会被优化成:
if(flag){
while (true){
synchronized (lock){
//some work
}
}
}
如果被优化成这样,那么就不具有可见性了,正因为下面同步块的出现,才禁止了这种优化,因为同步块和volatile有一样的作用,所以具有增强的同步效果,在同步之前的代码,实际上是会从主内存加载的,所以flag变量即使没有volatile修饰,也有可见性了。下面我们看下println语句的源码,会发现它里面也有同步块:
public void println(String x) {
synchronized (this) {
print(x);
newLine();
}
}
所以就不难理解为什么都可以正常停止。到这里我们已经揭开这诡异问题的真面目。这里需要注意的是即使上面的代码结果是正确的,但这种编写代码的方式是不正确的,我们要避免这样做,因为它们看起来非常迷惑,所以如果我们需要可见性我们可以通过合理的同步来达到目的,例如使用volatile,synchronized,atomic等并发包里面的一些工具类,一定避免使用上面的方式。
最后关于synchronized同步块的条件,建议大家不要字符串做为锁,这里有几个弊端:
(1)字符串如果没有被final修饰,那么它的引用是可变的,这意味着这个锁可能会变成多个对象
(2)如果第三方的依赖包里面也有同样的锁字符串,那么就会冲突,这样来有可能导致莫名奇妙的问题。
所以这里推荐使用final修饰的Object对象的实例做为锁的条件。
总结:
本文通过两个诡异的案例,给大家展示了可能会遇到的一个奇怪的case,通过分析类比我们知道真正的原因是由于happen-before的关系,尽管从理论分析的通,但实际上它不是正确的使用方式,这一点大家一定要记住。