Serie JVM: método de aprendizaje profundo en línea

Este artículo es el decimoctavo artículo en el "Estudio en profundidad de la serie JVM"

La inserción de métodos se ha mencionado muchas veces en los artículos anteriores. Como la tecnología de optimización más importante del compilador, esta tecnología no solo puede eliminar la sobrecarga de rendimiento causada por la llamada en sí, sino que también puede desencadenar más optimizaciones. Este artículo lo llevará a través de la tecnología.

método en línea

La inserción de métodos se refiere a: cuando se encuentra una llamada a un método durante el proceso de compilación, el cuerpo del método de destino se incluye en el alcance de la compilación y se reemplaza el método de optimización para reemplazar la llamada al método original.

Tomando el getter/setter como ejemplo, si no hay un método en línea, al llamar al getter/setter, el programa necesita guardar la posición de ejecución del método actual, crear y empujar el marco de pila para el getter/setter, acceder a la campo, abra el marco de la pila y, finalmente, reanude la ejecución del método actual. Y cuando las llamadas de método a getters/setters están en línea, lo único que queda para la operación anterior es el acceso al campo. En C2, la inserción de métodos se realiza durante el análisis del código de bytes. Cada vez que se encuentra un código de bytes de llamada de método, el C2 decidirá si la llamada de método debe estar en línea. Si se requiere la inserción, comience a analizar el código de bytes del método de destino.

Usamos el siguiente ejemplo para demostrar el proceso de inserción de métodos:

  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;
  }
复制代码

El método foo del código anterior recibirá un parámetro de tipo int y el método bar recibirá un parámetro de tipo booleano. Entre ellos, el método foo leerá el valor del indicador de campo estático y llamará al método bar como parámetro. Miramos el código y entendemos que el método de la barra está arreglado para devolver el valor 0, pero el intérprete de Java no lo sabe, por lo que es inútil esperar que el intérprete optimice este código.

De acuerdo con la definición de método en línea, sabemos que el compilador puede optimizar este fragmento de código, luego intentamos llamar al método foo y calentarlo hasta cierto punto, y finalmente desencadenar la compilación justo a tiempo. Agregamos el método de prueba de la siguiente manera:

  public static void main(String[] args) {
    for (int i = 0; i < 20000; i++) {
      foo(i);
    }
  }
复制代码

Agregue el parámetro -XX:+PrintCompilation antes de la ejecución para generar el resultado de la compilación. Para conocer el significado específico de cada información en línea, puede consultar el sitio web oficial .

    235   28       3       com.msdn.java.javac.jit.MethodInlineTest::foo (15 bytes)
    235   29       3       com.msdn.java.javac.jit.MethodInlineTest::bar (14 bytes)
    236   30       4       com.msdn.java.javac.jit.MethodInlineTest::foo (15 bytes)
    236   28       3       com.msdn.java.javac.jit.MethodInlineTest::foo (15 bytes)   made not entrant
复制代码

Se puede ver que el método foo activa la compilación justo a tiempo, y luego usamos el siguiente comando para ver si el método bar está integrado en el método foo.

-XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining
复制代码

La salida es:

inline indica que el método de la barra se insertó correctamente.

网上关于方法内联的推论是利用 IR 图,但是这个东西一方面看起来有些费劲,然后查看 IR 图需要 Ideal Graph Visualizer 工具,该工具对 JDK 版本有限制,只能配置 JDK6,且只支持 Windows 和 Liunx,所以就不讨论 IR 图了。

我们通过修改原代码,一步步来演示优化过程。

第一步,方法内联

public static int foo(int value) {
  int result = flag ? value0 : value1;
  if (result != 0) {
    return result;
  } else {
    return value;
  }
}
复制代码

第二步,死代码消除

public static int foo(int value) {
  int result = 0;
  if (result != 0) {
    return result;
  } else {
    return value;
  }
}
复制代码

上述代码方法内联成功了,那么什么情况下可以方法内联,有什么限制吗?我们继续往下学习。

方法内联的条件

方法内联能够触发更多的优化。通常而言,内联越多,生成代码的执行效率越高。然而,对于即时编译器来说,内联越多,编译时间也就越长,而程序达到峰值性能的时刻也将被推迟。

同时,内联意味着有编译,编译后的机器码要存到 Codecache 中,而 Codecache 内存是有固定大小的由 Java 虚拟机参数 -XX:ReservedCodeCacheSize 控制),如果 Codecache 满了,则会带来性能问题。

