深入理解Java虚拟机:(十三)方法内联
一、概念前面几篇中,多次提到方法内联的技术。它指的是:在编译过程中遇到方法调用时,将目标方法的方法体纳入编译范围之中,并取代原方法调用的优化手段,减少因方法调用开销的技术。函数调用过程首先会有个执行栈,存储它们的局部变量、方法名、动态连接;当一个方法被调用,一个新的栈帧会被加到栈顶,分配的本地变量和参数会存储在这个栈帧;跳转到目标方法代码执行;方法返回的时候,本地方法和参数被销毁,栈...
一、概念
前面几篇中,多次提到方法内联的技术。它指的是:在编译过程中遇到方法调用时,将目标方法的方法体纳入编译范围之中,并取代原方法调用的优化手段,减少因方法调用开销的技术。
函数调用过程
- 首先会有个执行栈,存储它们的局部变量、方法名、动态连接;
- 当一个方法被调用,一个新的栈帧会被加到栈顶,分配的本地变量和参数会存储在这个栈帧;
- 跳转到目标方法代码执行;
- 方法返回的时候,本地方法和参数被销毁,栈顶被移除;
- 返回原来的地址执行。
注:这就是通常说的函数调用的压栈和出栈过程,因此,函数调用需要有一定的时间开销和空间开销,当一个方法体不大,但又频繁被调用时,这个时间和空间开销会相对变得很大,变得非常不划算,同时降低了程序的性能。根据二八原则,80%的性能消耗其实是发生在20%的代码上,对热点代码的针对性优化可以提升整体系统的性能。
二、方法内联的原理
在 C2 中,方法内联是在解析字节码的过程中完成的。每当碰到方法调用字节码时,C2 将决定是否需要内联该方法调用。如果需要内联,则开始解析目标方法的字节码。
即时编译器首先解析字节码,并生成 IR 图,然后在该 IR 图上进行优化。优化是由一个个独立的优化阶段(optimization phase)串联起来的。每个优化阶段都会对 IR 图进行转换。最后即时编译器根据 IR 图的节点以及调度顺序生成机器码。
同 C2 一样,Graal 也会在解析字节码的过程中进行方法调用的内联。此外,Graal 还拥有一个独立的优化阶段,来寻找指代方法调用的 IR 节点,并将之替换为目标方法的 IR 图。这个过程相对来说比较形象一些,因此,今天我就利用它来给你讲解一下方法内联。
我们先来看下下面的一个例子:
package com.jvm;
public class InliningDemo {
private static final int NUM = 15000;
public static void main(String[] args) {
for (int i = 0; i < NUM; i++) {
calcSum();
}
}
private static void calcSum() {
long sum = 0;
for (int i = 0; i < 100; i++) {
sum += doubleValue(i);
}
}
private static long doubleValue(int i) {
// 这个空循环用于后面演示JIT代码优化过程
for (int j = 0; j < 100000; j++);
return i * 2;
}
}
首先运行这段代码,并且确认这段代码是否触发了即时编译,要知道某个方法是否被编译过,可以使用参数 -XX:+PrintCompilation
要求虚拟机在即时编译时将被编译成本地代码的方法名称打印出来,如下的打印信息(其中带有 “%” 的输出说明是由回边计数器触发的 OSR 编译):
命令行:java -XX:+PrintCompilation com.jvm.InliningDemo
从上面打印出的信息来看可以确认 calcSum()
、doubleValue()
方法已经被编译,我们还可以加上参数: -XX:+PrintInlining
要求虚拟机输出方法内联信息,如下的打印信息:
命令行:java -XX:+PrintCompilation -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining com.jvm.InliningDemo
从上面的打印信息可以看到方法 doubleValue()
被内联编译到 calcSum()
中,所以虚拟机再次执行 calcSum()
方法的时候,doubleValue()
方法不会再被调用,doubleValue()
的代码逻辑被直接内联到 calcSum()
方法中了。
如果除了本地代码的生成结果外,还想再进一步跟踪本地代码生成的具体过程,那还可以使用参数:-XX:+PrintCFGToFile
(使用 Client Compiler)或 -XX:+PrintIdealGraphFile
(使用 Server Compiler)令虚拟机将编译过程中各个阶段的数据(例如:对 C1 编译器来说,包括字节码、HIR 生成、LIR 生成、寄存器分配过程、本地代码生成等数据)输出到文件中。然后使用 Java HotSpot Client Compiler Visualizer
(用于分析 Client Compiler)或 Ideal Graph Visualizer
(用于分析 Server Compiler)打开这些数据文件进行分析。
以 Server Compiler 为例,来分析一下 JIT 编译器的代码的生成过程。
Server Compiler 的中间代码表示是一种名为 Ideal 的 SSA 形式程序依赖图,在运行 Java 程序的 JVM 参数中加入 -XX:+PrintIdealGraphLevel=2 -XX:+PrintIdealGraphFile=ideal.xml
,编译后将产生一个名为 ideal.xml
的文件,它包含了 Server Compiler 编译代码的过程信息,可以使用 Ideal Graph Visualizer 对这些信息进行分析,分析的过程我这里就不详细展开了,后面有时间可以再来研究分析下。
Java HotSpot Client Compiler Visualizer 官方站点
Java HotSpot Client Compiler Visualizer 官方站点
最后提醒一下读者,要输出 CFG 或 IdealGraph 文件,需要一个 Debug 版或 FastDebug 版的虚拟机支持,Product 版的虚拟机无法输出这些文件。
三、方法内联的条件
方法内联能够触发更多的优化。通常而言,内联越多,生成代码的执行效率越高。然而,对于即时编译器来说,内联越多,编译时间也就越长,而程序达到峰值性能的时刻也将被推迟。
此外,内联越多也将导致生成的机器码越长。在 Java 虚拟机里,编译生成的机器码会被部署到 Code Cache 之中。这个 Code Cache 是有大小限制的(由 Java 虚拟机参数 -XX:ReservedCodeCacheSize
控制)。
这就意味着,生成的机器码越长,越容易填满 Code Cache,从而出现 Code Cache 已满,即时编译已被关闭的警告信息(CodeCache is full. Compiler has been disabled)。
因此,即时编译器不会无限制地进行方法内联。下面我便列举即时编译器的部分内联规则。(其他的特殊规则,如自动拆箱总会被内联、Throwable 类的方法不能被其他类中的方法所内联,你可以直接参考 JDK 的源代码。)
首先,由 -XX:CompileCommand 中的 inline 指令指定的方法,以及由 @ForceInline 注解的方法(仅限于 JDK 内部方法),会被强制内联。 而由 -XX:CompileCommand 中的 dontinline 指令或 exclude 指令(表示不编译)指定的方法,以及由 @DontInline 注解的方法(仅限于 JDK 内部方法),则始终不会被内联。
其次,如果调用字节码对应的符号引用未被解析、目标方法所在的类未被初始化,或者目标方法是 native 方法,都将导致方法调用无法内联。
再次,C2 不支持内联超过 9 层的调用(可以通过虚拟机参数 -XX:MaxInlineLevel
调整),以及 1 层的直接递归调用(可以通过虚拟机参数 -XX:MaxRecursiveInlineLevel
调整)。
如果方法 a 调用了方法 b,而方法 b 调用了方法 c,那么我们称 b 为 a 的 1 层调用,而 c 为 a 的 2 层调用。
最后,即时编译器将根据方法调用指令所在的程序路径的热度,目标方法的调用次数及大小,以及当前 IR 图的大小来决定方法调用能否被内联。
我在上面的表格列举了一些 C2 相关的虚拟机参数。总体来说,即时编译器中的内联算法更青睐于小方法。
更多推荐
所有评论(0)