JVM JITコンパイラの仕組み


JVMのJITコンパイラの仕組みを勉強したことをまとめていく。

環境

  • macOS Mojava 10.14.4
  • AdoptOpenJDK 1.8
    • 64bit
  • Scala 2.13.1
~/workspace/$ java -version
openjdk version "1.8.0_222"
OpenJDK Runtime Environment (AdoptOpenJDK)(build 1.8.0_222-b10)
OpenJDK 64-Bit Server VM (AdoptOpenJDK)(build 25.222-b10, mixed mode)

JITコンパイラとは

Just In Timeコンパイラのことで、JVM上に実装されているコンパイラである。
Just In Timeの通り、「必要な物を、必要な時に」コンパイルするコンパイラのこと。

JVM上での実行までの流れ

めっちゃ大まかに言うと、以下の流れ

ソースコード -> コンパイル -> 中間コード -> 実行(コンパイル)

出典

JVM言語は、コンパイルを走らせた際に(javaではjavac、scalaではsbt compileなど)、対象の全てのソースコードをコンパイルするわけではない。
まず、中間コードを作成する。(java bytecode)。この中間コードを生成するプロセスを挟むことで、JVM環境さえあれば、どのOSでも同じコードを実行できるようになった。(JVMは、それぞれのOSに適した物を入れる必要がある)

その後、作成した中間コードを一気にコンパイル(nativeコードに変換)はしない。
インタプリタでソースコードを実行の都度コンパイルしている。(上図で言うと Java interpreter)

これには、以下の2つの理由がある。

1. コンパイルしてもそのコードが一回しか利用されなかったらコンパイル時間が無駄になる

コンパイルには時間がかかるので、一回しか呼びだされないコードに関しては、インタプリタで実行する方が、実行までの合計時間が短くなる
一方、頻繁に呼び出されるコードに関しては、コンパイルされたコードの方が高速に実行できるので、コンパイルされるべきである。
この、インタプリタで実行するかコンパイルして実行するのかのJVM上での閾値の話は後述する。

2. コンパイルする際に利用可能な情報を集めることができる。

コンパイルする際に必要な情報を、インタプリタで実行する際に取得することができる。
取得した情報を利用して、コンパイルの際に様々な最適化を施すことができる。
この最適化によって、このコンパイルされたコード内でも、実行時間に差が出てくる。

例えば、equals()メソッドで考える

以下のようなコードがある。

test.scala
val b = obj1.equals(obj2)

インタプリタがequals()メソッドに到達した時点で、equals()メソッドが、obj1に定義されているメソッドなのか、はたまたStringオブジェクトのメソッドなのかを探索する必要が出てくる。インタプリタのみなら、毎回equals()メソッドに到達するたびに探索するという時間が無駄にかかってしまう。

もしインタプリタで、obj1がStringオブジェクトのメソッドだと判断すると、equals()メソッドをStringオブジェクトのメソッドとしてコンパイルする。コンパイルされ、さらにインタプリタの際に探索していた時間がいらなくなるので、高速なコードがなる。

このように、コードを実行して見ないと最適化できないので、JITコンパイラはコードをすぐにはコンパイルしない。

JITコンパイラの三種類のコンパイラ

JITコンパイラには、三種類のコンパイラが存在する。
java8からは、三番目の階層的コンパイラがデフォルトで設定されている。

クライアントコンパイラ(C1)

早期の段階でコンパイルする

サーバーコンパイラ(C2)

コードの振る舞いについて情報を集めてからコンパイルする。
上記のように、最適化されてコンパイルされるのでクライアントコンパイラよりも速度が出る。

階層的コンパイラ

クライアントコンパイラとサーバーコンパイラをまとめたもの。
初期の段階では、C1でコンパイルされ、最適化のための情報が集まってきたら(コードがホットになってきたら)、C2でコンパイルされる。

確認

java -versionで、設定されているコンパイラを確認できる
僕の場合は、
- JVM version8
- 64bit
- Serverコンパイラ(階層的コンパイラ)

~/workspace/$ java -version
openjdk version "1.8.0_222"
OpenJDK Runtime Environment (AdoptOpenJDK)(build 1.8.0_222-b10)
OpenJDK 64-Bit Server VM (AdoptOpenJDK)(build 25.222-b10, mixed mode)

それぞれのコンパイラは、作成するアプリケーションによって使い分けるべき。
例えば、GUIアプリケーションをJVM上で実行している場合、使えば使うほど処理速度が上がるよりも、初期アクセス時間が速い方がUX的には良いので、クライアントコンパイラを使うべき。

などなど、アプリケーション・実現したいことによってコンパイラを選択する。

コンパイルされる閾値

