在前面的篇章中,我多次提到了方法内联这项技术。它指的是:在编译过程中遇到方法调用时,将目标方法的方法体纳入编译范围之中,并取代原方法调用的优化手段。
方法内联不仅可以消除调用本身带来的性能开销,还可以进一步触发更多的优化。因此,它可以算是编译优化里最为重要的一环。
以getter/setter为例,如果没有方法内联,在调用getter/setter时,程序需要保存当前方法的执行位置,创建并压入用于getter/setter的栈帧、访问字段、弹出栈帧,最后再恢复当前方法的执行。而当内联了对getter/setter的方法调用后,上述操作仅剩字段访问。
在C2中,方法内联是在解析字节码的过程中完成的。每当碰到方法调用字节码时,C2将决定是否需要内联该方法调用。如果需要内联,则开始解析目标方法的字节码。
复习一下:即时编译器首先解析字节码,并生成IR图,然后在该IR图上进行优化。优化是由一个个独立的优化阶段(optimization phase)串联起来的。每个优化阶段都会对IR图进行转换。最后即时编译器根据IR图的节点以及调度顺序生成机器码。
同C2一样,Graal也会在解析字节码的过程中进行方法调用的内联。此外,Graal还拥有一个独立的优化阶段,来寻找指代方法调用的IR节点,并将之替换为目标方法的IR图。这个过程相对来说比较形象一些,因此,今天我就利用它来给你讲解一下方法内联。
方法内联的过程public static boolean flag = true;public static int value0 = 0;public static int value1 = 1;public static int foo(int value) {int result = bar(flag);if (result != 0) {return result;} else {return value;}}public static int bar(boolean flag) {return flag ? value0 : value1;}
上面这段代码中的foo方法将接收一个int类型的参数,而bar方法将接收一个boolean类型的参数。其中,foo方法会读取静态字段flag的值,并作为参数调用bar方法。
foo方法的IR图(内联前)
在编译foo方法时,其对应的IR图中将出现对bar方法的调用,即上图中的5号Invoke节点。如果内联算法判定应当内联对bar方法的调用时,那么即时编译器将开始解析bar方法的字节码,并生成对应的IR图,如下图所示。
bar方法的IR图
接下来,即时编译器便可以进行方法内联,把bar方法所对应的IR图纳入到对foo方法的编译中。具体的操作便是将foo方法的IR图中5号Invoke节点替换为bar方法的IR图。
foo方法的IR图(内联后)
除了将被调用方法的IR图节点复制到调用者方法的IR图中,即时编译器还需额外完成下述三项操作。
第一,被调用方法的传入参数节点,将被替换为调用者方法进行方法调用时所传入参数对应的节点。在我们的例子中,就是将bar方法IR图中的1号P(0)节点替换为foo方法IR图中的3号LoadField节点。
第二,在调用者方法的IR图中,所有指向原方法调用节点的数据依赖将重新指向被调用方法的返回节点。如果被调用方法存在多个返回节点,则生成一个Phi节点,将这些返回值聚合起来,并作为原方法调用节点的替换对象。
在我们的例子中,就是将8号==节点,以及12号Return节点连接到原5号Invoke节点的边,重新指向新生成的24号Phi节点中。
第三,如果被调用方法将抛出某种类型的异常,而调用者方法恰好有该异常类型的处理器,并且该异常处理器覆盖这一方法调用,那么即时编译器需要将被调用方法抛出异常的路径,与调用者方法的异常处理器相连接。
经过方法内联之后,即时编译器将得到一个新的IR图,并且在接下来的编译过程中对这个新的IR图进行进一步的优化。不过在上面这个例子中,方法内联后的IR图并没有能够进一步优化的地方。
public final static boolean flag = true;public final static int value0 = 0;public final static int value1 = 1;public static int foo(int value) {int result = bar(flag);if (result != 0) {return result;} else {return value;}}public static int bar(boolean flag) {return flag ? value0 : value1;}
不过,如果我们将代码中的三个静态字段标记为final,那么Java编译器(注意不是即时编译器)会将它们编译为常量值(ConstantValue),并且在字节码中直接使用这些常量值,而非读取静态字段。举例来说,bar方法对应的字节码如下所示。
public static int bar(boolean);Code:0: iload_01: ifeq 84: iconst_05: goto 98: iconst_19: ireturn
在编译foo方法时,一旦即时编译器决定要内联对bar方法的调用,那么它会将调用bar方法所使用的参数,也就是常数1,替换bar方法IR图中的参数。经过死代码消除之后,bar方法将直接返回常数0,所需复制的IR图也只有常数0这么一个节点。
经过方法内联之后,foo方法的IR图将变成如下所示:
该IR图可以进一步优化(死代码消除),并最终得到这张极为简单的IR图:
方法内联能够触发更多的优化。通常而言,内联越多,生成代码的执行效率越高。然而,对于即时编译器来说,内联越多,编译时间也就越长,而程序达到峰值性能的时刻也将被推迟。
此外,内联越多也将导致生成的机器码越长。在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相关的虚拟机参数。总体来说,即时编译器中的内联算法更青睐于小方法。
今天我介绍了方法内联的过程以及条件。
方法内联是指,在编译过程中,当遇到方法调用时,将目标方法的方法体纳入编译范围之中,并取代原方法调用的优化手段。
即时编译器既可以在解析过程中替换方法调用字节码,也可以在IR图中替换方法调用IR节点。这两者都需要将目标方法的参数以及返回值映射到当前方法来。
方法内联有许多规则。除了一些强制内联以及强制不内联的规则外,即时编译器会根据方法调用的层数、方法调用指令所在的程序路径的热度、目标方法的调用次数及大小,以及当前IR图的大小来决定方法调用能否被内联。
今天的实践环节,你可以利用虚拟机参数-XX:+PrintInlining来打印编译过程中的内联情况。具体每项内联信息所代表的意思,你可以参考这一网页。