极客时间已完结课程限时免费阅读

21 | 方法内联(下)

21 | 方法内联(下)-极客时间

21 | 方法内联(下)

讲述:郑雨迪

时长09:48大小4.49M

在上一篇中,我举的例子都是静态方法调用,即时编译器可以轻易地确定唯一的目标方法。
然而,对于需要动态绑定的虚方法调用来说,即时编译器则需要先对虚方法调用进行去虚化(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 虚拟机便会对其所属的编译结果进行去优化。
public static int test(BinaryOp op) {
return op.apply(2, 1);
}
以上面这段代码中的 test 方法为例。假设即时编译的时候,如果类层次分析得出 BinaryOp 类只有 Add 一个子类的结论,那么即时编译器可以注册一个假设,假定抽象方法 BinaryOp.apply 有且仅有 Add.apply 这个具体实现。
基于这个假设,原虚方法调用便可直接被去虚化为对 Add.apply 方法的调用。如果在之后的运行过程中,Java 虚拟机又加载了 Sub 类,那么该假设失效,Java 虚拟机需要触发 test 方法编译结果的去优化。
public static int test(Add op) {
return op.apply(2, 1); // 仍需添加假设
}
事实上,即便调用者的声明类型为 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 中记录的类型相比较。如果匹配,则直接调用该记录类型所对应的目标方法。
public static int test(BinaryOp op) {
return op.apply(2, 1);
}
我们继续使用前面的例子。假设编译时类型 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);
}
}
分享给需要的人,Ta购买本课程,你将得18
生成海报并分享

赞 4

提建议

上一篇
20 | 方法内联(上)
下一篇
22 | HotSpot虚拟机的intrinsic
 写留言