バイトコードは、まずインタプリタで実行されると上述した。
では、どのタイミングでインタプリタからJITコンパイラに切り替えられるのか?
2つの閾値が存在する

呼び出しカウンター(スタンダードコンパイル)

対象のメソッドが呼ばれた回数

この回数が閾値を越えると、対象のメソッドがコンパイルのためのキューに積まれて、コンパイルされる。

バッグエッジカウンター

メソッド内でループ内のコードから処理が戻ってくる回数

この回数が閾値を超えると、ループ自身がコンパイルの対象となりコンパイルされる。
その際にされるコンパイルのことを、OSRコンパイルという。
コンパイルが完了すると、スタック上でコンパイルされたコードと交換され、次の処理からコンパイルされたコードが実行される。

チューニング

クライアントコンパイラとサーバコンパイラでは、上記のカウンターの閾値が違う。
この閾値をきちんとチューニングしないといけない。

サーバコンパイラでのスタンダードコンパイルでの閾値を下げるとコンパイルされる際に必要な情報が少なくなるので最適化されにくく、低速なコンパイルコードになってしまう。

それでも、閾値を低くするメリットもある

1. ウォームアップの時間を少し短くできる

それはそうだ。

2. 高い閾値ではコンパイルされないコードもコンパイルされる

これに関して、コードを継続して実行していくといずれ、呼び出しカウンター・バッグエッジカウンターの閾値に達すると考えられる。しかし、時間とともにカウンターの値が減算される。

上記のように、チューニングをきちんとしないといけない。

実際にチューニングしてみる

以下のコードでJITコンパイラの挙動を観察する・チューニングしてみる

下のように、.jvmopts内に、jvmオプションを指定できる。

.jvmopts
-XX:+PrintCompilation
-XX:CompileThreshold=1
  • -XX:+PrintCompilation

下記のようにコンパイルログを吐き出してくれる

形式は

タイムスタンプ コンパイルID 属性 メソッド名 サイズ 非最適化


$ sbt run
41    1       3       java.lang.Object::<init> (1 bytes)
42    2       3       java.lang.String::hashCode (55 bytes)
44    3       3       java.lang.String::charAt (29 bytes)
45    4       3       java.lang.String::equals (81 bytes)
45    5     n 0       java.lang.System::arraycopy (native)   (static)
45    6       3       java.lang.Math::min (11 bytes)
45    7       3       java.lang.String::length (6 bytes)
52    8       1       java.lang.Object::<init> (1 bytes)
52    1       3       java.lang.Object::<init> (1 bytes)   made not entrant
53    9       3       java.util.jar.Attributes$Name::isValid (32 bytes)
53   10       3       java.util.jar.Attributes$Name::isAlpha (30 bytes)
・・・・
  • XX:CompileThreshold=1000

メソッド・ループが、何回実行されるとコンパイルされるのか指定できる。

以下のコードでやってみる

Test.scala
object Test extends App{
  def compileTest() = {
    for (i <- 0 to 1000) {
      sampleLoop(i)
    }
  }

  def sampleLoop(num: Int) = {
    println(s"loopppp${num}")
  }

  println(compileTest())
}
.jvmopts
-XX:+PrintCompilation
-XX:CompileThreshold=1

結果

-XX:CompileThreshold=1に設定したので、一回このコードを実行するだけでcompileTestメソッドはコンパイルされていることが確認できる。
また、sampleLoopメソッドもループなので、コンパイルされている。

9983 9336       3       Test$$$Lambda$3666/873055587::apply$mcVI$sp (5 bytes)
9983 9338       3       Test$::sampleLoop (1 bytes)
9983 9337       3       Test$::$anonfun$compileTest$1 (8 bytes)
9984 9334       4       java.lang.invoke.MethodType::makeImpl (66 bytes)
9986 9339   !   3       scala.Enumeration$Val::toString (55 bytes)
・・・

JVMが起動の9秒後にcompileTestメソッドがコンパイルされている。

例えば、以下の設定ではどうか?


object Test extends App{
  def compileTests() = {
    for (i <- 0 to 10) { // 10回のループに変更
      sampleLoop(i)
    }
  }

  def sampleLoop(num: Int) = {
    println(s"loopppp${num}")
  }

  println(compileTests())
}
.jvmopts
-XX:+PrintCompilation
-XX:CompileThreshold=100 # 100回に閾値を変更

-XX:CompileThreshold=100などと設定すると、一回上記のコードを実行するだけでは、compileTestメソッドはコンパイルされない。
また、sampleLoopメソッドも、100回も実行されないのでコンパイル対象外となっている。

まとめ

実際にJITコンパイルの処理を眺めてみると理解しやすい。

参考