因此,即时编译器不会无限制地进行方法内联。下面我便列举即时编译器的部分内联规则。(其他的特殊规则,可以直接参考JDK 的源代码。)

第一,由 -XX:CompileCommand 中的 inline 指令指定的方法,以及由 @ForceInline 注解的方法(仅限于 JDK 内部方法),会被强制内联。 而由 -XX:CompileCommand 中的 dontinline 指令或 exclude 指令(表示不编译)指定的方法,以及由 @DontInline 注解的方法(仅限于 JDK 内部方法),则始终不会被内联。

这里仅测试手动禁止内联,基于上述代码,我们增加如下 JVM 参数:

-XX:+PrintCompilation -XX:CompileCommand=exclude,com/msdn/java/javac/jit/MethodInlineTest::bar
复制代码

编译输出结果为:

### Excluding compile: static com.msdn.java.javac.jit.MethodInlineTest::bar
made not compilable on all levels  com.msdn.java.javac.jit.MethodInlineTest::bar (14 bytes)   excluded by CompilerOracle
    213   28       3       com.msdn.java.javac.jit.MethodInlineTest::foo (15 bytes)
    214   29       4       com.msdn.java.javac.jit.MethodInlineTest::foo (15 bytes)
    214   30       4       java.lang.String::hashCode (55 bytes)
    214   28       3       com.msdn.java.javac.jit.MethodInlineTest::foo (15 bytes)   made not entrant
    214   31       3       java.util.Arrays::copyOfRange (63 bytes)
复制代码

即使执行 -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining 参数,也不会输出 inline 标识。

第二,如果调用字节码对应的符号引用未被解析、目标方法所在的类未被初始化,或者目标方法是 native 方法,都将导致方法调用无法内联。

第三,C2 不支持内联超过 9 层的调用(MaxInlineLevel默认为9,可以调整该参数大小),以及 1 层的直接递归调用(可以通过虚拟机参数 -XX:MaxRecursiveInlineLevel 调整)。

关于 1 层的直接递归调用,如下案例所示:

  public static int foo(int value) {
    if (1 == value) {
      return 1;
    } else {
      return value + foo(value - 1);
    }
  }

  public static void main(String[] args) {
    for (int i = 0; i < 10000; i++) {
      foo(3);
    }
  }
复制代码

利用虚拟机参数 -XX:+PrintInlining 来打印编译过程中的内联情况,发现无法内联。尝试修改 MaxRecursiveInlineLevel 参数,再次执行代码。

-XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining -XX:MaxRecursiveInlineLevel=5
复制代码

发现结果中有内联标识。

第四,即时编译器将根据方法调用指令所在的程序路径的热度,目标方法的调用次数及大小,以及当前 IR 图的大小来决定方法调用能否被内联。

img

虚方法内联

在讲述虚方法内联前,我们回顾一下之前讲述《方法是如何调用的》一节的虚方法,关于虚方法和非虚方法,是这样定义的:

只有使用 invokespecial 指令调用的私有方法、实例构造器、父类方法和使用 invokestatic 指令调用的静态方法才会在编译期进行解析,再加上被 final 修饰的方法(尽管它使用invokevirtual指令调用) ,这5种方法调用会在类加载的时候就可以把符号引用解析为该方法的直接引用。这些方法统称为“非虚方法”(Non-Virtual Method),与之相反,其他方法就被称为“虚方法”(Virtual Method)。“非虚方法”可以内联,“虚方法”可以采取相关措施来实现内联。

关于虚方法调用有两个优化:内联缓存(inlining cache)和方法内联(method inlining)。

这里简单回忆一下内联缓存的描述,内联缓存是一种加快动态绑定的优化技术。它能够缓存虚方法调用中调用者的动态类型,以及该类型所对应的目标方法。在之后的执行过程中,如果碰到已缓存的类型,内联缓存便会直接调用该类型所对应的目标方法。如果没有碰到已缓存的类型,内联缓存则会退化至使用基于方法表的动态绑定。

上文举的例子都是静态方法调用(非虚方法调用),即时编译器可以轻易地确定唯一的目标方法。

接下来讲述的就是关于虚方法如何实现方法内联,相关措施为:对于需要动态绑定的虚方法调用来说,即时编译器则需要先对虚方法调用进行去虚化(devirtualize),即转换为一个或多个直接调用,然后才能进行方法内联。

