JavaのGarbage collector:G1、ZGCおよび選択方法

1. 概要

Garbage collector(GC)はJavaアプリケーションが一時停止する時間とメモリ整理に消費するCPUリソースを決定します。選択を誤ると、コードに変更がなくても、latencyが劇的に増加する可能性があります。

この記事では、Java 21でG1とZGCがどのように動作するか、有効化とGCログの読み方、コードとテストでGCの実行状況を確認する方法を説明します。最後に、実際のアプリケーションに適切なGCを選択するための基準を示します。

2. Java 21で利用可能なGC

HotSpot JDK 21には、以下の主なGCが用意されています。

GC Flag 目的
Serial -XX:+UseSerialGC 小さいヒープ、1 CPU、フットプリント削減
Parallel -XX:+UseParallelGC 最大スループット、長いpauseを許容
G1 -XX:+UseG1GC スループットとpauseのバランス
ZGC -XX:+UseZGC 極めて短いpause、大規模ヒープ
Shenandoah -XX:+UseShenandoahGC 短いpause(特定のビルドのみ)

フラグを指定しない場合、JVMは自動選択します。「server-class」マシン(少なくとも2 CPUおよび約1792 MB RAM)では、JVMはG1を使用します。それ以外の場合は、JVMはSerialを使用します。

注意: コンテナ内では、JVMはcgroupのCPUとメモリの制限を読み取ります。1 CPUに制限されたPodは、JVMによってserver-classでないと見なされ、Serial GCが暗黙に実行されます。これは、Kubernetes上のサービスで異常に長いpauseが発生する一般的な原因です。

3. G1はどのように動作するか

G1(Garbage-First)はヒープを複数の等しいregionに分割します。通常は1 MBから32 MBです。各regionは異なる役割を担当します。Eden、Survivor、Old、またはHumongous(大きなオブジェクト用)です。

基本的なサイクルは以下で構成されます。

  • Young GC: すべてのアプリケーションスレッドを停止(stop-the-world)し、生き残ったオブジェクトをEdenからSurvivorまたはOldにコピーします。
  • Concurrent marking: Oldが一定の閾値に達すると、G1はアプリケーションと並行してアクティブなオブジェクトをマークします。
  • Mixed GC: youngと最もゴミが多いoldのregionを整理します。これが「Garbage-First」の意味です。

G1の利点は、pause目標を設定できることです。

java -XX:+UseG1GC -XX:MaxGCPauseMillis=100 -Xms2g -Xmx2g -jar order-service.jar

MaxGCPauseMillis=100はG1に各pauseを100 ms以下に保つよう指示します(デフォルトは200 ms)。G1はこの目標を達成するためにyoung generationのサイズを調整します。これはソフト目標であり、保証ではありません。G1のpauseはコピーする必要がある生き残ったオブジェクトの数に応じて増加します。

4. ZGCはどのように動作するか

ZGCはほぼすべての重い処理(marking、relocation、参照の更新)をアプリケーションと並行して実行します。colored pointersとload barrierを使用します。コードが参照を読み込むたびに、barrierはオブジェクトが移動されているかを確認し、必要に応じて参照を修正します。

結果として、pauseは通常1 ms未満であり、ヒープサイズに応じて増加しません。2 GBヒープでも2 TBヒープでも、pauseはほぼ同等です。

4.1. Java 21のGenerational ZGC

Java 21ではGenerational ZGC(JEP 439)が導入されました。このバージョンはyoungとoldを分離し、短期間のオブジェクトをより効率的に整理します。Java 21では、追加のフラグを有効化する必要があります。

java -XX:+UseZGC -XX:+ZGenerational -Xmx4g -jar order-service.jar

-XX:+ZGenerationalがない場合、Java 21は古いZGC(non-generational)を実行します。JDK 23からは、generationalがデフォルトになります。JDK 24からは、non-generationalモードは削除されました。したがって、Java 21でZGCを使用する場合は、すぐにgenerationalを有効化すべきです。

4.2. その代償

短いpauseには代償があります。

  • Load barrierは参照読み込みのたびに処理を遅くするため、スループットは通常G1より数パーセント低くなります。
  • ZGCはheadroomが必要です。つまり、ヒープに利用可能な空き容量が必要です。GCが並行実行中もアプリケーションは割り当てを続けます。GCが完了する前にヒープが満杯になると、スレッドがブロックされます(「Allocation Stall」)。その場合、latencyは劇的に増加し、元の目標に反します。
  • GCスレッドが並行実行されるため、アプリケーションとCPUを争います。

5. 小さなプログラムでのテスト

以下のプログラムは、多くの短期間のOrderを作成し、一部をキャッシュのように保持します。

import java.util.ArrayList;
import java.util.List;
import java.util.UUID;

public class OrderLoadSimulator {

