0%

JIT编译器优化

为了优化Java的性能 ,JVM在解释器之外引入了即时(Just In Time)编译器:当程序运行时,解释器首先发挥作用,代码可以直接执行。当程序运行时,JIT编译器再编译代码,获取更高的运行效率。

这篇文章来记录下JIT编译器的工作过程以及一些优化的把戏。本文章中大部分的知识都是来自这个视频中的内容,我学习之后将他们记录下来。

理解 JVM 中 JIT 玩的把戏_1_哔哩哔哩_bilibili

JIT分层编译

Java 8引入了分层编译的概念,分层编译将JVM的执行状态分为了五个层次。五个层级分别是:

  1. 解释执行。

  2. 执行不带profiling的C1代码。

  3. 执行仅带方法调用次数以及循环回边执行次数profiling的C1代码。

  4. 执行带所有profiling的C1代码。

  5. 执行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
2
3
4
5
ArrayStream<E>.forEach(Consumer<? super E> action) {
for (int i = 0; i < this.elementData.length(), i++) {
action.accept(this.elementData[i]);
}
}

首先,jvm为了保证代码的安全性,必须在代码中添加安全检查,因此,代码变成了下面这样。

1
2
3
4
5
6
7
8
9
10
11
12
ArrayStream<E>.forEach(Consumer<? super E> action) {
if (this == null) throw new NPE();
if (this.elementData == null) throw new NPE();
for (int i = 0; i < this.elementData.length(), i++) {
if (this == null) throw new NPE();
if (this.elementData == null) throw new NPE();
if (i < 0) throw new AIOBE();
if (i >= this.elementData.length) throw new AIOBE();
if (action == null) throw new NPE();
action.accept(this.elementData[i]);
}
}

首先可以去掉的就是对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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
ArrayStream.forEach(Consumer<? super E> action) {
if (this.elementData == null) {
throw new NPE();
}
if (0 < this.elementData.length()) {
if (this.elementData == null) {
throw new NPE();
}
if (action == null) throw new NPE();
action.accept(this.elementData[0]);
}

for (int i = 1; i < this.elementData.length(); i++) {
if (this.elementData == null) throw new NPE();
action.accept(this.elementData[i]);
}
}

这样我们让action的null检查从n次变成了一次。

方法内联

方法内联很简单,本质就是将被调用的方法的代码复制到调用者的方法体里,这样就消除了一次调用。

比如遍历elementData,将每一个元素传入一个consume方法,该方法将传入的每一个数字加到sum上。

1
2
3
static void consume(int x) {
sum += x;
}

采用for循环的话有三种方式。

第一种就是简单的获取this.elementData并遍历

1
2
3
for (int i = 0; i < this.elementData.length(); i++) {
consume(this.elementData[i]);
}

第二种方法和第一种有一点不同,我们将this.elementData用一个局部变量保存起来。

1
2
3
4
E[] elementData = this.elementData;
for (int i = 0; i < elementData.length(); i++) {
consume(elementData[i]);
}

第三种方式是使用Java的增强for循环

1
2
3
for (int i : this.elementData) {
consume(elementData[x]);
}

在consume方法内联的情况下,三个函数的执行效率都差不多。

但是,在不内联的情况下,第一种方式就比第二和第三种慢了许多。

原因在于,第一种在每一次循环都需要重新加载elementData,因为elementData可能被修改。

第二种方法使用了局部变量,因为局部变量是无法被修改的,所以不需要被重新加载。

第三种方法其实和第二种本质上没有什么差别,增强for循环也会创建一个局部变量。

但是对于虚函数,由于会有不同的实现类,那么又该如何内联呢。

去虚拟化

在Java中,除了private函数以及final修饰的函数,几乎所有的对象函数都是虚函数。虚函数是实现多态的基础。

每次调用虚函数,我们都要去找到对应的真实类型的函数,并进行调用。那么要怎么样才能去除虚函数的开销呢。

首先是静态分析。

假设有这样一个函数apply,接受一个Function参数,和一个int类型的参数,然后在函数内调用func.apply(x)

1
2
3
static double apply(Function func, int x) {
func.apply(x);
}

然后在main函数中调用apply

1
2
3
4
5
6
7
8
public class Monomorphic {
public static void main(String[] args) {
Function func = new Square();
for (int i = 0; i < 20_000; ++i) {
apply(func, i);
}
}
}

然后我们查看编译的日志

iHpJ23.jpeg

就会发现,编译器将先将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
2
3
4
5
6
7
if (func.getClass().equals(Square.class)) {
// 内联Square的apply函数
} else if (func.getClass().equals(Sqrt.class)) {
// 内联Sqrt的apply函数
} else {
uncommon_trap()
}

对于为什么不在else后面直接放虚函数调用,而还是加上一个uncommon trap。这时由于如果加上虚函数调用,那么在那个虚函数中又有可能会出现修改变量的行为,导致之前编译的代码出现问题。虚函数调用可以使用反射,反射可以修改几乎任何东西。

总结

这里只写了JIT优化的很少一部分,还有很多其他的东西都没有写,比如GC是如何实现Stop The World的,GC如何与编译后的函数交互等等。

从这里可以看出,Java很喜欢小方法和局部变量,很喜欢不变的东西,常量。Java也并不喜欢原生方法,因为Java虚拟机无法知晓native函数中具体做了什么,也无法优化,并且有可能导致崩溃。除非是intrinsic函数,也就是在jvm中有特殊实现的函数。

因此,写Java代码应该保持函数尽可能的小,当函数字节码大小超过8000字节之后,JIT就不会对该函数进行优化。JIT将会将各个小函数内联起来并进行优化。不应该一直抛出异常,因为异常可能会导致编译优化失效。变量应该尽量使用局部变量,因为局部变量的不变性使得他们更容易被优化。