为了后文的顺利进行,我们先了解几个字段代表的含义:

  • inline (hot),使用 -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining 参数时输出代码的内联情况,如果某个方法被内联了,结尾会跟有 inline (hot) 标识,当然也有其他一些标识,想要了解它们的含义可以参考官网

  • made not entrant,使用 -XX:+PrintCompilation 参数时输出代码的编译情况,该标识表示发生了去优化,关于去优化可以参考我的上一篇文章。

  • virtual_call,使用 JITWatch 或者 hsdis 查看汇编代码时会遇到,表示虚方法调用。

  • optimized virtual_call,使用 JITWatch 或者 hsdis 查看汇编代码时会遇到,表示虚方法优化。

  • runtime_call,使用 JITWatch 或者 hsdis 查看汇编代码时会遇到,表示运行时调用。

在学习过程中,遇到了这么一个问题,optimized virtual_call 和 inline (hot)之间的关系,仅从字面意义上来看,针对虚方法的内联不就是优化嘛,那不就意味着 inline (hot)=》optimized virtual_call,这是我初期的认知。网上文章也有相关的描述,比如说:

可以看到JIT对methodCall方法进行了虚调用优化optimized virtual_call。经过优化后的方法可以被内联。

在网上搜了很久,关于 optimized virtual_call 的介绍并不多,没法将其认定作为方法内联的判断条件,而且经过多次测试验证,发现某些虚方法带有 inline (hot) 标识,但是汇编代码中没有出现 optimized virtual_call,而是 runtime_call。所以在后文中我不会使用其作为判断方法内联的条件。(如果有大佬知晓这两者之间的关联,烦请告知一下,谢谢啦)

回归正题,看看 Java 虚拟机是如何解决虚方法的内联问题。

即时编译器的去虚化方式可分为完全去虚化以及条件去虚化(guarded devirtualization)。

完全去虚化是通过类型继承关系分析(class hierarchy analysis),识别虚方法调用的唯一目标方法,从而将其转换为直接调用的一种优化手段。它的关键在于证明虚方法调用的目标方法是唯一的。

条件去虚化则是将虚方法调用转换为若干个类型测试以及直接调用的一种优化手段。它的关键在于找出需要进行比较的类型。

说到动态绑定,自然而然就会想到继承关系,所以我们就构造这样一段代码:

public abstract class BinaryOp {
  public abstract int apply(int a, int b);
}

public class Add extends BinaryOp {
  public int apply(int a, int b) {
    return a + b;
  }
}
复制代码

基于类型继承关系分析的完全去虚化

为了解决虚方法的内联问题, Java虚拟机首先引入了一种名为类型继承关系分析(Class HierarchyAnalysis, CHA) 的技术, 这是整个应用程序范围内的类型分析技术,用于确定在目前已加载的类中, 某个接口是否有多于一种的实现、 某个类是否存在子类、 某个子类是否覆盖了父类的某个虚方法等信息。

基于类型推导的完全去虚化将通过数据流分析推导出调用者的动态类型,从而确定具体的目标方法。

  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。

还是不习惯使用 IR 图,所以我们从字节码入手吧。

public static int foo();
    descriptor: ()I
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=3, locals=1, args_size=0
         0: new           #2                  // class com/msdn/java/javac/jit/Add
         3: dup
         4: invokespecial #3                  // Method com/msdn/java/javac/jit/Add."<init>":()V
         7: astore_0
         8: aload_0
         9: iconst_2
        10: iconst_1
        11: invokevirtual #5                  // Method com/msdn/java/javac/jit/BinaryOp.apply:(II)I
        14: ireturn
      LineNumberTable:
        line 19: 0
        line 20: 8

  public static int bar(com.msdn.java.javac.jit.BinaryOp);
    descriptor: (Lcom/msdn/java/javac/jit/BinaryOp;)I
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=3, locals=1, args_size=1
         0: aload_0
         1: checkcast     #2                  // class com/msdn/java/javac/jit/Add
         4: astore_0
         5: aload_0
         6: iconst_2
         7: iconst_1
         8: invokevirtual #5                  // Method com/msdn/java/javac/jit/BinaryOp.apply:(II)I
        11: ireturn
复制代码

在 foo 方法的字节码中,有个关键的 new 指令,而在 bar 方法中有 checkcast 指令,后者可以简单看成强制转换后的精确类型。由于这两个节点的类型均被精确为 Add 类,因此,原 invokevirtual 指令虽然指向 BinaryOp.apply 方法,但是在解析过程中被识别对 Add.apply 方法的调用。