    record Order(String id, String customer, byte[] payload) {}

    public static void main(String[] args) {
        List<Order> cache = new ArrayList<>();
        long start = System.currentTimeMillis();

        for (int i = 0; i < 5_000_000; i++) {
            Order order = new Order(UUID.randomUUID().toString(), "customer-" + (i % 1000), new byte[512]);
            if (i % 100 == 0) {
                cache.add(order);           // 1%を保持 -> old generationへ
            }
            if (cache.size() > 20_000) {
                cache.subList(0, 10_000).clear(); // 一部を解放、oldにゴミを作る
            }
        }

        System.out.printf("Done in %d ms, cache=%d%n",
                System.currentTimeMillis() - start, cache.size());
    }
}

各反復は512バイトのペイロードを持つOrderを作成します。ほとんどのオブジェクトはすぐに死亡し、「ほとんどのオブジェクトは若いうちに死ぬ」という仮説と一致します。約1%はキャッシュ内に十分に長く保持され、old generationに昇格します。subList(...).clear()コマンドは時々それらを解放します。

G1で実行し、unified logging(-Xlog)でGCログを有効化します。

java -XX:+UseG1GC -Xmx512m -Xlog:gc OrderLoadSimulator.java

出力は次のような形式になります。

[0.008s][info][gc] Using G1
[0.312s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 25M->3M(512M) 2.814ms
[0.401s][info][gc] GC(1) Pause Young (Normal) (G1 Evacuation Pause) 28M->4M(512M) 1.957ms
...
Done in 3120 ms, cache=15000

各Pause Young行は、GC前後のヒープ(25M->3M)、コミットされたヒープの合計(512M)、および一時停止時間(2.814ms)を示しています。

次にGenerational ZGCで実行します。

java -XX:+UseZGC -XX:+ZGenerational -Xmx512m -Xlog:gc OrderLoadSimulator.java
[0.010s][info][gc] Using The Z Garbage Collector
[0.420s][info][gc] GC(0) Minor Collection (Allocation Rate) 60M(12%)->14M(3%) 0.021s
[0.610s][info][gc] GC(1) Minor Collection (Allocation Rate) 72M(14%)->16M(3%) 0.018s
...
Done in 3350 ms, cache=15000

各ZGC行の末尾の時間(0.021s)は、GC全体のサイクル時間で、ほとんどはアプリケーションと並行実行されます。これはアプリケーションが一時停止された時間ではありません。実際のpauseを見るには、-Xlog:gc,gc+phases=debugを使用してPause Mark Start、Pause Mark End、Pause Relocate Start行を探します。これらのpauseは通常わずか数十マイクロ秒です。

具体的な数値はあなたのマシンで異なります。一般的な傾向は、ZGCが合計時間ではわずかに遅くなりますが、長いpauseがないことです。

6. コードでGCの実行を確認する

本番環境では、設定を信じるのではなく、実際に実行されているGCを確認すべきです。GarbageCollectorMXBeanがそれを教えてくれます。

import java.lang.management.GarbageCollectorMXBean;
import java.lang.management.ManagementFactory;
import java.util.List;

public class GcInspector {

    public static List<String> activeCollectors() {
        return ManagementFactory.getGarbageCollectorMXBeans().stream()
                .map(GarbageCollectorMXBean::getName)
                .toList();
    }

    public static void main(String[] args) {
        for (GarbageCollectorMXBean gc : ManagementFactory.getGarbageCollectorMXBeans()) {
            System.out.printf("%s: count=%d, time=%d ms%n",
                    gc.getName(), gc.getCollectionCount(), gc.getCollectionTime());
        }
    }
}

各GCは、特徴的な名前を持つ1つ以上のMXBeanを登録します。例えば、G1の場合。

G1 Young Generation: count=0, time=0 ms
G1 Concurrent GC: count=0, time=0 ms
G1 Old Generation: count=0, time=0 ms

Generational ZGCの場合、名前はZGC Minor Cycles、ZGC Minor Pauses、ZGC Major CyclesおよびZGC Major Pausesになります。

6.1. GC構成を確認するテストを記述する

テストでGC構成をロックできます。誰かが誤ってJVMオプションを変更しないようにするためです。まず、SurefireがZGCでテストを実行するように構成します。

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-surefire-plugin</artifactId>
    <version>3.2.5</version>
    <configuration>
        <argLine>-XX:+UseZGC -XX:+ZGenerational</argLine>
    </configuration>
</plugin>

その後、JUnit 5とAssertJを組み合わせたテストを記述します。

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

class GcInspectorTest {

    @Test
    void whenRunningWithZgcFlag_thenZgcCollectorsAreActive() {
        var collectors = GcInspector.activeCollectors();

        assertThat(collectors)
                .isNotEmpty()
                .allMatch(name -> name.startsWith("ZGC"));
    }

    @Test
    void whenGenerationalZgc_thenMinorCyclesBeanExists() {
        assertThat(GcInspector.activeCollectors())
                .contains("ZGC Minor Cycles");
    }
}

最初のテストはすべてのcollectorがZGCに属することを確認します。2番目のテストはgenerationalモードが有効になっていることを保証します。誰かが-XX:+ZGenerationalを削除した場合、bean名はZGC Cyclesに変わり、テストが失敗します。

注意: このテストはテストプロセスのJVMオプションに依存します。IDEがSurefireからargLineを読み込まない場合、IDEで直接テストを実行すると失敗します。

7. Spring Bootアプリケーション向けのGC構成

Spring Bootは自動的にGCを選択しません。デプロイ方法に応じてJVMにフラグを渡します。Maven pluginでローカルに実行する場合。

<plugin>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-maven-plugin</artifactId>
    <configuration>
        <jvmArguments>-XX:+UseZGC -XX:+ZGenerational -Xmx2g</jvmArguments>
    </configuration>
</plugin>

コンテナでは、環境変数JAVA_TOOL_OPTIONSを使用するのが最もシンプルです。JVMは起動時にこの変数を自動的に読み込みます。

docker run -e JAVA_TOOL_OPTIONS="-XX:+UseG1GC -XX:MaxRAMPercentage=75 -Xlog:gc*:stdout:time" \
  --cpus=2 --memory=2g payment-service:1.0

MaxRAMPercentage=75は、ヒープの最大値をコンテナメモリの75%に設定します。残りはmetaspace、スレッドスタック、directバッファ用です。セクション2で述べた理由から、JVMに自動選択させるのではなく、常に明示的に-XX:+UseG1GCまたは-XX:+UseZGCを指定してください。

Spring Boot ActuatorとMicrometerがある場合、jvm.gc.pauseメトリクスはGCタイプ別のpause時間分布を示します。GC変更の前後でこのメトリクスのp99とmaxを監視してください。

8. GCの選択方法

アプリケーション要件から始めます。「最新の」GCから始めるのではなく。

G1を選択する場合:

  • 変更する具体的な理由がない場合。G1は、ほとんどのREST APIとサービスに合理的なデフォルト選択肢です。
  • ヒープが数百MBから数十GB。
  • 数十から200 msのpauseが許容できる場合。
  • CPUが厳しく制限されていて、良好なスループットが必要な場合。

ZGC(generational)を選択する場合:

  • p99またはp999 latencyが非常に厳しい要件がある場合。例えば、トレーディング、ゲームサーバー、SLAが50 ms未満のAPI。
  • 大規模なヒープ(数十GB以上)。G1のpauseが生き残ったオブジェクトが多いために長くなり始める場合。
  • headroom用の余分なCPUとメモリがある場合。

Parallelを選択する場合: バッチジョブ、ETL、オフラインデータ処理を実行する場合。合計時間がpauseより重要な場合。

Serialを選択する場合: 小さなCLI、ヒープが約100 MB以下でCPUが1つのserverless関数を実行する場合。

注意: 小さいコンテナ(1 CPU、ヒープ512 MB)ではZGCを使用しないでください。GCスレッドはアプリケーションとCPUを争い、ヒープの少ないheadroomはAllocation Stallを引き起こしやすくなります。結果はG1より悪い可能性があります。

チューニング時の一般的な間違い。

  • -XX:NewRatioや-XX:SurvivorRatioなどの古いフラグをG1にコピーしています。これらのフラグは、MaxGCPauseMillisを達成するための自動調整メカニズムを妨げます。-Xmxとpause目標で始めるべきです。
  • G1のMaxGCPauseMillisを低すぎる値(例:10 ms)に設定します。G1はyoung generationを縮小し、GCが頻繁に実行され、スループットが大きく低下します。そのレベルのpauseが必要な場合は、ZGCに切り替えてください。
  • メモリリークを修正するためにGCを変更します。参照によって保持されているオブジェクトを解放できるGCはありません。heap dumpを分析してください。
  • 数秒のベンチマークで評価します。本番と同様の負荷で少なくとも数十分測定し、p99 latency、CPU、memoryを比較してください。

9. 結論

G1はバランスの取れたデフォルト選択肢で、ほとんどのアプリケーションに適しています。特にJava 21のgenerational版ZGCは、非常に大きなヒープでも1 ms未満のpauseを提供します。代わりに、追加のCPUが必要であり、より多くのheadroomが必要です。

常にGCを明示的に宣言し、-Xlog:gcを有効化し、GarbageCollectorMXBeanまたはテストでGCが実行されていることを確認してください。p99 latency数値がpauseが実際に問題であることを示している場合にのみ、GCを変更してください。

javajvmgarbage-collectorg1zgc