🦁

Javaの強みを徹底解説②:JITコンパイラ

2024/10/23に公開

こんにちは NSS の江口と申します。前回の記事(Javaの強みを徹底解説①:Javaのいいところ) に引き続きJavaの話となります。
今回はJITコンパイラについて話していこうと思います。
なお、今回記載している内容は Java21 に準拠しております。

JITコンパイラとは

JIT とは Just In Time の略で言葉の通り 必要に応じたコンパイラ という意味になります。
Javaもいわゆるコンパイル言語なのですが、Write once, run anywhereを実現するために以下のようにコンパイルコードから動的にネイティブコードによる実行に変換しています。

JITコンパイラ(概要).png

コンパイルによりクラスファイルが生成されるわけですが、これが直接実行されるわけではなくJavaランタイムでは 中間コード という位置づけになり、実行時( Just In Time )に実行環境毎のネイティブコードに変換することによりプログラムが実行されています。
前回の記事(Javaの強みを徹底解説①:Javaのいいところ) で述べた通り、同じ動作をしたくても、実行環境ごとに必要な命令は異なるので、 JVM(Java Virtual Machine) がそれはそれは頑張ってくれるわけです。
そう考えていくと、プログラマとしてはコンパイル言語なんですが、見ようによってはインタプリタ言語とも言えるかもしれませんね。

最適化

前の段では実行時に中間コードをJVMが動的にコンパイルし、実行していると述べましたが、
毎回同じネイティブコードに変換されるというわけではありません。
JVMは動的にコンパイルを行いながら、より早いコードへの変換を試みていきます。

JITコンパイラ(最適化).png

最適化の一種として、インライン化がありますが、インライン化というのは、例えば以下のようにメソッド経由のアクセスを展開してしまうことです。

/* 元のコード */
for (int i = 0; i < 100; i++) {
    executeEachI(i);
}
private void executeEachI(int i) {
    for (int j = 0; j < 100; j++) {
        int answer = j * i;
    }
}

/* インライン化されたコード */
for (int i = 0; i < 100; i++) {
    for (int j = 0; j < 100; j++) {
        int answer = j * i;
    }
}

メソッド経由ですと、内部的には処理中にメソッドポインタへのアクセスが頻発することになるので、極力メモリアクセスの頻度を下げて高速化して行こうと狙いですね。
こういった涙ぐましい努力によりJavaの処理は高速化されていきます。

ネイティブキャッシュ

ここまではインライン化などの最適化を用いて、処理の高速化を図ってきたJavaですが、さらに高速化をしようと思うと

もう、いちいちネイティブコードに変換しなくていいのでは?

という所まで考えが至るかと思います。それを実現している機能がネイティブキャッシュです。

JITコンパイラ(ネイティブキャッシュ).png

最適化によりもう十分に高速化されたのだから、ネイティブコードをキャッシュしておきそれを使いまわそうということですね。
この機能により頻繁に実行されるコードはネイティブコードの生成が省略されるため、処理が高速化されます。
つまり動作しながら、自動でパフォーマンスがチューニングされていきます。これがJavaVMが世の中に支持される一つの要因だと思います。よく Java Hotspot VM と呼ばれますが、その所以ですね。

処理の高速化

JITコンパイラによる処理の高速化について述べてきましたが、高速化の仕組みをまとめると

  • まずは素直に中間コードをコンパイルし、実行する
  • 徐々に最適化を行っていき、高速化していく
  • もはやこれ以上の最適化は不要と判断し、ネイティブコードをキャッシュしてしまう

となります。この仕組みによりJavaの処理速度は 段階的 に高速化されていきます。
以下はある文字列を繰り返し大量に生成するプログラムです。

public class JITCompilerSample {

    // 定数
    public static final int LOOP_COUNT = 100_000;
    public static final int SPLIT_TERM = 500;

    public static void main(String[] args) {
        // ストップウォッチの開始
        long startNanosec = System.nanoTime();
        
        for (int i = 0; i < LOOP_COUNT; i++) {
            // 実行
            execute(i);
            
            // 一定の期間内のタイムを計測する
            if (i % SPLIT_TERM == 0) {
                long elapsedNano = System.nanoTime() - startNanosec;
                System.out.println(
                        String.format("%06d", i) + ":" + String.format("% 10d", elapsedNano / 1000) + " MicroSec");
                startNanosec = System.nanoTime();
            }
        }
    }

    /** 実行 */
    public static String execute(int i) {
        // とある文字が大量に続く文字列を作成する
        int ch = 'a' + (char)(i % 25);
        StringBuilder builder = new StringBuilder();
        for (int j = 0; j < 10000; j++) {
            builder.append((char)ch);
        }
        
        return builder.toString();
    }
}

これを実行した結果が以下となっており、徐々に高速化されていくのがわかるかと思います。

JITコンパイラ(処理の高速化).png

ここで注意していただきたい点としては、 JITコンパイラ 以外にも クラスロード にかかる時間と ガベージコレクション にかかる時間など性能に影響する要素はあるということです。

チューニングのためのオプション

JITコンパイルに関連するオプションについても共有したいと思います。

VM引数 説明 デフォルト値
-XX:CompileThreshold=n ネイティブコードへのコンパイルを行うメソッドが
実行されたしきい値
10,000
-XX:+CITime JITコンパイラに費やした時間を出力する 無効
-XX:+PrintCompilation メソッドがコンパイルされた情報を出力する 無効
-XX:+Inline インラインによる最適化を有効化する 有効
-XX:InlineSmallCode=n 最適化によりインライン化する際のコードのサイズ (環境依存)
-XX:InitialCodeCacheSize=n
-XX:ReservedCodeCacheSize=n
-XX:CodeCacheExpansionSize=n
それぞれコードキャッシュの初期サイズ、最大サイズ、拡張サイズ (環境依存)
-XX:+BackgroundCompilation コンパイルの非同期化 有効

この中で私がチューニングしようと思ったことがあるのは -XX:CompileThreshold で、
できるだけ早くネイティブコードに変換してほしいと思ったことが理由でした。
ただ、思ったほど性能向上はできなかったので、JVMの設計をされる方も色々なユースケースを想定された上で、デフォルト値を定めているのだと改めて痛感いたしました。

まとめ

今回JavaにおけるJITコンパイラについて記載してきました。
今後もJavaが発展していってくれることを願いたいと思います。
次回はガベージコレクションについて投稿したいと思います。

Discussion