经过四天的努力,终于把这道题目搞清楚了,收获很多,一起分享给大家。这道题目是这样的,一位网友在群里发了他的面试题目,然后在群里持续了两天的讨论。引起了我的兴趣。先来看看原题目。
原题

  1. 这段代码执行结果?会不会死循环?
  2. 什么问题造成死循环?怎么解决死循环?

面试官既然都这样问了,肯定是会死循环的。实践下来也的确如此。此时,还有群友提出,在while中,加入 System.out.println();就会跳出循环,类似

1
2
3
4
5
...
while (num == 0){
System.out.println("far");
}
...

那么这就更奇怪了,将群内的气氛推向高潮,为什么会有这种玄学?我一开始,也邀请了我们的公司大神一起寻找答案。我们一开始从编译器优化开始,没有找到我们要的答案。后来寄希望于线程可见性,我们一直了解到CPU缓存一致性,就是那几个状态(MESI协议)。也不能合理的解释这个玄学。

最后得到一丝线索是来自effective java中的一条案例,这本真的是java圣书,每次遇到玄学,我都会先拿出来它。在effective java的第66条中,有举例说到这样一个案例,跟我们的题目很像。
effective java

但是书中并没有给出原因,只是告诉我们,这时一种优化,这种优化称作提升。这正是HotSpot Server VM的工作,然后给出建议是加synchronized或者volatile。这段话好像也没有解决我们的问题,effective java只是抛出了一个问题,就没管了。但是它给我提供了一个关键字HotSpot Server VM。说实话,以前确实没有了解过这个,就抱着试一下的态度上谷歌。第一次认识它,毕竟是新鲜的,这时那个面试题已经不重要了,相对于HotSpot这个新玩物来说,真的不值一提。

多说一句,多谢R大,国内JVM大牛,也是在他博客中找到这个题目的论证思路。

环境准备

fastdebug版的jdk

你可以自己根据你使用的jdk版本,自己编译debug版的jdk。
我这里也有1.8现成的,也可以自取。主要我压缩的分两卷,请把两卷一起下载解压。
https://www.lanzous.com/b894096/
密码:f1ov

jdk1.6 | jdk1.7

下面的可视化(IGV)工具不支持1.8,所以下载一个吧。

ideal graph visualizer(IGV)

将C2(HotSpot Server Compiler)编译方法时内部数据结构的状态,输出可视化。
下载地址:https://www.lanzous.com/i5ixf1c
解压后修改etc下面的idealgraphvisualizer.conf

1
jdkhome="${你jdk1.6的地址}"

分析

获取编译时阶段数据

根据R大所说,验证一个优化是否真的在起作用,可以先获取C2编译时每个阶段内部数据结构的状态,然后用IGV,观察某些优化发生过程。例如我们可以使用-XX:PrintIdealGraphLevel=2 -XX:PrintIdealGraphFile=ideal.xml,将数据输出到ideal.xml文件中。

1
2
3
D:\fastdebug-1.8.0.111-1\bin>javac LoopTest.java

D:\fastdebug-1.8.0.111-1\bin>java -XX:PrintIdealGraphLevel=2 -XX:PrintIdealGraphFile=ideal.xml LoopTest

-XX:PrintIdealGraphLevel=2输出的Ideal graph记录。另外,-XX:+PrintOptoAssembly所输出的近似汇编的日志也比真的汇编包含更多上层信息,更便于理解。

本次编译的ideal记录,可以在此下载,本次分析也仅针对本次编译。https://www.lanzous.com/i5ktacb

IGV的主界面显示出C2编译到这个阶段时的中间代码(Intermediate Representation)的图。R大解释:C2所采用的IR是一种名为“Ideal”的静态单赋值(SSA)形式的程序依赖图(Program Dependence Graph)这里我没有过细研究,各位有兴趣的话可以慢慢探索。

现在获取到编译时每一个阶段的变化,我们就可以用IGV输出可视化分析了,我们主要观察两个阶段After Parsing和Iter GVN 1,如图
IGV

After Parsing

After Parsing这个阶段的图。C2编译一个Java方法时,需要先把字节码解析(parse)为C2的IR,然后才可以继续做分析和优化,最终生成代码。“After Parsing”就是C2刚完成parse过程,把这个方法完整的IR图建立好的时候;这个状态代表了C2对要编译的方法的初步认识。

我们先知道怎么看这张图,我们要知道图中的每一个基本块之间控制流怎么对应我们的java代码,我是这样定位的,大家可以借鉴,先设置节点的显示的内容,输出dump_spec数据。

1
2
[idx] [name]  
[dump_spec]

回忆本案例中的是否死循环是由一个num变量控制的,而effective java中提到案例解释到C2在编译时会将stopRequested当作循环不变量提升到循环之外,以此来作为优化。那么我们也从num入手肯定是没错的,要论证的就是num的读会不会也像effective java中的stopRequested当作循环不变量来处理。

我们先定位程序读取num的节点,在IGV右上角搜索Load,我们可以找到两个节点,如下:
IGV

我们定位到这两个节点,分别在B2和B12块中,我们可以看到该节点中name就是我们要找的num字段,如下:
IGV

现在回到我们的代码,我们的代码中,也有两处读取num的地方,一次是修改num的值,一次是while循环中,刚好对应的上。我们暂且将B2、B12确认为这两处代码。我们现在打开右边工具栏Filters中的“C2 Only Control Flow”
IGV

去掉数据流节点,我们先看各个基本块之间的控制流,方便与原本的java代码对应起来。这个图由于比较长就不放图了,在这个例子中我们可以看到。

  • B6 是本次编译的入口,main方法入口
  • B5 Thread.sleep()
  • B4 new Thread()
  • B3 Thread.start()
  • B13 退出,包含异常退出,代码执行完退出
  • B2 代码入口,读取了num的值
  • B8、B10 做个标记,loop predicate失败,请求退回到解释器里继续执行,并且未来可能重新编译
  • B1、B7、B9 loop predicate相关
  • B12 循环的主体

也就是说在此阶段,完成按照我们代码正常编译,还没有发生类似循环条件被看作循环不变量提升到循环之外的优化,循环体里还保留这那个循环条件。

Iter GVN 1

我们接下来看下一个阶段Iter GVN 1,此时搜索Load,却发现只有一个结果了
IGV

这个Load存在B2中,而之前B12代码块中的Load已经不见了,也就是说此时C2把B12中的num看作与B2中的一致,当作不变量提升到循环外了,便作此优化。

在IGV里重新回到After Parsing阶段,在当前阶段,对Iter GVN 1右键,选择 Differnce to current graph,可以看到两图之间的差异:
IGV

图里红色的节点就是被消除了的节点,虚线的边也是已不复存在的边。B12中的Load是在 Iterative GVN被消除的。至此,无限循环就已经形成了。

最后R大还输出-XX:+TraceIterativeGVN日志,论证从第二次读取num的值开始,C2如何一步步将B12中的访问num的操作当作B2中的那个的。我在这里卡住了,后续解决了再更新。

R大原文地址:https://hllvm-group.iteye.com/group/topic/34932