精选留言(20)

  • 2018-09-12
    感觉跟不上了,先过吧! 已经拉下两节了,日后回头再看看。 现在仅明白,方法内联-是编译器的一种代码优化手段,会根据不同代码调用方式有不同的优化方式,目的都是为了提高JVM的效率,根本方式,我认为就是采用取巧的方式,提前判断出来可以少做一些事情,然后先提前做一些准备,整体的时间和空间成本会降下来。 另外,提供小建议,雨迪能否对于这种比较比较抽象的知识,来点生动形象的比喻以便帮助消化,之前在知乎看到一篇关于锁的文章,全篇通过生动形象的比喻讲解锁的本质、分类、各种锁的特点,读起来一下子就明白了。
    展开
    共 3 条评论
    29
  • 永烁星光
    2018-09-10
    IR 图分析看了这三篇,好几次,现在还是不甚明白,

    作者回复: 如果都看懂了,可以考虑来我司,或者阿里的JIT专家职位 :) 一般来说,了解这些优化针对怎样的代码模式,会转化为怎样的代码就可以啦。至于IR图,主要是用来辅助理解具体的优化过程。

    共 3 条评论
    13
  • Joker
    2019-08-16
    漫漫长路,这JAVA一门语言就要如此深究,真特么知无涯
    10
  • 西门吹牛
    2020-07-20
    方法内联就是将调用的目标方法,内联到调用者方法里面,以避免目标方法的重复调用带来的开销,但是在内联时,如果目标方法,完全确定,也就是说,目标方法的调用是唯一的,那么直接内联就可, 但是由于Java的多态特性,基于接口而非实现编程等,导致目标方法的调用的需要在运行时确定,也就是虚方法的调用在即时编译阶段无法确定唯一调用的目标方法版本,而内联是在即时编译阶段。 一部分方法的符号引用在编译阶段就可以确定唯一的调用版本,但是一部分必须在运行时才能将符号引用替换为直接引用,这就导致,在即时编译器进行内联时,这部分方法没法确定唯一的调用版本,于是就有去虚化手段,把虚方法调用通过一定的去虚化手段,直接替换为直接调用,保证内联后的方法在实际运行时不会出错。 去虚化的手段,只能尽量保证虚方法的调用能直接替换为直接调用,只有准确的替换,才能体现出内联的优势,如果实在确定不了虚方法调用的准确版本,那么就去优化,也就是不内联了。 基于类型的去虚化:通过对象的静态类型,实际类型,一些重载,重写方法的调用,其实编译器能通过具体的数据类型,进行识别。可以说一旦识别,就准确无误。 基于层次的去虚化:完全依赖于jvm类的加载,基于只加载一个类的假设。适用场景很受限。 基于条件的去虚化:依赖于分层编译时收集的数据。 总的来说,内联带来的程序运行的性能提升要远远大于内联的成本。方法内联,为的就是把即时编译的性能发挥到极致。都是为性能考虑的。
    展开
    8
  • 一少爷
    2019-02-27
    为什么后面留言的人越来越少了,我觉得后面这些也很关键很有趣呀。对思想的提升很有帮助的。
    6
  • 随心而至
    2019-10-25
    免费的才是最贵的,享受便利的同时,想搞明白确实不容易,我只有个大的概念。感觉这可以类比CPU里面的冒险与预测来理解,都是基于某种方式来优化,让程序跑的更快些。
    4
  • 饭粒
    2019-12-24
    基于类型推导的完全去虚化 基于类层次分析的完全去虚化 条件虚拟化 目录挺清楚的,极客时间的文章出个标题侧栏就更好了。
    2
  • 李亮亮
    2019-04-11
    后面两张图是不是还应该有Deopt NullCheckException 这条红色的路径?
    2
  • hqg
    2018-09-07
    遇到jvm崩溃,可否帮分析下
    1
  • 史海洋
    2021-06-09
    这一章比周志明书里讲的还是要深一点. 两类去虚化 三种去虚化方式 首先类型推导,看能否确认唯一类型。 再次,类层次分析看能否确认唯一子类。 最后基于即时编译收集到的profile做一些假设,以及类型判断。进行去虚化或者去优化。 结合即时编译章节看更容易理解后半部分。
    展开
  • 剑八
    2020-06-14
    总结下来: 为了将invokeVertual的调用转成直接调用,然后进行内联 而方案有: 类型推导,条件判断,类层次分析 类型推导就是看代码中对应虚方法调用的实际类型,如果存在多种类型调用这个方案就无效了。 条件判断是判断每一个虚方法对应实现类,然后转成直接调用。 类层次调用则根据虚方法对应的实现类是否只有一个类型被加载。
    展开
  • GaGi
    2020-04-13
    文中:“如果某个假设不再成立,那么 Java 虚拟机便会对其所属的编译结果进行去优化”的去优化是什么意思呢?
  • 单俊宁
    2020-03-31
    想问老师,很多底层理论知识当时学了,过了一段时间就忘了,怎么和实际工作联系起来,不至于过段时间就忘了
    共 1 条评论
  • 小陈
    2020-03-29
    ir图很深奥啊,看不懂呐
  • 饭粒
    2019-12-24
    可以结合05篇看。
  • 星星个是大太阳丶
    2018-09-25
    节点上的P(0)是否是代表方法的参数,C(1)这些代表常量,各个节点的线的颜色是否有什么含义呢?老师能不能指导一下
  • Scott
    2018-09-10
    是每个对象有type profile的限制么?

    作者回复: 每条类型相关字节码,如invokeinterface invokevirtual checkcast instanceof等

  • Scott
    2018-09-09
    是C1在不同的编译层次么?
  • Scott
    2018-09-09
    我也不清楚,什么时候可以有完整的profile,什么时候是不完整的

    作者回复: 回了原提问,这里复制一下: 每个字节码的type profile有数量限制,比如默认情况下只能存两个不同的动态类型。如果收集profile过程中来了三个不同的动态类型,那么JVM不能全部记下来,因此即时编译器看到的type profile是不完整的。

  • Void_seT
    2018-09-08
    老师,想请教一下,“类型Profile”完整还是不完整,是如何判断的?

    作者回复: 每个字节码的type profile有数量限制,比如默认情况下只能存两个不同的动态类型。如果收集profile过程中来了三个不同的动态类型,那么JVM不能全部记下来,因此即时编译器看到的type profile是不完整的。