ネイティブ実行可能ファイルのメモリー・フットプリントの最適化

適切なガベージ・コレクタを選択し、ガベージ・コレクション構成を調整すると、GC時間とメモリー・フットプリントを削減できます。ネイティブ・イメージを実行する場合、Javaヒープ設定はシステム構成およびGCに基づいて決定されます。デフォルト構成をオーバーライドして、関連するメトリックのユース・ケースをさらに改善できます。

このガイドでは、メモリー消費の分野におけるアプリケーションを最適化する方法、およびGC一時停止時間、メモリー・フットプリントおよびパフォーマンス間のトレードオフを行う方法を示します。

前提条件

Oracle GraalVM for JDK 23以降がインストールされていることを確認します。最も簡単に始めるには、SDKMAN!を使用します。その他のインストール・オプションについては、ダウンロード・セクションにアクセスしてください。

1. アプリケーションの準備

ログ分析など、大きな文字列が頻繁に連結、分割または操作される大量のテキスト処理を実行するJavaアプリケーションは、ガベージ・コレクタのストレス・テストに適したアプローチです。

使用するアプリケーションは大量の一時文字列を生成し、GCに負荷をかけます。

  1. 次のJavaコードをStringManipulation.javaという名前のファイルに保存します:
     import java.util.ArrayDeque;
    
     public class StringManipulation {
    
         public static void main(String[] args) {
             System.out.println("Starting string manipulation GC stress test...");
    
             // Parse arguments
             int iterations = 1000000;
             int numKeptAliveObjects = 100000;
             if (args.length > 0) {
                 iterations = Integer.parseInt(args[0]);
             }
             if (args.length > 1) {
                 numKeptAliveObjects = Integer.parseInt(args[1]);
             }
    
             ArrayDeque<String[]> aliveData = new ArrayDeque<String[]>(numKeptAliveObjects + 1);
             for (int i = 0; i < iterations; i++) {
                 // Simulate log entry generation and log entry splitting. The last n entries are kept in memory.
                 String base = "log-entry";
                 StringBuilder builder = new StringBuilder(base);
    
                 for (int j = 0; j < 100; j++) {
                     builder.append("-").append(System.nanoTime());
                 }
    
                 String logEntry = builder.toString();
                 String[] parts = logEntry.split("-");
    
                 aliveData.addLast(parts);
                 if (aliveData.size() > numKeptAliveObjects) {
                     aliveData.removeFirst();
                 }
    
                 // Periodically log progress
                 if (i % 100000 == 0) {
                     System.out.println("Processed " + i + " log entries");
                 }
             }
    
             System.out.println("String manipulation GC stress test completed: " + aliveData.hashCode());
         }
     }
    

    実行時に、コマンドラインで、このアプリケーションの実行時間(1番目の引数、反復回数)と、保持するメモリー量(2番目の引数)を指定します。

  2. アプリケーションをコンパイルしてHotSpotで実行し、結果の所要時間を測定します:
     javac StringManipulation.java
    
     /usr/bin/time java StringManipulation 500000 50000
    

    48GBのメモリー、8つのCPU、およびHotSpot上のデフォルトのG1 GCを持つマシンでは、結果は次のようになります。この結果には、ユーザーと経過時間、システムCPU使用率、およびこのリクエストの実行に必要な最大メモリー使用量が表示されます:

     Starting string manipulation GC stress test...
     Processed 0 log entries
     Processed 100000 log entries
     Processed 200000 log entries
     Processed 300000 log entries
     Processed 400000 log entries
     String manipulation GC stress test completed: 1791741888
     6.61user 0.57system 0:03.35elapsed 214%CPU (0avgtext+0avgdata 4046128maxresident)k
     0inputs+64outputs (8major+39776minor)pagefaults 0swaps
    

    結果には、ウォールクロック時間が3.35秒、合計CPU時間が6.61秒+0.57秒(実際のCPU使用率を示す)、最大メモリー使用量が3.85GB(常駐セット・サイズ、RSS)であることが示されています。

2. デフォルトGCを使用したネイティブ・イメージのビルド

