JVM(HotspotVM)がなぜ速いかとその検証

7 min read読了の目安(約6400字

皆さんこんにちは、Java書いてますか?
静的型付け言語大好きマンの私はお母さんのようなJavaが大好きです。
Javaは今でも根強い人気があり30億のデバイス上で動作しています。そしてそれを実現しているのがJRE(Java Runtime Environment)に含まれるJVM(Java Virtual Machine)というJavaの実行環境です。

JVMはJavaバイトコードならどのOS上でも同じように動作するという特徴を持ちます。
今となっては「どのOS上でも同じように動作する」というのは大きなメリットではないかもしれません。しかし、Javaが出た当時はコンパイル型言語としてこのメリットは大変価値のあるものだったと思います。(JDK1.4の時代とか幼稚園生でしたので憶測)

JVMとして使えるソフトウェアで現在主流なのは
・HotspotVM
・GraalVM
です。

最近登場したGraalVMは非常人気ですが、今でも現役のHotspotVMはC言語ほどではないにせよ非常にスループットが高いです。そんなHotspotVMにちょっと詳しくなった気持ちになれるような記事を書きました。

JVMで命令が実行されるまで

そもそもJVMで命令が実行されるまでの処理をおさらいしておきましょう。
Javaに少しでも触れたことがある方はご存知かと思いますが、一応説明します。
ざっと図にしてみました。

画像

(javacコマンドから生えている矢印の先は..javaファイルではなくclassファイルでした)

まず、プログラムのエントリポイントであるmainメソッドを持つJavaソースコードをjavacコマンドでコンパイルします。(図中1

javac ./App.java

すると下記のような内容の.classという拡張子のJavaバイトコードが生成されます。

Compiled from "App.java"
public class App {
 public App();
   Code:
      0: aload_0
      1: invokespecial #1                  // Method java/lang/Object."<init>":()V
      4: return

 public static void main(java.lang.String[]) throws java.lang.Exception;
   Code:
      0: getstatic     #7                  // Field java/lang/System.out:Ljava/io/PrintStream;
      3: ldc           #13                 // String Hello, World!
      5: invokevirtual #15                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      8: return
}

それをjavaコマンドに渡してやるとエントリポイントであるmainメソッドを実行してくれます。(図中2

java -cp ".classファイルの生成先ディレクトリまでのパス" App

JVMはコンパイルして生成されたJavaバイトコードをインタプリタと2つJIT(Just In Time)コンパイラを用いて実行します。

なぜHotspotVMが優れた性能を発揮するのか

HotspotVMの非常に高いスループットを支えているのが2つのJITコンパイラです。これらはそれぞれ別の特徴を持っており、Client(C1), Server(C2)と呼ばれています。

画像

インタプリタのみで命令を実行すると、AOT(Ahead of Time)コンパイラ並みのスループットは出ません。そこでHotspotVMは頻繁に実行される命令を、別スレッド上のJITコンパイラで段階的に機械語にコンパイルします。そうすることで次回以降の呼び出しではリアルタイムで機械語に変換するオーバーヘッドが発生しなくなり、スループットの向上に貢献します。

段階的にコンパイルするのは、頻繁に実行されない命令を高い最適化度合いでコンパイルしてもコンパイルにかけた時間分のメリットを得られないからです。

画像

それぞれのJITコンパイラの特性を検証する

さて、理屈は分かりましたが、実際どの程度JITコンパイルがスループットの向上に貢献しているのか気になります。なのでO(2^n)の愚直にフィボナッチ数列を算出するアルゴリズムを用いて
・JITコンパイルなし
・serverコンパイルのみ
・インタプリタ+serverコンパイル
・clientコンパイルのみ
・インタプリタ+clientコンパイラ+serverコンパイル
における35番目のフィボナッチ数列を求めるのにかかった実行時間を図ってみようと思います。
実行に使うコードはこちら

public class App {

   private static long fib(long n) {
       if(n < 2) return n;
       return fib(n -2) + fib(n - 1);
   }
   public static void main(String ...args) {
       var num = Integer.parseInt(args[0]);
       System.out.printf("%d番目のフィボナッチ数は%d\n", num, fib(num));
   }
}

JITコンパイルなし

JITコンパイルなしでJavaのソースを実行するためには-Xintオプションをjavaコマンドに渡してあげればOKです。

$ time java -Xint App 35
35番目のフィボナッチ数は9227465
java -Xint App 35  1.57s user 0.01s system 99% cpu 1.580 total

1.58秒とまずまずです。ただこれじゃあ速いなんて言えませんね。

serverコンパイルのみ

インタプリタを全く使わない場合は-Xcompオプションをjavaコマンドに渡してあげればOKです。

今回使っているオプションの説明
・-Xcomp JITコンパイルを強制
・-XX:+CITime JITコンパイルにかかった時間の表示
・-XX:-TieredCompilation 階層型コンパイルの無効化(C2のみ使用)
・-Xbatch フォアグラウンドでのJITコンパイル実行

$ time java -Xcomp -XX:+CITime -XX:-TieredCompilation -Xbatch App 35
~~~
 C2 {speed: 55244 bytes/s; standard:  6.001 s, 331530 bytes, 2576 methods; osr:  0.000 s, 0 bytes, 0 methods; nmethods_size: 2651640 bytes; nmethods_code_size: 1587872 bytes}
   C2 Compile Time:        7.772 s
~~~
java -Xcomp -XX:+CITime -XX:-TieredCompilation -Xbatch App 35  8.31s user 0.09s system 101% cpu 8.317 total</code>

コードの実行自体は0.538秒で終わっていますが、コンパイル自体に7.7秒強もかかっています。おそらく実行頻度の低いコードをコンパイルするのに加え、インタプリタがJavaバイトコードのプロファイリングを行っていないため時間がかかっているのだと思います。(参考

インタプリタ+serverコンパイル

先程の推測が正しければ、インタプリタによる実行を挟めば高速に動作するはずです。一体どうなるでしょう。

$ time java -XX:+CITime -XX:-TieredCompilation App 35
~~~
  C2 {speed: 53598 bytes/s; standard:  0.004 s, 240 bytes, 6 methods; osr:  0.000 s, 0 bytes, 0 methods; nmethods_size: 2056 bytes; nmethods_code_size: 1088 bytes}
   C2 Compile Time:        0.007 s
~~~
java -XX:+CITime -XX:-TieredCompilation App 35  0.13s user 0.01s system 102% cpu 0.136 total

JITコンパイルをバックグラウンドで実行している分を考慮してもかなーーーーーーり速くなりました。すごい!!

clientコンパイルのみ

さて機械語の最適化度合いがserverコンパイラより低いclientコンパイラは、どの程度のパフォーマンスを出してくれるのでしょうか。
x64マシンでserverコンパイラを使用せずにclientコンパイラのみを使用するには--XX:TieredStopAtLevel=に1~3を指定すればOKです。このオプションを含めこちらの記事に、JITコンパイラ周りのJVMオプションがまとまっています。(執筆者に感謝ァ!)今回はプロファイリング無しで実行したいので1を指定します。

$ time java -Xcomp -XX:+CITime -XX:TieredStopAtLevel=1 -Xbatch App 35
~~~
  C1 {speed: 699211 bytes/s; standard:  0.413 s, 288649 bytes, 1784 methods; osr:  0.000 s, 0 bytes, 0 methods; nmethods_size: 3781560 bytes; nmethods_code_size: 2309792 bytes}
   C1 Compile Time:        0.409 s
~~~
java -Xcomp -XX:+CITime -XX:TieredStopAtLevel=1 -Xbatch App 35  0.69s user 0.10s system 106% cpu 0.743 total

コンパイルに0.409秒、実行に0.338秒という結果になりました。

インタプリタ+clientコンパイラ+serverコンパイル

JITコンパイラの動作に関連するオプションを何も指定しない場合このパターンになります。はたして階層化コンパイルの効果は出るのでしょうか。

$ time java -XX:+CITime App 35
~~~
  C1 {speed: 368115 bytes/s; standard:  0.019 s, 7128 bytes, 124 methods; osr:  0.000 s, 0 bytes, 0 methods; nmethods_size: 151344 bytes; nmethods_code_size: 100288 bytes}
   C1 Compile Time:        0.019 s
~~~
  C2 {speed: 46336 bytes/s; standard:  0.007 s, 332 bytes, 8 methods; osr:  0.000 s, 0 bytes, 0 methods; nmethods_size: 2864 bytes; nmethods_code_size: 1632 bytes}
   C2 Compile Time:        0.010 s
~~~
java -XX:+CITime App 35  0.15s user 0.01s system 123% cpu 0.134 total

実行時間は0.134秒と「インタプリタ+serverコンパイル」のときと大差が有りません。なぜでしょう。

おそらくJITコンパイルが別スレッドで行われており、そもそもC2コンパイルまで実行されるメソッドが少なすぎることが影響していそうです。
階層化コンパイルは長時間起動する(同じメソッドを実行する回数が絶対的多いケース)デーモン系アプリを実行すると、「インタプリタ+serverコンパイル」のときと結果は違ってくると思います。

まとめ

・JVMの一種であるhotspotVMは階層型コンパイルを導入している
・階層型コンパイルはインタプリタのみやJITのみの実行と比べてスループットが高い
・hotspotVMすごい

hotspotVMの上級テクニックとしてC2コンパイラを実行しないで、C1コンパイラのみを用いることでアプリケーションの起動速度を上げることもできるらしいです。(参考

最近virtual youtuberの誕生日限定グッズを1万円で買いました。ASMRボイスドラマが付いてきたので、今は聞かずに精神が病んだときに聞こうと思います。