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

在Effective Java第10章看到关于volatile的使用,敲代码测试时发现了另外一个问题,没找到原因。代码如下来自《Effective J…
关注者
480
被浏览
54,427
登录后你可以
不限量看优质回答私信答主深度交流精彩内容一键收藏

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

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

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

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

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

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

  • 如果run()方法被HotSpot Server Compiler编译了:这个多加的System.out.println()调用干扰了编译器的优化,导致hoisting没有成功——读stopRequested的动作没有被挪到循环外面。只要循环每次都还去读取一次stopRequested变量,就总有读到修改过后的值的机会,所以循环会可以结束。
  • 然而也有可能这个run()方法压根还没来得及被编译,stopRequested变量就被main线程置为true了,因而run()方法里的代码还在解释器里跑,并没有得到任何优化,就直接跑到头了。此时如果还是想观察编译了的情况,请适当增加main线程sleep()的时间长度,例如说调整到TimeUnit.SECONDS.sleep(30)。

具体来说是这样的:

编译器总是比较喜欢局部变量,而不那么喜欢成员字段或者静态字段。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变量的读写之间的效果的顺序,但对这个例子来说最重要的就是保证每次都重新访问内存。