下面的代码 Java 线程结束原因是什么?

软件工程师、主攻高级编程语言虚拟机的设计与实现

731 👍 / 74 💬

问题描述

在Effective Java第10章看到关于volatile的使用,敲代码测试时发现了另外一个问题,没找到原因。代码如下来自《Effective Java》:
以下System.out语句会引起线程结束,如果去掉System.out语句,线程是永远不会结束的,望知晓。
import java.util.concurrent.TimeUnit;

public class ThreadTest {
   private static boolean stopRequested;

   public static void main(String[] args) throws InterruptedException {
      Thread backgroundThread = new Thread(new Runnable() {
         public void run() {
            int i = 0;
            while (!stopRequested){
               i++;
               //这段System.out语句会导致线程结束,原因?
               System.out.println(i);
            }
         }
      });
      backgroundThread.start();
      TimeUnit.SECONDS.sleep(1);
      stopRequested = true;
   }

}

嗯这是我日常工作的领域,问我就对了 >_<

这是《Effective Java》第二版第十章的内容。然而题主给的代码并不是书中原本的代码,而是多加了这个问题的主角——System.out.println()。

针对书中原本例子,我以前已经写过一个非常详细的讲解,描述了如何观察HotSpot VM Server Compiler对该方法的优化,特别是书中提到的“hoisting”。传送门在此:

请问R大 有没有什么工具可以查看正在运行的类的c/汇编代码

强调一下,本文所说的编译器优化都是在JIT编译器中做的,而不是在Java源码到字节码的编译器(javac / ECJ之类)做的。并不是前端编译器不能做,只是javac不做而已。

而至于题主的这个问题,其实原因也很简单:

具体来说是这样的:

编译器总是比较喜欢局部变量,而不那么喜欢成员字段或者静态字段。Java语言尤其如此(相比C/C++而言,因为Java无法对局部变量取地址,不像C/C++那么灵活)

具体到原本书中的代码:

         public void run() {
            int i = 0;
            while (!stopRequested) {
               i++;
               // no memory effects here
            }
         }

这个stopRequested是一个静态字段,编译器本来是需要对它做保守处理的。但编译器发现这个方法是个叶子方法(leaf method)——并不调用任何其它方法——所以只要这个run()方法正在运行,在同一线程内就不可能有其它代码能观测到stopRequested的值的变化。因此,编译器就大胆冒进一把,将stopRequested当作循环不变量(因为本方法并没有对其值所任何修改),而将其读取操作提升(hoist)到循环之外。被优化后的代码就变成这样了:

         public void run() {
            int i = 0;
            boolean hoistedStopRequested = stopRequested;
            while (!hoistedStopRequested) {
               i++;
               // no memory effects here
            }
         }

这么一来,这个循环就真的完全没可能观测到别的线程对stopRequested的修改了。

而当添加了一个System.out.println()调用之后:

         public void run() {
            int i = 0;
            while (!stopRequested) {
               i++;
               System.out.println(i); // full memory kill here
            }
         }

这个println()调用在HotSpot VM Server Compiler的实现里无法完全内联到底,总是得留下至少一个未内联的方法调用。

未内联的方法调用,从编译器的角度看是一个“full memory kill”,也就是说副作用不明、必须对内存的读写操作做保守处理。

这里的代码中,下一轮循环的stopRequested读取操作按顺序说要发生在上一轮循环的System.out.println()之后,这里“保守处理”具体的体现就是:就算上一轮我已经读取了stopRequested的值,由于经过了一个副作用不明的地方,再到下一次访问就必须重新读取了。

还有一点需要注意的是,虽然题主没说,但显然题主是在x86平台上跑的实验。x86本身有比较强的内存模型,所以就算此例中不显式生成内存屏障指令,这里只要重复读取stopRequest的值也足以“在某个时候”看到更新。

因而经过JIT编译器优化后,stopRequested的读取操作仍然保留在循环中而没有被提升到外面,循环最终就能读到改变过的值从而退出。

