乐趣区

关于java:414天前我以为这是编程玄学

你好呀,我是 why。

不晓得大家还有没有印象,我已经写了这样的一篇文章:《一个困扰我 122 天的技术问题,我如同晓得答案了。》

文章我给出了这样的一个示例:

public class VolatileExample {

    private static boolean flag = false;
    private static int i = 0;
    public static void main(String[] args) {new Thread(() -> {
            try {TimeUnit.MILLISECONDS.sleep(100);
                flag = true;
                System.out.println("flag 被批改成 true");
            } catch (InterruptedException e) {e.printStackTrace();
            }
        }).start();
        while (!flag) {i++;}
        System.out.println("程序完结,i=" + i);
    }
}

下面这个程序是不会失常完结的,因为变量 flag 没有被 volatile 润饰。

而在子线程休眠的 100ms 中,while 循环的 flag 始终为 false,循环到肯定次数后,触发了 jvm 的即时编译性能(JIT),进行循环表达式外提(Loop Expression Hoisting),导致造成死循环。

而如果加了 volatile 去润饰 flag 变量,保障了 flag 的可见性,则不会进行晋升。

验证计划就是敞开 JIT 性能,对应的命令是 -Xint 或者 -Djava.compiler=NONE

这都不是重点,重点是我接下来有几处小改变,代码的运行后果也是各不相同。

文章中的最初一节我是这样说的:

而图片外面提到的“对于 Integer”的问题,就是文章说提到的“玄学”:

是的,我回来填坑了。

再次摸索

其实让我再次摸索这个问题的起因是因为四月份的时候有人私信我,问我对于 Integer 的玄学问题是否有了论断。

我只能说:

然而,起初我想到了这篇文章外面的一个留言:

因为过后公众号没有留言性能,用的第三方小程序,所以我没有太留神到留言揭示。

这位大佬留言之后,我隔了很长时间才看到,我还在留言前面回复了一个:

谢谢大佬剖析,有工夫的时候我依照这个思路去剖析剖析。

然而起初我也搁置了,因为我感觉如同持续在这外面深究上来收益曾经不大了。

没想到,时隔这么长时间,又有读者来问了。

于是在五一期间我依照留言的说法,批改了一下程序,并进行了一波基于搜索引擎的钻研。

嘿,你猜怎么着?

我还真的钻研出了一点有意思的货色。

先说论断:final 关键字影响了程序的后果。

在下面这个案例中,final 关键字在哪呢?

当咱们把程序外面的 int 批改为 Integer 后,i++ 操作波及到装箱、拆箱的操作,这个过程中对应的源码是这里:

而这里的 new Interger(i) 外面的 value 是 final,

程序能失常完结,的确是 final 关键字影响了程序的后果。

那么 final 到底是怎么影响的呢?

这个中央我通过摸索之后,发现和留言中说的有肯定的偏差。

留言中说的是因为有 storestore 屏障加上 Happens-Before 关系得出 flag 会被刷到主内存中。

而我基于搜索引擎的帮忙,摸索进去的论断是加上 final 和不加 final,生成的是两套机器码,导致运行后果不统一。

然而我这里得加上一个前提:处理器是 x86 架构。

得出这个论断基于的测试案例如下,也是依照留言给的思路写进去的:

Class 外面蕴含一个 final 的属性,在构造方法外面给属性赋值。而后在 while 循环外面一直 new 该对象:

我的运行环境是:

  • jdk1.8.0_271
  • win10
  • IntelliJ IDEA 2019.3.4

运行后果是:

  • 如果 age 属性加上 final 润饰,程序则能够失常退出。
  • 如果 age 属性去掉 final 润饰,程序则有限循环,不能退出。

动图如下:

你也能够把我下面给的代码粘进去,跑一跑,看看是否和我说的运行后果统一。

说说 final

当我把程序革新成下面这个样子之后,其实论断曾经很显著了,final 关键字影响了程序的运行。

其实过后我得出这个论断的时候十分兴奋,一个困扰我长达一年多的问题终于要被我亲手解开神秘面纱了。

论断都有了,寻找推理过程还不是轻而易举的事件?

而且我晓得去哪里找答案,答案就藏在我桌子上的一本书外面。

于是我打开了《Java 并发编程的艺术》,其中有一大节专门讲到了 final 域的内存语义:

.png)

这一大节我印象可是太粗浅了,因为 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 食谱》

http://gee.cs.oswego.edu/dl/j…

在这篇食谱外面,有这样的一个表格:

能够看到,在 x86 处理器中,LoadStore、LoadLoad、StoreStore 都是 no-op,即无任何操作。

On x86, any lock-prefixed instruction can be used as a StoreLoad barrier. (The form used in linux kernels is the no-op lock; addl $0,0(%%esp).) Versions supporting the “SSE2” extensions (Pentium4 and later) support the mfence instruction which seems preferable unless a lock-prefixed instruction like CAS is needed anyway. The cpuid instruction also works but is slower.

翻译过去就是:在 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 下挖到了一个宝藏:

就是这个宝藏问题,推动了接下来的剧情倒退:

https://stackoverflow.com/que…

我晓得你看到这里的时候心田毫无波澜,听到我虎躯一震,甚至还想笑。

然而我看到这个问题的时候,不夸大的说:手都在抖。

因为我晓得,在这里,就能解决这个玄学问题了。

而我倒吸一口凉气的起因是:这个问题外面的示例代码居然和我的代码一模一样,他代码外面的 Simple 就是对应着我代码外面的 Why。想要验证的问题,那就更是截然不同了。

问题外面的形容是这样说的:

Actually, I know the storing “final” field would not emit any assembly instructions on x86 platform. But why this situation came out? Are there some particular operations I don’t know ?

实际上,我晓得“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 机制导致的。

验证

通过后面的剖析,当初新的排查方向又进去了。

我当初得去验证一下答复问题这个哥们是不是在胡说。

于是我先去验证了他的这句话:

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 大的这个专栏:

https://www.zhihu.com/column/…

专栏外面的这篇文章,宝藏:

https://zhuanlan.zhihu.com/p/…

比方本文波及到的栈上替换 (OSR),R 大就答复过:

婉言,OSR 对于跑分很有用,对于失常程序来说,用不上:

其中提到了这样的一段话:

JIT 对代码做了十分激进的优化。

其实回到咱们的文章中,final 关键字的加上与否,表象上看是生成了两套不同的机器码,而实质上还是 final 关键字阻止了 JIT 进行激进的优化。

退出移动版