ネイティブ・イメージでデフォルトのガベージ・コレクタ(シリアルGC)を使用して、このアプリケーションを事前にコンパイルします。シリアルGCは、低メモリー・フットプリントおよび小規模なJavaヒープ・サイズ用に最適化された非パラレルのストップ・アンド・コピーGCです。

  1. native-imageを使用してビルドします:
     native-image -o testgc-serial StringManipulation
    

    -oオプションは、生成される出力ファイルの名前を定義します。

    ビルド出力では、次のような初期化ステージのGC情報が出力されます:

     [1/8] Initializing...
     ...
     Garbage collector: Serial GC (max heap size: 80% of RAM)
     ...
    
  2. 同じ引数でネイティブ実行可能ファイルを実行し、結果の所要時間を測定します:
     /usr/bin/time ./testgc-serial 500000 50000
    

    リソースの使用状況が異なります:

     Starting string manipulation GC stress test...
     ...
     8.82user 1.24system 0:10.10elapsed 99%CPU (0avgtext+0avgdata 611272maxresident)k
     0inputs+0outputs (0major+854664minor)pagefaults 0swaps
    

    デフォルトのGCを使用する場合、このベンチマークでは、前述のHotSpot実行と比較して、経過時間が長くなりますが、最大常駐セット・サイズが小さくなります。

  3. 実行時に-XX:+PrintGCを渡してログを出力することで、このGCの詳細なインサイトを取得します:
     /usr/bin/time ./testgc-serial 500000 50000 -XX:+PrintGC
    

    シリアルGCでは一時停止時間が長くなることに注意してください。これは、レイテンシが重要なアプリケーションでは問題になる可能性があります。たとえば:

     [9.301s] GC(55) Pause Full GC (Collect on allocation) 400.19M->214.69M 318.384ms
    

    ここでは、GCはアプリケーションを318.384ミリ秒一時停止しました。

3.G1 GCを使用したネイティブ・イメージのビルド

次のステップでは、ガベージ・コレクタを変更します。ネイティブ・イメージでは、--gc=G1native-imageビルダーに渡すことで、G1ガベージ・コレクタがサポートされます。G1 GCは、世代別、増分、パラレル、ほぼ同時実行、ストップ・ザ・ワールドGCで、アプリケーションのレイテンシとスループットを向上させるために推奨されます。

G1 GCはOracle GraalVMで使用でき、Linuxでのみサポートされています。

アプリケーションのパフォーマンスを最適化するために、G1 GCをプロファイルに基づく最適化(PGO)と組み合せて使用することをお薦めします。ただし、このガイドでは、手順を簡潔にするためにPGOは適用されません。

  1. G1 GCを使用して2番目のネイティブ実行可能ファイルをビルドし、出力ファイルに別の名前を指定して、実行可能ファイルが相互に上書きしないようにします:
     native-image --gc=G1 -o testgc-g1 StringManipulation
    

    ビルド出力に異なるGC情報が出力されます:

     [1/8] Initializing...
     ...
     Garbage collector: G1 GC (max heap size: 25.0% of RAM)
    
  2. このネイティブ実行可能ファイルを同じ引数で実行し、-XX:+PrintGCも渡して一時停止時間に関する詳細なインサイトを取得し、結果を比較します:
     /usr/bin/time ./testgc-g1 500000 50000 -XX:+PrintGC
    
     ...
     Processed 300000 log entries
     [2.705s][info][gc] GC(16) Pause Young (Normal) (G1 Evacuation Pause) 2301M->1690M(4840M) 25.144ms
     Processed 400000 log entries
     [3.322s][info][gc] GC(17) Pause Young (Normal) (G1 Evacuation Pause) 2715M->1870M(4840M) 20.364ms
     String manipulation GC stress test completed: 305943342
     5.77user 0.47system 0:03.85elapsed 161%CPU (0avgtext+0avgdata 3707920maxresident)k
     0inputs+0outputs (0major+12980minor)pagefaults 0swaps
    

    G1 GCはシリアルGCよりも大幅に高速であるため、ウォールクロック時間が10.1秒から3.85秒に短縮されます。一時停止時間も大幅に改善されています。ただし、G1 GCのメモリー使用量はシリアルGCよりも多くなります。

    前述のHotSpot実行(G1 GCも使用)と比較すると、パフォーマンスは同程度ですが、メモリー使用量は少なくなっています(3.68GB対3.85GB)。これは、ネイティブ・イメージではHotSpotよりもオブジェクトがコンパクトであるためです。CPU時間の合計も短くなっています。

4. Epsilon GCを使用したネイティブ・イメージのビルド

ネイティブ・イメージでサポートされているガベージ・コレクタがもう1つあります。Epsilon GCです。Epsilon GCは、ガベージ・コレクションを行わないノーオペレーション・ガベージ・コレクタで、割り当てられたメモリーを解放しません。このGCの主なユース・ケースは、少量のメモリーのみを割り当てる、実行時間が非常に短いアプリケーションです。

