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を変更してください。