综上,即时编译器因为确定了对象类型,可以直接去虚化,虽然是 invokevirtual 指令,但却可以确定出唯一的方法,最终实现方法内联。

在 bar 方法中,我们还强制将 op 对象转换为 Add 类型,保证了唯一性。那么如果没有这次强转语句,是否可以进行内联呢?

  public static int test(BinaryOp op) {
    return op.apply(2, 1);
  }
  
  //main测试方法
  public static void main(String[] args) {
  	Add add = new Add();
    for (int i = 0; i < 20000; i++) {
      test(add);
    }
  }
复制代码

输出编译结果如下,test 和 apply 方法都触发了即时编译。

    632   48       3       com.msdn.java.javac.jit.VirtualTest::test (7 bytes)
    632   49       3       com.msdn.java.javac.jit.Add::apply (4 bytes)
    632   50       1       com.msdn.java.javac.jit.Add::apply (4 bytes)
    632   51       4       com.msdn.java.javac.jit.VirtualTest::test (7 bytes)
    632   49       3       com.msdn.java.javac.jit.Add::apply (4 bytes)   made not entrant
    633   48       3       com.msdn.java.javac.jit.VirtualTest::test (7 bytes)   made not entrant
复制代码

毫无意外,apply 方法同样被内联了。

如果 BinaryOp 还有另外一个子类,那么情况会做如何改变,首先增加 Sub 类,然后再进行测试。

public class Sub extends BinaryOp{

  @Override
  public int apply(int a, int b) {
    return a - b;
  }
}
复制代码

关于 main 方法不变,继续调用 test 方法进行测试,这里我们打印一下类的加载情况。可以发现 Java 虚拟机仅仅加载了 Add,那么,BinaryOp.apply 方法只有 Add.apply 这么一个具体实现。因此,当即时编译器碰到对 BinaryOp.apply 的调用时,便可直接内联 Add.apply 的内容。

如果同时初始化 Add 类和 Sub 类,那编译器又该如何处理?

条件去虚化

根据类型继承关系分析的定义可知,当 Add 和 Sub 类同时存在,BinaryOp.apply 方法有两种具体实现,则该项技术已经无法处理这种情况。那么就只能依赖接下来的条件去虚化,条件去虚化通过向代码中添加若干个类型比较,将虚方法调用转换为若干个直接调用。

具体的原理非常简单,是将调用者的动态类型,依次与 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 中的记录可以匹配到对应的对象类型,即时编译器则可以进行方法内联。我们实操一下看看结果:

  public static void main(String[] args) {
  	Add add = new Add();
    Sub sub = new Sub();
    for (int i = 0; i < 20000; i++) {
      test(add);
      test(sub);
    }
  }
复制代码

编译结果为:

直接看内联结果如下:

如果遍历完类型 Profile 中的所有记录,仍旧匹配不到调用者的动态类型,那么即时编译器有两种选择。

第一,如果类型 Profile 是完整的,也就是说,所有出现过的动态类型都被记录至类型 Profile 之中,那么即时编译器可以让程序进行去优化,重新收集类型 Profile。

第二,如果类型 Profile 是不完整的,也就是说,某些出现过的动态类型并没有记录至类型 Profile 之中,那么重新收集并没有多大作用。此时,即时编译器可以让程序进行原本的虚调用,通过内联缓存进行调用,或者通过方法表进行动态绑定。

关于上述两点,自己只找到了一种替代方案来进行测试,主要就是禁止编译 Sub 类中的 apply 方法,这样就没法类型 Profile 就不包含 Sub 信息,同样因为是手动禁止编译,所以编译器也不会重新收集 Profile 信息。

还是基于上述测试代码,不过增加如下参数:

-XX:CompileCommand=exclude,com/msdn/java/javac/jit/Sub::apply -XX:+PrintCompilation
复制代码

编译结果输出如下:

同样我们利用 PrintInlining 参数输出内联信息。

扩展

因类加载导致去优化

上文演示 Add 和 Sub 同时初始化的情况,如果修改一下 Sub 初始化的位置,则会发生意想不到的情况。

public static void main(String[] args) throws InterruptedException {
  Add add = new Add();
  for (int i = 0; i < 20000; i++) {
    test(add);
  }
  Thread.sleep(2000); 
  System.out.println("Loading Sub");
  Sub sub = new Sub();
}
复制代码