Epsilon GCは、非常に特殊なケースでのみ使用してください。Epsilon GCをデフォルトのGC(シリアルGC)と比較して、Epsilon GCが実際にアプリケーションにメリットをもたらすかどうかを判断することをお薦めします。

  1. Epsilon GCを有効にするには、イメージ・ビルド時に--gc=epsilonを渡します:
     native-image --gc=epsilon -o testgc-epsilon StringManipulation
    

    ビルドの出力にEpsilon GCが使用されていることが報告されます:

     [1/8] Initializing...
     ...
     Garbage collector: Epsilon GC (max heap size: 80% of RAM)
    
  2. このネイティブ・イメージを実行しますが、反復回数を増やします:
     /usr/bin/time ./testgc-epsilon 3200000 50000
    
     Starting string manipulation GC stress test...
     ...
     Processed 3100000 log entries
     Exception in thread "main" 
     Exception: java.lang.OutOfMemoryError thrown from the UncaughtExceptionHandler in thread "main"
     PlatformThreads.ensureCurrentAssigned() failed during shutdown: java.lang.OutOfMemoryError: Could not allocate an aligned heap chunk because the heap address space is exhausted. Consider re-building the image with compressed references disabled ('-H:-UseCompressedReferences').
     Command exited with non-zero status 1
     21.07user 13.11system 0:34.25elapsed 99%CPU (0avgtext+0avgdata 33556824maxresident)k
     0inputs+0outputs (0major+8387698minor)pagefaults 0swaps
    

    OutOfMemoryError例外が発生するのは、Epsilon GCはガベージ・コレクションを実行せず、ある時点でヒープが一杯になるためです。このアプリケーションの実行時間を短縮する必要があります。

    より多くの作業(より多くの反復)が実行されたため、使用結果は、前述のステップの結果と比較できません。

5. 最大ヒープ・サイズを設定するネイティブ・イメージのビルド

デフォルトでは、ネイティブ・イメージは、シリアルまたはEpsilon GCを使用する場合は最大Javaヒープ・サイズを物理メモリーの80%、G1 GCを使用する場合は25%に設定します。たとえば、16GBのRAMを搭載したマシンでは、最大ヒープ・サイズはシリアルまたはEpsilon GCを使用する場合は12.8GBに設定されます。ただし、圧縮参照のサポートを有効にしてOracle GraalVMで実行する場合、最大Javaヒープは32GBを超えることはできません。この情報は、各ビルドの出力で確認できます。

デフォルトの動作をオーバーライドするには、最大ヒープ・サイズを明示的に設定できます。これを実行するには、次の2つの方法があります。

5.1.実行時の最大ヒープ・サイズの設定

1つ目の方法は推奨される方法で、デフォルトのヒープ設定を使用してネイティブ・イメージをビルドし、実行時に-Xmxを使用して最大ヒープ・サイズをバイト単位でオーバーライドします。シリアルGCおよびG1 GCの両方のネイティブ・イメージでこのオプションをテストします。

  1. シリアルGC:
     /usr/bin/time ./testgc-serial -Xmx512m 500000 50000
    
     Starting string manipulation GC stress test...
     ...
     9.53user 1.40system 0:10.99elapsed 99%CPU (0avgtext+0avgdata 590404maxresident)k
     0inputs+0outputs (0major+953535minor)pagefaults 0swaps
    
  2. G1 GC:
     /usr/bin/time ./testgc-g1 -Xmx512m 500000 50000
    
     Starting string manipulation GC stress test...
     ...
     14.99user 0.41system 0:05.13elapsed 300%CPU (0avgtext+0avgdata 554004maxresident)k
     0inputs+0outputs (0major+5622minor)pagefaults 0swaps
    

5.2.ビルド時の最大ヒープ・サイズの定義

2つ目の方法は、ネイティブ・イメージをビルドし、-R:MaxHeapSizeオプションを使用して最大ヒープ・サイズの新しいデフォルト値を設定することです。このデフォルトは、実行時に-X...または-XX:...オプションを渡して明示的にオーバーライドされないかぎり、実行時に使用されます。

  1. 新しいネイティブ実行可能ファイルを作成します:
     native-image --gc=G1 -R:MaxHeapSize=512m -o testgc-maxheapset-g1 StringManipulation
    

    更新されたGC情報に注目してください:

     [1/8] Initializing...
     ...
     Garbage collector: G1 GC (max heap size: 512.00MB)
    
  2. 同じ負荷で実行します:
     /usr/bin/time ./testgc-maxheapset-g1 500000 50000
    

    このテスト・マシンでは、結果はステップ5.1の前の数値と一致するはずです:

     Starting string manipulation GC stress test...
     ...
     14.87user 0.44system 0:05.33elapsed 287%CPU (0avgtext+0avgdata 552292maxresident)k
     0inputs+0outputs (0major+5694minor)pagefaults 0swaps
    

-Xmx以外にも、エキスパートがパフォーマンス・チューニングに使用できるGC固有のオプションが多数あります。たとえば、-XX:MaxGCPauseMillisはターゲットの最大一時停止時間を設定します。パフォーマンス・チューニング・オプションの完全なリストは、リファレンス・ドキュメントを参照してください。

サマリー

適切なガベージ・コレクタを選択し、適切なガベージ・コレクション構成を構成すると、GCの一時停止が大幅に削減され、アプリケーション全体の応答性が向上します。メモリー使用量の予測可能性が向上することで、様々なワークロード下でネイティブ・アプリケーションをより効率的に実行できるようになります。このガイドでは、アプリケーション目標(低レイテンシ、最小メモリー・オーバーヘッド、または最適なパフォーマンス)に応じて最適なGC戦略を選択するためのインサイトを提供します。