JVMシリーズ:ジャストインタイムコンパイラに関する最適化対策

この記事は、「JVMシリーズの詳細な調査」の17番目の記事です。

Java仮想マシンでのジャストインタイムコンパイルに移りましょう。

プロファイリング

階層化されたコンパイルに関する相互作用図は前回の記事で紹介されましたが、ここで再度投稿します。

写真で説明されている4つのコンパイル方法が詳細に紹介されており、ここでは紹介を繰り返しません。その中で、次の内容について説明します。レイヤードコンパイルのレイヤー0、レイヤー2、レイヤー3がプロファイリングされ、コレクションは、プログラムの実行ステータスを反映できます。データ。その中で最も基本的なのは、第2層で実行されるプロファイリングであり、メソッド呼び出しの数とループバックの実行時間をカウントするだけで済みます。統計の合計がしきい値を超えると、リアルタイムのコンパイルが行われます。トリガーされます。

レイヤー0とレイヤー3はレイヤー2よりも複雑であり、ジャンプ数や非ジャンプ数など、ブランチジャンプバイトコードのブランチプロファイル(ブランチプロファイル)など、レイヤー4C2コンパイル用のデータを収集する必要があります。 、非プライベートインスタンスメソッド呼び出し命令、キャストチェックキャスト命令、タイプテストinstanceof命令、および参照型配列ストレージaastore命令タイププロファイル(レシーバータイププロファイル)。上記のデータは、ブランチプロファイルとタイププロファイルの2つのカテゴリに分類されます。

写真のコンパイル方法によると、階層型コンパイルでは、どのような状況であっても、ブランチプロファイルとタイププロファイルの収集が高い確率で実行されます。

ブランチプロファイルとタイププロファイルのコレクションは、アプリケーションにかなりのパフォーマンスオーバーヘッドを課します。統計によると、3層C1コードのパフォーマンスが2層C1コードのパフォーマンスよりも30%低いのは、まさにこの余分なプロファイリングのためです。

では、莫大な費用をかけて収集されたこれらのプロファイルの役割は何でしょうか。

答えは、C2は収集されたデータに基づいて推測を行うことができ、次の実行も収集されたプロファイルに従うと想定して、より積極的な最適化を行うことができるということです。

ブランチプロファイルベースの最適化

たとえば、次のコードには2つの条件付き判断が含まれています。最初の条件付きテストは、入力されたブール値をテストします。

//-XX:+PrintCompilation
public class BranchProfile {

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

  public static int foo(boolean flag) {
    if (flag) {
      return 1;
    } else {
      return 2;
    }
  }
}
复制代码

输出结果为:

    .......
    204   27       3       java.lang.String::isEmpty (14 bytes)
    204   25       3       java.lang.StringBuilder::append (8 bytes)
    204   28       3       com.msdn.java.javac.jit.BranchProfile::foo (8 bytes)
    204   29       4       com.msdn.java.javac.jit.BranchProfile::foo (8 bytes)
    205   28       3       com.msdn.java.javac.jit.BranchProfile::foo (8 bytes)   made not entrant
    205    2       4       java.lang.Math::min (11 bytes)
复制代码

看一下编译结果,可以看到 foo 先是走 3层 C1编译,后续又进行 4层 C2编译,这符合通用编译途径。made not entrant 则表示后续不再进入 3层 C1编译的 foo 方法。

对应的 foo 方法的字节码文件如下:

 public static int foo(boolean);
    descriptor: (Z)I
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=1, locals=1, args_size=1
         0: iload_0
         1: ifeq          6
         4: iconst_1
         5: ireturn
         6: iconst_2
         7: ireturn
复制代码

从解释执行的角度来看,foo 方法的执行过程如下:

假设应用程序调用 foo 方法时,所传入的 flag 值皆为 true。那么,偏移量为 1 的条件跳转指令所对应的分支 profile 中,跳转的次数都为 0。那么就将 false 的流程分支剪掉,

foo 方法就可以简化为如下代码:

  public static int foo(boolean flag) {
     return 1;
  }
复制代码

综上所述,根据条件跳转指令的分支 profile,即时编译器可以将从未执行过的分支剪掉,以避免编译这些很有可能不会用到的代码,从而节省编译时间以及部署代码所要消耗的内存空间。此外,“剪枝”将精简程序的数据流,从而触发更多的优化。

当然,在实际应用中,分支 profile 出现仅跳转或者仅不跳转的情况并不多见,不然也不会有分支判断。

总结一下,关于即时编译器对分支 profile 的优化,除了剪枝优化,还会根据分支 profile,计算每一条程序执行路径的概率,以便某些编译器优化优先处理概率较高的路径。

基于类型 profile 的优化

举个例子,下面这段代码中包含对象类型判断。下面这段代码将测试所传入的对象是否为 Exception 的实例,如果是,则返回它的系统哈希值;如果不是,则返回它的哈希值。

