non-final case
的截图是这样的:
顺道说一句题外话,截图来源就是 JITWatch 工具,一个很强大的工具。
从你的截图来看,虽然 runMethod 都被编译过了,但是并没有被真正的执行过。你需要注意的是汇编输出中有 % 标记的地方,它代表着 OSR(on-stack replacement)栈上替换。
如果你不清楚啥是 OSR 也先别着急,一会说。
对于加和不加 final,最终得出的汇编代码是不一样的,我编译之后,仅保留相关部分如下:
从截图中可以看出,没有加 final 的时候,汇编代码其实就是一个死循环。而加上 final 之后,每次都会去加载 flag 字段。
但是你看,这两种情况,都没有对 Simple 类进行实例分配,也没有字段的分配。
所以,这不是编译器 final 字段赋值的问题,而是编译器的一种优化手段。
整个过程中完全没有 Simple 类的事儿,也就更加没有 final 字段的事儿了。但是加上 final 之后确实影响了程序的结果。
这个问题在比较新的 JVM 版本中得到了修复(言外之意就是一个 BUG?)。
所以,如果你在 JDK 11 版本上运行相同的代码,无论加不加 final,程序都不会正常退出。
好了,上面说了这么多,其实原因已经很清楚了。
根本原因是因为加不加 final 在我的示例环境中生成的是两套不同的机器码。
深层次的原因是 OSR 机制导致的。
验证
经过前面的分析,现在新的排查方向又出来了。
我现在得去验证一下回答问题这个哥们是不是在胡说。
于是我先去验证了他的这句话:
If you run the same example on JDK 11, there will be an infinite loop in both cases, regardless of the final modifier.
用高版本的 JDK 分别运行加了 final 和不加 final 修饰符的情况。
程序确实是都陷入了死循环。
动图如下,可以看到我的 JDK 版本是 15.0.1:
第一个点验证完成。同样的代码,JDK8 和 JDK15 运行起来结果不一致(其实JDK9运行就不一致了)。
我有理由相信,也许这是 JVM 的一个,不能说 BUG,应该说是缺陷吧。(等等...缺陷不就是 BUG 吗?)
第二个验证的点是他的这句话:
Instead, execution jumps from the interpreter to the OSR stub.
用 JDK8 跑出来结果不一样是因为有栈上替换在捣鬼,那么我可以用下面这个命令,把栈上替换给关闭了:
-XX:-UseOnStackReplacement
去掉 final 后,再次运行程序,程序停止了。
第二个点验证完成。
第三个验证的点是他的这个地方:
我也把我的汇编搞出来看看,有没有类似这样的地方。
怎么搞汇编出来呢?
用下面这个命令:
-XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:+LogCompilation -XX:LogFile=jit.log
同时你还需要一个 hsdis 的 dll 文件,网上有很多,一搜就能找到,我相信如果你也想亲自验证,那么找这个文件难不倒你。
没有加 final 字段的时候,汇编是这样的:
jmp 指令是干啥的?
无条件跳转。
所以,这里就是个死循环。
加上 final 字段后,汇编是这样的:
首先跳转用的是 je 了,而不是 jmp 了。
je 的跳转是有条件的,代表的是“等于则跳转”。
而在 je 指令之前,还有 movzbl 指令,该操作就是在读取 flag 变量的值。
所以,加了 final 语句之后,每次都会去读取 flag 变量的值,因此 flag 值的变化能及时被主线程看到。
同时我也有 JITWatch 看了一下,对于循环中的 new Why(18)
语句,编译器分析出来这句话并没有什么卵用,于是被优化掉了:
所以我们在汇编中没有看到对 Why 对象进行分配的相关指令,也就是验证了他的这句话:
You see, in both cases there is no Simple instance allocation at all, and no field assignment either.
自此,玄学问题得到了科学的解释。
如果你坚持看到了这里,那么恭喜你,又学到了一个没啥卵用的知识点。
如果你想要学点和本文相关的、有用的东西,那么我建议看看这几个地方:
- 《Java并发编程的艺术》的3.6小节-final域的内存语义。
- 《深入理解Java虚拟机》的第四部分-程序编译与代码优化。
- 《深入解析Java虚拟机HotSpot》的第7章-编译概述,第8章-C1编译器,第9章-C2编译器。
- 《Java性能优化实践》的第10章-理解即时编译
看完上面这些之后,你至少会比较清楚的了解到 Java 程序从源码编译成字节码,再从字节码编译成本地机器码的这两个过程。
能够了解 JVM 的热点代码探测方案、HotSpot 的即时编译、编译触发条件,以及如何从 JVM 外部观察和分析即使编译的数据和结果。
还有会了解到一些编译器的优化技术,比如:方法内联、分层编译、栈上替换、分支预测、逃逸分析、锁消除、锁膨胀...等等,这些基本上用不上,但是你知道了又显得高大上的知识点。
另外,强推R大的这个专栏:
专栏里面的这篇文章,宝藏:
JIT 对代码做了非常激进的优化。
其实回到我们的文章中,final 关键字的加上与否,表象上看是生成了两套不同的机器码,而本质上还是 final 关键字阻止了 JIT 进行激进的优化。