输出编译结果发现:

Sub 类的加载时机是晚于 test 方法的调用,从编译结果可以看出,因类加载导致 test 方法的去优化。至于为何会发生去优化,原因如下:test 方法中对于 apply 方法调用已经被去虚化为对 Add.apply 方法的调用。如果在之后的运行过程中,Java 虚拟机又加载了 Sub 类,那么该假设失效,Java 虚拟机需要触发 test 方法编译结果的去优化。

一点疑惑

上文我们已经创建了 Add 和 Sub 类,那么我们再创建一个 Mul 类,同样继承 BinaryOp。

public class Mul extends BinaryOp {

  @Override
  public int apply(int a, int b) {
    return a * b;
  }
}

//测试代码
public static void main(String[] args) {
  Add add = new Add();
  Sub sub = new Sub();
  Mul mul = new Mul();
  for (int i = 0; i < 20000; i++) {
    test(add);
    test(sub);
    test(mul);
  }
}
复制代码

同样输出编译结果,如下图所示:

继续看内联信息

从结果来看,没有任何的内联信息,那么关于 apply 方法的调用要么通过内联缓存进行调用,或者通过方法表进行动态绑定。

后续又经测试,将循环次数增加至 400000 时,apply 方法又进行了内联,而且输出编译结果时,main 方法竟然也触发了即时编译,关于这其中的细节以及为何变成这样,个人暂时搞不懂。如果有大佬知晓这块知识,望不吝赐教。

总结

方法内联是指,在编译过程中,当遇到方法调用时,将目标方法的方法体纳入编译范围之中,并取代原方法调用的优化手段。

方法内联有许多规则。除了一些强制内联以及强制不内联的规则外,即时编译器会根据方法调用的层数、方法调用指令所在的程序路径的热度、目标方法的调用次数及大小来决定方法调用能否被内联。

完全去虚化通过类型继承关系分析,将虚方法调用转换为直接调用。它的关键在于证明虚方法调用的目标方法是唯一的。

条件去虚化通过向代码中增添类型比较,将虚方法调用转换为一个个的类型测试以及对应该类型的直接调用。它将借助 Java 虚拟机所收集的类型 Profile。

在学习过程中遇到了几点疑惑,一是 optimized virtual_call 和 inline (hot)之间的关系,两者是否存在关联,optimized virtual_call 的触发场景是什么?二是虚方法内联扩展的第二点,自己对写的测试用例带来的结果存在疑惑。

其他技能

JITWatch

JITWatch 是一个查看JIT行为的可视化工具。

下载

1、从 github 上 clone 项目,之后运行该项目。

// maven运行方法
mvn clean compile test exec:java
//gradle
gradlew clean build run
复制代码

个人测试过,发现运行后打开的 JITWatch 窗口显示乱码,就只好换个方式。(也许只是我个人遇到了这个问题,该种方式最简单,推荐尝试)

2、从pan.baidu.com/s/1i3HxFDF 下载,原作者放了一个 jitwatch.sh 在里面,下载下来之后改一下文件中的路径就可以直接运行了。

下载好的压缩包解压后得到如下内容:

修改 jitwatch.sh 文件:

JITWATCH_HOME="/Users/xxx/Downloads/JITWatch/jitwatch-master/lib";
JITWATCH_JAR="/Users/xxx/Downloads/JITWatch/jitwatch-1.0.0-SNAPSHOT.jar"
java -cp $JAVA_HOME/lib/tools.jar:$JAVA_HOME/jre/lib/jfxrt.jar:$JITWATCH_JAR:$JITWATCH_HOME/hamcrest-core-1.3.jar:$JITWATCH_HOME/logback-classic-1.1.2.jar:$JITWATCH_HOME/logback-core-1.1.2.jar:$JITWATCH_HOME/slf4j-api-1.7.7.jar org.adoptopenjdk.jitwatch.launch.LaunchUI
复制代码

然后打开命令行窗口,执行如下命令:

JITWatch % ./jitwatch.sh
复制代码

至于使用记录,可以参考如下几篇文章:

测试 初识jitwatch

java汇编指令查看工具jitwatch

JIT的Profile神器JITWatch

参考文献

极客时间 郑雨迪 《深入拆解Java虚拟机》 方法内联

基本功 | Java即时编译器原理解析及实践

《深入理解Java虚拟机第三版》

Supongo que te gusta

Origin juejin.im/post/7078671997143629832
Recomendado
Clasificación