运行结果是:
动图如下:
你也可以把我上面给的代码粘出来,跑一跑,看看是否和我说的运行结果一致。
说说 final
当我把程序改造成上面这个样子之后,其实结论已经很明显了,final 关键字影响了程序的运行。
其实当时我得出这个结论的时候非常兴奋,一个困扰我长达一年多的问题终于要被我亲手解开神秘面纱了。
结论都有了,寻找推理过程还不是轻而易举的事情?
而且我知道去哪里找答案,答案就藏在我桌子上的一本书里面。
于是我翻开了《Java并发编程的艺术》,其中有一小节专门讲到了 final 域的内存语义:
这一小节我印象可是太深刻了,因为 3.6.5 小节的“溢出”应该是“逸出”才对,早年间还基于此,写了这篇文章:
所以我只要在这一个小节里面找到证据,来证明留言里面的“storestore 屏障加上 Happens-Before 关系得出 flag 会被刷到主内存中”这个论点就行了。
但是,事情远远没有我想的这么简单,因为我发现,我在书里面没有找到能证明论点的证据,反而找到了推翻论点的证据。
书里面的一大段内容我就不搬运过来了,仅仅关注 3.6.6 final语义在处理器中的实现这一小节的内容:
注意画了下划线这一句话:在 X86 处理器中,final 域的读/写不会插入任何内存屏障。
由于没有任何内存屏障的存在,即“storestore 屏障”也是省略掉了。因此在 X86 处理器的前提下,final 域的内存语义带来的 flag 刷新是不存在的。
所以前面的论点是不正确的。
那么这本书里面的“在 X86 处理器中,final 域的读/写不会插入任何内存屏障”这个结论又是从哪里来的呢?
这个说来就巧了,是我们的老朋友 Doug Lee 告诉作者的。
你看 3.6.7 小节提到了 JSR-133。而关于 JSR-133,老爷子写过这样的一篇文章:《The JSR-133 Cookbook for Compiler Writers》,直译过来就是《编译器编写者的JSR-133食谱》
在这篇食谱里面,有这样的一个表格:
可以看到,在 x86 处理器中,LoadStore、LoadLoad、StoreStore 都是 no-op,即无任何操作。
翻译过来就是:在 x86 上,任何带 lock 前缀的指令都可以用作一个 StoreLoad 屏障。 (在 Linux 内核中使用的形式是 no-op lock; addl $0,0(%%esp)。) 支持 "SSE2" 扩展的版本(Pentium4 和更高版本)支持 mfence 指令, 该指令似乎是更好的,除非无论如何都需要像 CAS 这样的带 lock 前缀的指令。cpuid 指令也可以,但是速度较慢。
查到这里的时候我都快懵逼了,好不容易整理出来的一点点思路就这样再次被堵死了。
我给你捋一下啊。
我们是不是已经可以非常明确 final 带来的屏障(StoreStore)在 X86 处理器中是空操作,并不能对内存可见性产生任何影响。
那么为什么程序加上 final 之后,停下来了?
程序停下来了,说明主线程一定是观测到了 flag 的变化了?
那么为什么程序去掉 final 后,停不下来了?
程序没有停了,说明主线程一定沒有观测到 flag 的变化?
也就是说停不停下来,和有没有 final 有直接的关系。
但是 final 域带来的屏障在 X86 处理器中是空操作。
这特么是玄学吧?
绕了一圈,怎么又回去了啊。
这波,说真的,激怒我了,我花了这么多时间,绕了一圈又回来了?
干它。
stackoverflow
经过前面的分析,留言中提到的结论是验证不下去了。
但是我已经可以非常明确的知道,肯定是 final 关键字在作怪。
于是,我准备去 stackoverflow 上找一圈,看看会不会有意外发现。
果然,皇天不负有心人,我大概翻了几百个帖子,就在准备放弃的边缘,我翻到了一个让我虎躯一震的帖子。
虎躯一震之后,又是倒吸一口凉气:我的个娘,这是 JVM 的一个 BUG!?
这事先按下不表,我先说说我是怎么在 stackoverflow 里面搜索问题的。
首先,当前的这个情况下,我能确定的关键字就是 Java
,final
这两个。
但是我拿着这两个关键字去查的时候,查询出来的结果太多了,翻了几个之后我就发现这无疑是大海捞针。
于是我改变了策略,stackoverflow 上搜索是有 tag 即标签功能的:
如果让我把这个问题划分一个标签,标签无非就是 Java
,JVM
,JMM
,JIT
。
于是,我在 java-memory-model
即 JMM 下挖到了一个宝藏:
就是这个宝藏问题,推动了接下来的剧情发展:
我知道你看到这里的时候内心毫无波澜,听到我虎躯一震,甚至还想笑。
但是我看到这个问题的时候,不夸张的说:手都在抖。
因为我知道,在这里,就能解决这个玄学问题了。
而我倒吸一口凉气的原因是:这个问题里面的示例代码竟然和我的代码如出一辙,他代码里面的 Simple 就是对应着我代码里面的 Why。想要验证的问题,那就更是一模一样了。
问题里面的描述是这样说的:
实际上,我知道“final”字段不会在 x86 处理器上发出任何汇编指令。但为什么会出现这种情况?有什么特别的操作我不知道吗?
真相
上面提到的 stackoverflow 问题下面有这样的一个回答,这里面就是玄学背后的科学:
我翻译一下给你看:
老哥,我看到你问题里面的截图了,你查问题的姿势没对。
截图是什么呢?
就是提问者附在问题里面的两个截图:
其中 final case
的截图是这样的:
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 机制导致的。
验证
经过前面的分析,现在新的排查方向又出来了。
我现在得去验证一下回答问题这个哥们是不是在胡说。
于是我先去验证了他的这句话:
用高版本的 JDK 分别运行加了 final 和不加 final 修饰符的情况。
程序确实是都陷入了死循环。
动图如下,可以看到我的 JDK 版本是 15.0.1:
第一个点验证完成。同样的代码,JDK8 和 JDK15 运行起来结果不一致(其实JDK9运行就不一致了)。
我有理由相信,也许这是 JVM 的一个,不能说 BUG,应该说是缺陷吧。(等等...缺陷不就是 BUG 吗?)
第二个验证的点是他的这句话:
用 JDK8 跑出来结果不一样是因为有栈上替换在捣鬼,那么我可以用下面这个命令,把栈上替换给关闭了:
去掉 final 后,再次运行程序,程序停止了。
第二个点验证完成。
第三个验证的点是他的这个地方:
我也把我的汇编搞出来看看,有没有类似这样的地方。
怎么搞汇编出来呢?
用下面这个命令:
同时你还需要一个 hsdis 的 dll 文件,网上有很多,一搜就能找到,我相信如果你也想亲自验证,那么找这个文件难不倒你。
没有加 final 字段的时候,汇编是这样的:
jmp 指令是干啥的?
无条件跳转。
所以,这里就是个死循环。
加上 final 字段后,汇编是这样的:
首先跳转用的是 je 了,而不是 jmp 了。
je 的跳转是有条件的,代表的是“等于则跳转”。
而在 je 指令之前,还有 movzbl 指令,该操作就是在读取 flag 变量的值。
所以,加了 final 语句之后,每次都会去读取 flag 变量的值,因此 flag 值的变化能及时被主线程看到。
同时我也有 JITWatch 看了一下,对于循环中的 new Why(18)
语句,编译器分析出来这句话并没有什么卵用,于是被优化掉了:
所以我们在汇编中没有看到对 Why 对象进行分配的相关指令,也就是验证了他的这句话:
自此,玄学问题得到了科学的解释。
如果你坚持看到了这里,那么恭喜你,又学到了一个没啥卵用的知识点。
如果你想要学点和本文相关的、有用的东西,那么我建议看看这几个地方:
看完上面这些之后,你至少会比较清楚的了解到 Java 程序从源码编译成字节码,再从字节码编译成本地机器码的这两个过程。
能够了解 JVM 的热点代码探测方案、HotSpot 的即时编译、编译触发条件,以及如何从 JVM 外部观察和分析即使编译的数据和结果。
还有会了解到一些编译器的优化技术,比如:方法内联、分层编译、栈上替换、分支预测、逃逸分析、锁消除、锁膨胀...等等,这些基本上用不上,但是你知道了又显得高大上的知识点。
另外,强推R大的这个专栏:
专栏里面的这篇文章,宝藏:
比如本文涉及到的栈上替换(OSR),R大就回答过:
直言,OSR 对于跑分很有用,对于正常程序来说,用不上:
其中提到了这样的一段话:
JIT 对代码做了非常激进的优化。
其实回到我们的文章中,final 关键字的加上与否,表象上看是生成了两套不同的机器码,而本质上还是 final 关键字阻止了 JIT 进行激进的优化。