public static int hash(Object o) {
  if (o instanceof Exception) {
    return System.identityHashCode(o);
  } else {
    return o.hashCode();
  }
}
//字节码文件
  public static int hash(java.lang.Object);
    descriptor: (Ljava/lang/Object;)I
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: instanceof    #5                  // class java/lang/Exception
         4: ifeq          12
         7: aload_0
         8: invokestatic  #6                  // Method java/lang/System.identityHashCode:(Ljava/lang/Object;)I
        11: ireturn
        12: aload_0
        13: invokevirtual #7                  // Method java/lang/Object.hashCode:()I
        16: ireturn
复制代码

假设应用程序调用该方法时,所传入的 Object 皆为 Integer 实例。那么,偏移量为 1 的 instanceof 指令的类型 profile 仅包含 Integer,偏移量为 4 的分支跳转语句的分支 profile 中不跳转的次数为 0,偏移量为 13 的方法调用指令的类型 profile 仅包含 Integer。

从解释执行的角度来看,hash 方法的执行过程如下:

相较于分支 profile 测试代码中的判断条件,o instanceof Exception 明显更复杂一些,尤其是 instanceof 指令,关于该指令的学习可以参考本文,更加深入的内容可以参考 R大的回答。这里我们引入一下总结好的内容:

如果Sobjectref所引用的对象的类,而T是已解析类,数组或接口的类型,则instanceof确定是否 objectrefT的一个实例。S s = new A(); s instanceof T

  • 如果S是一个普通的(非数组)类,则:
    • 如果T是一个类类型,那么S必须是T的同一个类,或者S必须是T的子类;
    • 如果T是接口类型,那么S必须实现接口T。
  • 如果S是接口类型,则:
    • 如果T是类类型,那么T必须是Object。
    • 如果T是接口类型,那么T一定是与S相同的接口或S的超接口。
  • 如果S是表示数组类型SC的类[],即类型SC的组件数组,则:
    • 如果T是类类型,那么T必须是Object。
    • 如果T是一种接口类型,那么T必须是数组实现的接口之一(JLS§4.10.3)。
    • 如果T是一个类型为TC的数组[],即一个类型为TC的组件数组,那么下列其中一个必须为真:
      • TC和SC是相同的原始类型。
      • TC和SC是引用类型,类型SC可以通过这些运行时规则转换为TC。

这里举几个例子,以下子类型关系都成立(“<:”符号表示左边是右边的子类型,“=>”符号表示“推导出”):

  1. String[][][] <: String[][][] (数组子类型关系的自反性)
  2. String <: CharSequence => String[] <: CharSequence[] (数组的协变)
  3. String[][][] <: Object (所有数组类型是Object的子类型)
  4. int[] <: Serializable (原始类型数组实现java.io.Serializable接口)
  5. Object[] <: Serializable (引用类型数组实现java.io.Serializable接口)
  6. int[][][] <: Serializable[][] <: Serializable[] <: Serializable (上面几个例子的延伸⋯开始好玩了吧?)
  7. int[][][] <: Object[][] <: Object[] <: Object

关于 instanceof 判断为 true 的描述,可以结合官网好好研究一下,除此之外,如果目标类型 T 被 final 修饰,那么 JVM 只需要判断 S 是否为 final 类型即可。

在我们的例子中,假设所输入的 Object 对象仍为 Integer 实例,那么判断条件为 false,最后执行 o.hashCode()。我们查看 Integer.hashCode() 方法:

public final class Integer ... {
    ...
    @Override
    public int hashCode() {
        return Integer.hashCode(value);
    }

    public static int hashCode(int value) {
        return value;
    }
    ...
}
复制代码

上述代码进行方法内联优化,关于方法内联后续会介绍,这里我们只需要了解有这个优化即可。

  //-XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining
  public static void main(String[] args) {
    for (int i = 0; i < 20000; i++) {
      hash(new Integer(1));
    }
  }
复制代码

查看输出结果如下:

最终关于 hash 方法的执行过程简化如下:

当然上述代码也涉及到了分支 profile 的优化,不管怎样,两者的优化都是基于假设的。对于分支 profile,即时编译器假设的是仅执行某一分支;对于类型 profile,即时编译器假设的是对象的动态类型仅为类型 profile 中的那几个。

但是,如果假设错了呢,程序又该如何处理?

去优化

Java 虚拟机给出的解决方案便是去优化,即从执行即时编译生成的机器码切换回解释执行,并重新收集相关的 profile。

在前文讲 Codecache 如何回收时提及到了去优化,当发生去优化时,会将之前出现过的编译标记为 “made not entrant"(使用-XX:+PrintCompilation)。它表示该方法不能再被进入。具体去优化的过程比较复杂,暂无能力讲解清楚,只能通过一个案例来侧面验证一下自己的所得。

