为了优化Java的性能 ,JVM在解释器之外引入了即时(Just In Time)编译器:当程序运行时,解释器首先发挥作用,代码可以直接执行。当程序运行时,JIT编译器再编译代码,获取更高的运行效率。
这篇文章来记录下JIT编译器的工作过程以及一些优化的把戏。本文章中大部分的知识都是来自这个视频中的内容,我学习之后将他们记录下来。
理解 JVM 中 JIT 玩的把戏_1_哔哩哔哩_bilibili
JIT分层编译
Java 8引入了分层编译的概念,分层编译将JVM的执行状态分为了五个层次。五个层级分别是:
解释执行。
执行不带profiling的C1代码。
执行仅带方法调用次数以及循环回边执行次数profiling的C1代码。
执行带所有profiling的C1代码。
执行C2代码。
第一阶段就是解释执行,第二阶段是C1编译,C1拥有三个模式,也就是2,3,4,最后是C2编译。
C1第一个模式,仅仅是编译执行,代码中没有任何埋点进行profiling的地方。
C1第二个模式,是编译加上一个counter计数器,当计数器达到阈值将会到达C2。
C1第三个模式,是编译加上所有的profiling,进行详细分析。比如某个分支,永远是true,或者某一个接口方法的调用,只使用过一种类型(比如List的add方法,实际上永远是ArrayList),或者某个强制转换从未失败,某个调用从来没有产生过NEP异常。
在一般情况下,代码从解释执行到C1,会直接到C1的第三个模式,最后达到C2。
在C2忙的时候,也就是说C2拥有很多方法需要编译,队列中有很多待处理的任务时,代码会先达到C1的第二个模式,再达到C1的第三个模式,以此来减少C2的执行时间。在C1第三个模式下,如果profiling没有收集到有价值的数据,那么jvm会断定C2编译并没有比C1好很多。因此将会直接到C1的第一个模式。
在C1忙的时候也有可能直接达到C2,在解释过程中进行profiling,然后直接由C2编译。
Uncommon Trap
比如ArrayStream的forEach方法。
1 | ArrayStream<E>.forEach(Consumer<? super E> action) { |
首先,jvm为了保证代码的安全性,必须在代码中添加安全检查,因此,代码变成了下面这样。
1 | ArrayStream<E>.forEach(Consumer<? super E> action) { |
首先可以去掉的就是对this的检查,因为在对象的方法体中,this不可能为null。
其次,由于i从0开始,并且this.elementData.length()不可能大于Integer.MAX,且i递增每次+1,所以,i不可能小于0。因此 i < 0被去掉了。
接着,对于 i >= this.elementData.length,编译器将它优化为了 !(i < this.elementData.length) ,这样,在上面的for循环中已经判断了一次 i <this.elementData.length,在这个检查中只需要将上面计算的结果进行取反。也就是公共表达式消除。
到这里,我们只剩下了,最开始的elementData == null的检查,循环中的elementData == null的检查,以及循环中的action == null的检查。
这三个null检查是无法消除的,因此对于无法消除的null检查,我们就需要将检查的开销将到最低。
因此,在编译时,检查elementData == null将会被直接消除,然后jvm会注册一个sig_fault handler,当出现了elementData为null的时候,jvm不会直接崩溃,而是会进入trap,通过操作系统调用段错误处理器,然后再抛出一个NPE异常。
这样的优化比起直接新建一个分支来判断会快很多。前提是,这个elementData并不是null。类似于jvm断言这里肯定不会产生NPE异常,但是一旦产生null,就会陷入trap,然后去优化,整体就会变慢。
循环剥离
在上面的null判断中,除了使用trap,还有一种方法,也就是将第一次循环剥离出来。
这时,代码变成了下面这样。
1 | ArrayStream.forEach(Consumer<? super E> action) { |
这样我们让action的null检查从n次变成了一次。
方法内联
方法内联很简单,本质就是将被调用的方法的代码复制到调用者的方法体里,这样就消除了一次调用。
比如遍历elementData,将每一个元素传入一个consume方法,该方法将传入的每一个数字加到sum上。
1 | static void consume(int x) { |
采用for循环的话有三种方式。
第一种就是简单的获取this.elementData并遍历
1 | for (int i = 0; i < this.elementData.length(); i++) { |
第二种方法和第一种有一点不同,我们将this.elementData用一个局部变量保存起来。
1 | E[] elementData = this.elementData; |
第三种方式是使用Java的增强for循环
1 | for (int i : this.elementData) { |
在consume方法内联的情况下,三个函数的执行效率都差不多。
但是,在不内联的情况下,第一种方式就比第二和第三种慢了许多。
原因在于,第一种在每一次循环都需要重新加载elementData,因为elementData可能被修改。
第二种方法使用了局部变量,因为局部变量是无法被修改的,所以不需要被重新加载。
第三种方法其实和第二种本质上没有什么差别,增强for循环也会创建一个局部变量。
但是对于虚函数,由于会有不同的实现类,那么又该如何内联呢。
去虚拟化
在Java中,除了private函数以及final修饰的函数,几乎所有的对象函数都是虚函数。虚函数是实现多态的基础。
每次调用虚函数,我们都要去找到对应的真实类型的函数,并进行调用。那么要怎么样才能去除虚函数的开销呢。
首先是静态分析。
假设有这样一个函数apply,接受一个Function参数,和一个int类型的参数,然后在函数内调用func.apply(x)
1 | static double apply(Function func, int x) { |
然后在main函数中调用apply
1 | public class Monomorphic { |
然后我们查看编译的日志
就会发现,编译器将先将apply函数内联进了main函数,然后直接将Square的apply内联进了Monomorphic的apply函数。
这里JIT使用了一个封闭世界的假设,它发现现在整个jvm中只有一个Function的实例,也就是Square,其他的实现了Function接口的类可能存在,但是现在还没有被加载。
如果我们在这时加载了一个其他的类,比如Sqrt。这时实现了Function接口的类就不只有Square了,还存在Sqrt。这时,上面的假设就不成立了。
因此这时,前面的编译将会失效,因为它直接内联了Square的apply函数。jvm将会立刻去优化。
在这时,C2编译器将会被锁住,所有的线程将会返回到解释模式,性能将会大幅下降。
这时,虚拟机将会开始收集调用的信息,被称为类型采样分析,这些信息由C1或者解释器收集。
虚拟机将会收集每个Function的实现类被调用了几次,从哪里调用。
假如在20000次Function.apply调用中,有19000次都是Square.apply,只有1000次是Sqrt.apply。这时JIT就可以基于这些信息开始编译。
这次编译并不会像之前那样直接内联Square和Sqrt的apply函数。而是会加上类型检查。
这时,代码将会在变成类似下方这样。
1 | if (func.getClass().equals(Square.class)) { |
对于为什么不在else后面直接放虚函数调用,而还是加上一个uncommon trap。这时由于如果加上虚函数调用,那么在那个虚函数中又有可能会出现修改变量的行为,导致之前编译的代码出现问题。虚函数调用可以使用反射,反射可以修改几乎任何东西。
总结
这里只写了JIT优化的很少一部分,还有很多其他的东西都没有写,比如GC是如何实现Stop The World的,GC如何与编译后的函数交互等等。
从这里可以看出,Java很喜欢小方法和局部变量,很喜欢不变的东西,常量。Java也并不喜欢原生方法,因为Java虚拟机无法知晓native函数中具体做了什么,也无法优化,并且有可能导致崩溃。除非是intrinsic函数,也就是在jvm中有特殊实现的函数。
因此,写Java代码应该保持函数尽可能的小,当函数字节码大小超过8000字节之后,JIT就不会对该函数进行优化。JIT将会将各个小函数内联起来并进行优化。不应该一直抛出异常,因为异常可能会导致编译优化失效。变量应该尽量使用局部变量,因为局部变量的不变性使得他们更容易被优化。