21 方法内联(下)
在上一篇中,我举的例子都是静态方法调用,即时编译器可以轻易地确定唯一的目标方法。
然而,对于需要动态绑定的虚方法调用来说,即时编译器则需要先对虚方法调用进行去虚化(devirtualize),即转换为一个或多个直接调用,然后才能进行方法内联。
即时编译器的去虚化方式可分为完全去虚化以及条件去虚化(guarded devirtualization)。
完全去虚化是通过类型推导或者类层次分析(class hierarchy analysis),识别虚方法调用的唯一目标方法,从而将其转换为直接调用的一种优化手段。它的关键在于证明虚方法调用的目标方法是唯一的。
条件去虚化则是将虚方法调用转换为若干个类型测试以及直接调用的一种优化手段。它的关键在于找出需要进行比较的类型。
在介绍具体的去虚化方式之前,我们先来看一段代码。这里我定义了一个抽象类BinaryOp,其中包含一个抽象方法apply。BinaryOp类有两个子类Add和Sub,均实现了apply方法。
abstract class BinaryOp {
public abstract int apply(int a, int b);
}
class Add extends BinaryOp {
public int apply(int a, int b) {
return a + b;
}
}
class Sub extends BinaryOp {
public int apply(int a, int b) {
return a - b;
}
}
下面我便用这个例子来逐一讲解这几种去虚化方式。
基于类型推导的完全去虚化
基于类型推导的完全去虚化将通过数据流分析推导出调用者的动态类型,从而确定具体的目标方法。
public static int foo() {
BinaryOp op = new Add();
return op.apply(2, 1);
}
public static int bar(BinaryOp op) {
op = (Add) op;
return op.apply(2, 1);
}
举个例子,上面这段代码中的foo方法和bar方法均会调用apply方法,且调用者的声明类型皆为BinaryOp。这意味着Java编译器会将其编译为invokevirtual指令,调用BinaryOp.apply方法。
前两篇中我曾提到过,在Sea-of-Nodes的IR系统中,变量不复存在,取而代之的是具体值。这些具体值的类型往往要比变量的声明类型精确。
foo方法的IR图(方法内联前)
bar方法的IR图(方法内联前)
在上面两张IR图中,方法调用的调用者(即8号CallTarget节点的第一个依赖值)分别为2号New节点,以及5号Pi节点。后者可以简单看成强制转换后的精确类型。由于这两个节点的类型均被精确为Add类,因此,原invokevirtual指令对应的9号invoke节点都被识别对Add.apply方法的调用。
经过对该具体方法的内联之后,对应的IR图如下所示:
foo方法的IR图(方法内联及逃逸分析后)
bar方法的IR图(方法内联后)
可以看到,通过将字节码转换为Sea-of-Nodes IR之后,即时编译器便可以直接去虚化,并将唯一的目标方法进一步内联进来。
public static int notInlined(BinaryOp op) {
if (op instanceof Add) {
return op.apply(2, 1);
}
return 0;
}
不过,对于上面这段代码中的notInlined方法,尽管理论上即时编译器能够推导出调用者的动态类型为Add,但是C2和Graal都没有这么做。
其原因在于类型推导属于全局优化,本身比较浪费时间;另一方面,就算不进行基于类型推导的完全去虚化,也有接下来的基于类层次分析的去虚化,以及条件去虚化兜底,覆盖大部分的代码情况。
notInlined方法的IR图(方法内联失败后)
因此,C2和Graal决定,如果生成Sea-of-Nodes IR后,调用者的动态类型已能够直接确定,那么就进行这项去虚化。如果需要额外的数据流分析方能确定,那么干脆不做,以节省编译时间,并依赖接下来的去虚化手段进行优化。
基于类层次分析的完全去虚化
基于类层次分析的完全去虚化通过分析Java虚拟机中所有已被加载的类,判断某个抽象方法或者接口方法是否仅有一个实现。如果是,那么对这些方法的调用将只能调用至该具体实现中。
在上面的例子中,假设在编译foo、bar或notInlined方法时,Java虚拟机仅加载了Add。那么,BinaryOp.apply方法只有Add.apply这么一个具体实现。因此,当即时编译器碰到对BinaryOp.apply的调用时,便可直接内联Add.apply的内容。
那么问题来了,即时编译器如何保证在今后的执行过程中,BinaryOp.apply方法还是只有Add.apply这么一个具体实现呢?
事实上,它无法保证。因为Java虚拟机有可能在上述编译完成之后加载Sub类,从而引入另一个BinaryOp.apply方法的具体实现Sub.apply。
Java虚拟机的做法是为当前编译结果注册若干个假设(assumption),假定某抽象类只有一个子类,或者某抽象方法只有一个具体实现,又或者某类没有子类等。
之后,每当新的类被加载,Java虚拟机便会重新验证这些假设。如果某个假设不再成立,那么Java虚拟机便会对其所属的编译结果进行去优化。
以上面这段代码中的test方法为例。假设即时编译的时候,如果类层次分析得出BinaryOp类只有Add一个子类的结论,那么即时编译器可以注册一个假设,假定抽象方法BinaryOp.apply有且仅有Add.apply这个具体实现。
基于这个假设,原虚方法调用便可直接被去虚化为对Add.apply方法的调用。如果在之后的运行过程中,Java虚拟机又加载了Sub类,那么该假设失效,Java虚拟机需要触发test方法编译结果的去优化。
事实上,即便调用者的声明类型为Add,即时编译器仍需为之添加假设。这是因为Java虚拟机不能保证没有重写了apply方法的Add类的子类。
为了保证这里apply方法的语义,即时编译器需要假设Add类没有子类。当然,通过将Add类标注为final,可以避开这个问题。
可以看到,即时编译器并不要求目标方法使用final修饰符。只要目标方法事实上是final的(effective final),便可以进行相应的去虚化以及内联。
不过,如果使用了final修饰符,即时编译器便可以不用生成对应的假设。这将使编译结果更加精简,并减少类加载时所需验证的内容。
test方法的IR图(方法内联后)
让我们回到原本的例子中。从test方法的IR图可以看出,生成的代码无须检测调用者的动态类型是否为Add,便直接执行内联之后的Add.apply方法中的内容(2+1经过常量折叠之后得到3,对应13号常数节点)。这是因为动态类型检测已被移至假设之中了。
然而,对于接口方法调用,该去虚化手段则不能移除动态类型检测。这是因为在执行invokeinterface指令时,Java虚拟机必须对调用者的动态类型进行测试,看它是否实现了目标接口方法所在的接口。
Java类验证器将接口类型直接看成Object类型,所以有可能出现声明类型为接口,实际类型没有继承该接口的情况,如下例所示。
// A.java
interface I {}
public class A {
public static void test(I obj) {
System.out.println("Hello World");
}
public static void main(String[] args) {
test(new B());
}
}
// B.java
public class B implements I { }
// Step 1: compile A.java and B.java
// Step 2: remove "implements I" from B.java, and compile B.java
// Step 3: run A
既然这一类型测试无法避免,C2干脆就不对接口方法调用进行基于类层次分析的完全去虚化,而是依赖于接下来的条件去虚化。
条件去虚化
前面提到,条件去虚化通过向代码中添加若干个类型比较,将虚方法调用转换为若干个直接调用。
具体的原理非常简单,是将调用者的动态类型,依次与Java虚拟机所收集的类型Profile中记录的类型相比较。如果匹配,则直接调用该记录类型所对应的目标方法。
我们继续使用前面的例子。假设编译时类型Profile记录了调用者的两个类型Sub和Add,那么即时编译器可以据此进行条件去虚化,依次比较调用者的动态类型是否为Sub或者Add,并内联相应的方法。其伪代码如下所示:
public static int test(BinaryOp op) {
if (op.getClass() == Sub.class) {
return 2 - 1; // inlined Sub.apply
} else if (op.getClass() == Add.class) {
return 2 + 1; // inlined Add.apply
} else {
... // 当匹配不到类型Profile中的类型怎么办?
}
}
如果遍历完类型Profile中的所有记录,仍旧匹配不到调用者的动态类型,那么即时编译器有两种选择。
第一,如果类型Profile是完整的,也就是说,所有出现过的动态类型都被记录至类型Profile之中,那么即时编译器可以让程序进行去优化,重新收集类型Profile,对应的IR图如下所示(这里27号TypeSwitch节点等价于前面伪代码中的多个if语句):
当匹配不到动态类型时进行去优化
第二,如果类型Profile是不完整的,也就是说,某些出现过的动态类型并没有记录至类型Profile之中,那么重新收集并没有多大作用。此时,即时编译器可以让程序进行原本的虚调用,通过内联缓存进行调用,或者通过方法表进行动态绑定。对应的IR图如下所示:
当匹配不到动态类型时进行虚调用(仅在Graal中使用。)
在C2中,如果类型Profile是不完整的,即时编译器压根不会进行条件去虚化,而是直接使用内联缓存或者方法表。
总结与实践
今天我介绍了即时编译器去虚化的几种方法。
完全去虚化通过类型推导或者类层次分析,将虚方法调用转换为直接调用。它的关键在于证明虚方法调用的目标方法是唯一的。
条件去虚化通过向代码中增添类型比较,将虚方法调用转换为一个个的类型测试以及对应该类型的直接调用。它将借助Java虚拟机所收集的类型Profile。
今天的实践环节,我们来重现因类加载导致去优化的过程。
// Run with java -XX:CompileCommand='dontinline JITTest.test' -XX:+PrintCompilation JITTest
public class JITTest {
static abstract class BinaryOp {
public abstract int apply(int a, int b);
}
static class Add extends BinaryOp {
public int apply(int a, int b) {
return a + b;
}
}
static class Sub extends BinaryOp {
public int apply(int a, int b) {
return a - b;
}
}
public static int test(BinaryOp op) {
return op.apply(2, 1);
}
public static void main(String[] args) throws Exception {
Add add = new Add();
for (int i = 0; i < 400_000; i++) {
test(add);
}
Thread.sleep(2000);
System.out.println("Loading Sub");
Sub[] array = new Sub[0]; // Load class Sub
// Expect output: "JITTest::test (7 bytes) made not entrant"
Thread.sleep(2000);
}
}
- 永烁星光 👍(14) 💬(3)
IR 图分析看了这三篇,好几次,现在还是不甚明白,
2018-09-10 - Scott 👍(1) 💬(1)
是每个对象有type profile的限制么?
2018-09-10 - Scott 👍(0) 💬(1)
我也不清楚,什么时候可以有完整的profile,什么时候是不完整的
2018-09-09 - Void_seT 👍(0) 💬(1)
老师,想请教一下,“类型Profile”完整还是不完整,是如何判断的?
2018-09-08 - 钱 👍(31) 💬(3)
感觉跟不上了,先过吧! 已经拉下两节了,日后回头再看看。 现在仅明白,方法内联-是编译器的一种代码优化手段,会根据不同代码调用方式有不同的优化方式,目的都是为了提高JVM的效率,根本方式,我认为就是采用取巧的方式,提前判断出来可以少做一些事情,然后先提前做一些准备,整体的时间和空间成本会降下来。 另外,提供小建议,雨迪能否对于这种比较比较抽象的知识,来点生动形象的比喻以便帮助消化,之前在知乎看到一篇关于锁的文章,全篇通过生动形象的比喻讲解锁的本质、分类、各种锁的特点,读起来一下子就明白了。
2018-09-12 - Joker 👍(11) 💬(0)
漫漫长路,这JAVA一门语言就要如此深究,真特么知无涯
2019-08-16 - 西门吹牛 👍(8) 💬(0)
方法内联就是将调用的目标方法,内联到调用者方法里面,以避免目标方法的重复调用带来的开销,但是在内联时,如果目标方法,完全确定,也就是说,目标方法的调用是唯一的,那么直接内联就可, 但是由于Java的多态特性,基于接口而非实现编程等,导致目标方法的调用的需要在运行时确定,也就是虚方法的调用在即时编译阶段无法确定唯一调用的目标方法版本,而内联是在即时编译阶段。 一部分方法的符号引用在编译阶段就可以确定唯一的调用版本,但是一部分必须在运行时才能将符号引用替换为直接引用,这就导致,在即时编译器进行内联时,这部分方法没法确定唯一的调用版本,于是就有去虚化手段,把虚方法调用通过一定的去虚化手段,直接替换为直接调用,保证内联后的方法在实际运行时不会出错。 去虚化的手段,只能尽量保证虚方法的调用能直接替换为直接调用,只有准确的替换,才能体现出内联的优势,如果实在确定不了虚方法调用的准确版本,那么就去优化,也就是不内联了。 基于类型的去虚化:通过对象的静态类型,实际类型,一些重载,重写方法的调用,其实编译器能通过具体的数据类型,进行识别。可以说一旦识别,就准确无误。 基于层次的去虚化:完全依赖于jvm类的加载,基于只加载一个类的假设。适用场景很受限。 基于条件的去虚化:依赖于分层编译时收集的数据。 总的来说,内联带来的程序运行的性能提升要远远大于内联的成本。方法内联,为的就是把即时编译的性能发挥到极致。都是为性能考虑的。
2020-07-20 - 一少爷 👍(6) 💬(0)
为什么后面留言的人越来越少了,我觉得后面这些也很关键很有趣呀。对思想的提升很有帮助的。
2019-02-27 - 随心而至 👍(4) 💬(0)
免费的才是最贵的,享受便利的同时,想搞明白确实不容易,我只有个大的概念。感觉这可以类比CPU里面的冒险与预测来理解,都是基于某种方式来优化,让程序跑的更快些。
2019-10-25 - 饭粒 👍(2) 💬(0)
基于类型推导的完全去虚化 基于类层次分析的完全去虚化 条件虚拟化 目录挺清楚的,极客时间的文章出个标题侧栏就更好了。
2019-12-24 - 李亮亮 👍(2) 💬(0)
后面两张图是不是还应该有Deopt NullCheckException 这条红色的路径?
2019-04-11 - hqg 👍(1) 💬(0)
遇到jvm崩溃,可否帮分析下
2018-09-07 - 史海洋 👍(0) 💬(0)
这一章比周志明书里讲的还是要深一点. 两类去虚化 三种去虚化方式 首先类型推导,看能否确认唯一类型。 再次,类层次分析看能否确认唯一子类。 最后基于即时编译收集到的profile做一些假设,以及类型判断。进行去虚化或者去优化。 结合即时编译章节看更容易理解后半部分。
2021-06-09 - 剑八 👍(0) 💬(0)
总结下来: 为了将invokeVertual的调用转成直接调用,然后进行内联 而方案有: 类型推导,条件判断,类层次分析 类型推导就是看代码中对应虚方法调用的实际类型,如果存在多种类型调用这个方案就无效了。 条件判断是判断每一个虚方法对应实现类,然后转成直接调用。 类层次调用则根据虚方法对应的实现类是否只有一个类型被加载。
2020-06-14 - GaGi 👍(0) 💬(0)
文中:“如果某个假设不再成立,那么 Java 虚拟机便会对其所属的编译结果进行去优化”的去优化是什么意思呢?
2020-04-13