//-XX:+PrintCompilation
public static void main(String[] args) throws InterruptedException {
  for (int i = 0; i < 40000; i++) {
    foo(true);
  }
}

public static int foo(boolean flag) {
  if (flag) {
    return 1;
  } else {
    return 2;
  }
}
复制代码

截取部分输出结果如下:

    184   28       3       com.msdn.java.javac.jit.BranchProfile::foo (8 bytes)
    185   29       4       com.msdn.java.javac.jit.BranchProfile::foo (8 bytes)
    185   30       3       java.util.Arrays::copyOfRange (63 bytes)
    185   28       3       com.msdn.java.javac.jit.BranchProfile::foo (8 bytes)   made not entrant
    185   31       3       java.lang.System::getSecurityManager (4 bytes)
复制代码

接着对上述代码进行修改:

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

输出结果为:

    179   27       3       com.msdn.java.javac.jit.BranchProfile::foo (8 bytes)
    180   28       4       com.msdn.java.javac.jit.BranchProfile::foo (8 bytes)
    180   29       4       java.lang.String::hashCode (55 bytes)
    180   27       3       com.msdn.java.javac.jit.BranchProfile::foo (8 bytes)   made not entrant
    180   30       3       java.util.Arrays::copyOfRange (63 bytes)
    180   28       4       com.msdn.java.javac.jit.BranchProfile::foo (8 bytes)   made not entrant
复制代码

根据上述三种代码的输出结果,结合 Codecache 的清除策略,可以得到如下结论:

1、根据多次测试结果得到的日志可知,foo 方法首先触发了3层的C1即时编译,然后触发了4层的C2的即时编译,最后 3层的 C1编译被标记为 made not entrant,即 foo 方法发生了去优化。

这里为什么会发生去优化呢?made not entrant 也就是不会再被进入,我们都知道编译途径先经过 C1编译最后到 C2编译,代码达到一定热度后再次执行会直接调用 C2编译的机器码,当程序结束后,因为 C1编译的结果热度不够,所以被标识为 made not entrant,后续会释放在 Codecache 中占用的内存。

2、当分支 profile 优化失败后(foo(false)),因为无法复用 C2编译的机器码,所以只能解释执行,最后 4层的 C2编译也被标记为 made not entrant,即 foo 方法又发生了去优化。

这里又是什么情况呢?因为 C2 编译的激进优化失败,那么就没必要保留这部分机器码,所以被标记为 made not entrant。

上述两点总结为本人拙见,如无错误,请多多指出。

扩展

ClassViewer

ClassViewer 是一个轻量级的 Java 类文件查看器。

官方文档地址:ClassViewer ,国内Github 地址:Glavo/ClassViewer

比如下面这段代码:

  public static void main(String[] args) {
    for (int i = 0; i < 40000; i++) {
      if (i % 2 == 0) {
        foo(true);
      }else{
        foo(false);
      }
    }
  }

//部分字节码文件
         0: iconst_0
         1: istore_1
         2: iload_1
         3: sipush        20000
         6: if_icmpge     20
         9: iconst_1
        10: invokestatic  #2                  // Method foo:(Z)I
        13: pop
        14: iinc          1, 1
        17: goto          2
        20: return
复制代码

启动过程:下载 jar 包,在命令行窗口执行如下命令:

java -jar ClassViewer-3.9.jar 
复制代码

我们通过 ClassViewer 来查看字节码文件,如下图所示:

点击 methods 下的 main 方法,查看详细指令,点击偏移量为 17 的指令:goto 2,右侧会显示十六进制内容:

其中 A7 对应 goto,FF F1 表示相对偏移量,FF F1 这里要解读为大端的带符号16位数,值为-15。这条 goto 指令从偏移量 17 的位置开始,而它的跳转目标就是17 + (-15) = 2。

HSDIS生成反汇编代码

下载配置参考本文

验证 hsdis 是否工作的命令:

java -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -version
复制代码

具体使用,比如有这样一段代码:

public class CompilationTest2 {

  public static int getHashCode(Object input) {
    if (input instanceof Exception) {
      return System.identityHashCode(input);
    } else {
      return input.hashCode();
    }
  }

  public static void main(String[] args) throws InterruptedException {
    for (int i = 0; i < 500000; i++) {
      getHashCode(i);
    }
    Thread.sleep(2000);
  }
}
复制代码

我们想看 getHashCode 的机器码,则可以执行如下命令:

javac CompilationTest2.java
java -XX:CompileCommand='print,CompilationTest2.getHashCode' CompilationTest2
复制代码

不过暂时看不懂输出的机器码。

如果查看整个文件的机器码,则可以这样:

java -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly CompilationTest2
复制代码

参考文献

jvm中的GOTO指令,后面跟的跳转位置是什么?

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

おすすめ

転載: juejin.im/post/7078305396431159310
おすすめ