就这么简单而已。

这里涉及的原理其实在

《CS:APP》

里就有提到。在第5.1小节,书中提到memory aliasing阻碍了优化,其实本质上也是由于有可能出现未知副作用而迫使编译器放弃对其优化。这是本挺全面的入门书,值得好好品味。所以我也把它放在我的书单里了:

学习编程语言与编译优化的一个书单

(深入内容,看不懂可以忽略:如果大家有兴趣按照本文开头我给的链接的方式去做实验的话,可以观察到HotSpot Server Compiler编译这个run()方法时,表示读取stopRequested字段的LoadUB节点有一个输入是“Memory”。这个就是表示编译器对内存副作用的跟踪的输入。

在原本书里的例子里,LoadUB的Memory输入来自方法初始的那一个。

而在添加了System.out.println()之后,会发现LoadUB的Memory输入是个Phi,其中一侧的输入是从循环回边过来的。这就反映了编译器觉得循环里有未知的副作用,因而将对这个副作用的依赖输入给了LoadUB节点。

把部分Ideal节点放在这边方便参考,实验在Oracle JDK 7u51的fastdebug版上运行,sleep()调整到了30秒:

 7	Parm	===  3  [[ 70  27  26 ]] Memory  Memory: @BotPTR *+bot, idx=Bot; !orig=[38] !jvms: ThreadTest$1::run @ bci:2
 89	LoadUB	=== _  70  88  [[ 91 ]]  @java/lang/Class:exact+112 *, name=stopRequested, idx=4; #bool !jvms: ThreadTest::access$000 @ bci:0 ThreadTest$1::run @ bci:2
 70	Phi	===  137  7  123  [[ 16  109  103  80  89 ]]  #memory  Memory: @BotPTR *+bot, idx=Bot; !jvms: ThreadTest$1::run @ bci:2
 137	Loop	===  137  28  127  [[ 137  72  71  70  69  93 ]] inner  !orig=[68] !jvms: ThreadTest$1::run @ bci:2
 111	If	===  95  108  [[ 112  113 ]] P=0.999999, C=-1.000000 !jvms: ThreadTest$1::run @ bci:15
 112	IfTrue	===  111  [[ 105 ]] #1 !jvms: ThreadTest$1::run @ bci:15
 105	CallStaticJava	===  112  69  80  8  1 ( 158  99  1  99 ) [[ 121  122  123 ]] # Static  java.io.PrintStream::println void ( java/io/PrintStream:NotNull *, int ) ThreadTest$1::run @ bci:15 !jvms: ThreadTest$1::run @ bci:15
 123	Proj	===  105  [[ 136  70  71 ]] #2  Memory: @BotPTR *+bot, idx=Bot; !jvms: ThreadTest$1::run @ bci:15

run()方法的其余节点可以在这边看:

PrintIdeal for Item 66 from Effective Java, 2nd, with an additional println(i) in the loop.

这个例子其实也从一个侧面体现了当前HotSpot VM的JIT编译器的优化的局限性——它更多的是做过程内分析(intraprocedural analysis),而只做非常非常有限的过程间分析(interprocedural analysis),例如类层次分析(CHA)。

如果能基于封闭环境(closed-world assumption)做全程序分析的话,就会知道System.out.println()不可能修改stopRequested的值,于是照样可以在这个例子里把stopRequested的读取操作提升到循环外,再次导致循环无法结束。

说了半天,怎样才能保证循环一定能结束呢?《Effective Java》第二版已经给出了正解:做足同步。最简单的,给stopRequested字段加上volatile声明即可。不做同步的话,Java语言规范与JVM规范是允许上述优化的。

volatile在此对编译器的影响之一就是会迫使编译器放弃对它做任何冒进的优化,而总是会从内存重新访问其值。当然它还有其它语义,例如说保证volatile变量的读写之间的效果的顺序,但对这个例子来说最重要的就是保证每次都重新访问内存。