可见性问题实例

说到并发安全时,我们常提及可见性的问题,通俗点讲就是线程1看不到线程2写入变量v的值(更专业的解释以及是什么导致可见性问题,又该如何解决,见扩展阅读),但一直偏于理论,实际中有没有因可见性而导致问题的例子呢?回答是肯定的,接下来我们一起来看几个例子。

这个例子很简单,新建的线程里有一个普通变量stop,用来表示是否结束循环里的自增操作。主线程启动这个线程后,将该变量置为true,观察线程是否打印出finish loop那行,如果存在可见性问题,主线程修改stop值为true,线程v看stop的值应该还是false。

[code lang=”java”]
class VisibilityThread extends Thread {
private boolean stop;

public void run() {
int i = 0;
System.out.println("start loop.");
while(!getStop()) {
i++;
}
System.out.println("finish loop,i=" + i);
}

public void stopIt() {
stop = true;
}

public boolean getStop(){
return stop;
}
}

public class VisibilityTest {
public static void main(String[] args) throws Exception {
VisibilityThread v = new VisibilityThread();
v.start();

Thread.sleep(1000);//停顿1秒等待新启线程执行
System.out.println("即将置stop值为true");
v.stopIt();
Thread.sleep(1000);
System.out.println("finish main");
System.out.println("main中通过getStop获取的stop值:" + v.getStop());
}
}
[/code]

我们先来执行一遍(操作系统:XP,下同。JDK:见图示):

执行结果如上图,有人该问了,线程v最终停下来了,这不是表示它看到stop值为true了吗?是的,确实如此。但让我们再看一个这个程序的执行结果。

这一次,我们发现程序一直未能结束,表示线程v看到stop的值是false,但是主线程打印出的值却是true。

对比两次的执行方式,我们发现后一次加上了-server选项。显示version的时候也由Client VM变成了Server VM。那么Client VM与Server VM有什么区别在哪里?简单地讲,Client VM启动时做了一般优化,耗时少,启动快,但程序执行的也相对也较慢;Server VM启动的时候做了更多优化,耗时多,启动慢,但程序执行快。如果在运行java命令的时候没有指定具体模式的时候,会有一个默认值,这个默认值随硬件和操作系统的不同而不同,这里有张JDK 1.6在各平台默认VM模式的图。

我们再来看个例子,这个例子源于hotspot VM的一个bug:

[code lang=”java”]
public class InterruptedVisibilityTest {
public void think() {
System.out.println("新线程正在执行");
while (true) {
if (checkInterruptedStatus()) break;
}
System.out.println("新线程退出循环");
}

private boolean checkInterruptedStatus() {
return Thread.currentThread().isInterrupted();
}

public static void main(String[] args) throws Exception {
final InterruptedVisibilityTest test = new InterruptedVisibilityTest();
Thread thinkerThread = new Thread("Thinker") {
public void run() {
test.think();
}
};
thinkerThread.start();
Thread.sleep(1000);//等待新线程执行
System.out.println("马上中断thinkerThread");
thinkerThread.interrupt();
System.out.println("已经中断thinkerThread");
thinkerThread.join(3000);
if (thinkerThread.isAlive()) {
System.err.println("thinkerThread未能在中断后3s停止");
System.err.println("JMV bug");
System.err.println("主线程中检测thinkerThread的中断状态:" + thinkerThread.isInterrupted());
}
}
}
[/code]

这个例子也很简单,thinkerThread一直检查中断状态,主线程在启动thinkerThread之后的某个时刻调用interrupt中断thinkerThread。在《The Java Language Specification Java SE 7 Edition》§17.4.4中我们能够看到,如果线程1调用线程2的interrupt方法,那么所有线程(包括线程2)都能通过Thread.isInterrupted方法来检测到这个中断状态。这里直接用hotspot VM的-server模式执行一下,结果如下图:

thinkerThread没能退出循环,没看到主线程所置的中断状态。

后面这个例子是hotpost VM的一个bug导致的,在最新的hotspot中应该已经被修复了(笔者未测试最新版)。其它VM如IBM J9,JRockit,harmony等并没有发现这样的bug。说这是bug,是因为JLS中规定了main发出的中断必须对thinkerThread可见。但是,如第一个例子,则不是bug,因为JLS是允许这种行为的。当在第一个例子的循环中的i++后面加上一句Thread.yield()调用(该调用在规范中并没有特殊内存语义),这我使用的这个版本的VM上,就看不到可见性问题了。这也说明,JVM的优化是无法预知的,允许可见性的地方不一定就真会出现或一直出现。

JLS允许未充分同步的代码出现可见性问题,但是某个实际的JVM完全可以实现的比JLS上规定的更强,比如不允许可见性问题出现,那么,在这样的JVM上就展现不出这样的问题了。第一个例子这里只是运行在hotpost下,也许在其它JVM下同样采用最优化的方式执行,可能并不会出现这里的问题。

在我们编码的时候,也许并不知道代码会跑在什么样的系统上,不知道会采用什么样的JVM,为了使得写出的代码更健壮,我们只能按照规范所规定的最低保证去编码,要避免这类问题,只有保证代码充分同步,避免数据争用,而不应该依赖于某个具体JVM实现。即使是具体的某款JVM,不同的版本间也可能存在着差异。

最后,这样的例子启发我们,测试代码的时候应尽可能启用各JVM的最佳优化模式。

扩展阅读:

至此,我们已经了解到实际中多线程运行真的会出现这样的场景。为什么会出现可见性问题?有什么解决方案?下面链接中的内容为我们提供了专业的解答。

原创文章,转载请注明: 转载自并发编程网 – ifeve.com本文链接地址: 可见性问题实例

  • Trackback 关闭
  • 评论 (9)
    • fair_jm
    • 2013/03/16 3:52下午

    蛮好的..
    另外在1.6u38也未修复:
    java version “1.6.0_38”
    Java(TM) SE Runtime Environment (build 1.6.0_38-b05)
    Java HotSpot(TM) 64-Bit Server VM (build 20.13-b02, mixed mode)
    G:\tempForTest>java InterruptedVisibilityTest
    新线程正在执行
    马上中断thinkerThread
    已经中断thinkerThread
    thinkerThread未能在中断后3s停止
    JMV bug
    主线程中检测thinkerThread的中断状态:true

    • xieyuooo
    • 2013/03/16 11:57下午

    写得很好,竟然让你模拟出来了,呵呵!太牛了!我也测试过一把,呵呵,我测试的结果也是可见性问题,不过刚好相反就是了,我测试的结果就是那个死循环永远会循环下去,不会跳出来,因为那个线程没看到别人给他设置的结果,这个优化的过程的确是需要看具体的机器和VM以及OS的;

    我觉得可以补充点:
    1、当顺序执行多条代码,例如赋值语句,经过JIT优化后,顺序未必和原先一致,例如:先设置一些参数,再设置一个状态,表示参数已经设置好了,外部通过判定状态确定参数是否设置好,若顺序被发生改变,状态被先设置,参数没被设置,此时即使抛开可见性问题,外面读取到的参数可能是null或一个老值;

    2、可见性还体现在对long数据的操作,通常按照32bit为一个单位进行操作,假如当读取了前面4个字节,此时数据被修改,得到的结果是不可预知的。

    • Snway
    • 2013/03/18 9:52上午

    xieyuooo :
    写得很好,竟然让你模拟出来了,呵呵!太牛了!我也测试过一把,呵呵,我测试的结果也是可见性问题,不过刚好相反就是了,我测试的结果就是那个死循环永远会循环下去,不会跳出来,因为那个线程没看到别人给他设置的结果,这个优化的过程的确是需要看具体的机器和VM以及OS的;
    我觉得可以补充点:
    1、当顺序执行多条代码,例如赋值语句,经过JIT优化后,顺序未必和原先一致,例如:先设置一些参数,再设置一个状态,表示参数已经设置好了,外部通过判定状态确定参数是否设置好,若顺序被发生改变,状态被先设置,参数没被设置,此时即使抛开可见性问题,外面读取到的参数可能是null或一个老值;
    2、可见性还体现在对long数据的操作,通常按照32bit为一个单位进行操作,假如当读取了前面4个字节,此时数据被修改,得到的结果是不可预知的。

    觉得,第1点,说的是串行语义,第2点,个人觉得是跟原子性相关,而不是可见性问题。

      • xieyuooo
      • 2013/03/18 10:44上午

      这个看怎么看吧,我认为原子的概念,不仅仅包含读,而且包含修改、写入这两个动作才能叫做原子;

      可见性体现在每次读取到的内存信息是最新的,也就是在读的 瞬间是准确的,我是理解是这样的,你可能理解原子性只是在读取的时候,两次读取是不会被修改,其实可见性也是一把锁,只是粒度在读这个动作,原子一般体现在三个动作:读、改、写三个动作保证一致;Atomic系列的信息就保证了数据的原子性;

      第一个是的确是串行化,和可见性也有一些区别,不过串行化的目的也是让数据可见,因为顺序一旦被修改了,就不能得到正确的结果,概念上的确和可见性有些区别就是了,呵呵;

      上面的循环体,不仅仅加一个yield可以跳出来,加个sleep,或者做个字符串拼接,或者在命令行输出一条信息,或者做个其他的什么文件操作都应该会跳出来。

      • 诸如重排序、cache是否刷回内存、cache刷回内存的次序、内存是否会reload到cache、内存读到cache的次序等都可以造成可见性问题,这样想其实是个因果关系。 扩展阅读 中对可见性的原因与避免有很详细的介绍了。
        是的,循环中加入很多语句都可以跳出来,举这个例子是想说明JIT优化是不可预测的,如果没有充分同步的程序运行良好,后续维护的时候添加或减少了一些语句,可能问题就显现出来了。
        long/double直观上是一个原子性问题,但是读写时分裂开来,那么必然就有线程看到的值是不正确的,说有可见性问题似乎也说得通。

        • xieyuooo
        • 2013/03/18 1:08下午

        是的,加些区域跳出只是解决了这个问题,其实如果在实际的场景中如果if语句的话,这个地方就直接不是自己想要的结果了;在while循环体内,只要做赋值、自增、左移、右移也就是和几乎是CPU的直接调用,可以在CPU缓存中直接完成的,而无需内存的store和load的操作,此时while循环就一直可能出不来;不过这个只是我部分机器上的结果,就像你说的,优化的方式是不定性的。

      • 这也就是为什么只能依赖JMM所提供的最低保证的原因。不同的JVM采用的优化手段不一样,随着时间的推移,同一款JVM的新版本可能采取更多更先进的优化手段,随时都有可能将有问题的代码的问题暴露出来。但是,所有JVM总能提供JMM规定的那些保障。

    • zjy7554
    • 2018/04/11 2:20下午

    为什么加个打印就会立刻退出?

return top