ZGC(Z Garbage Collector)は、OpenJDKのHotSpot VMに入っている低遅延型のガベージコレクタです。オブジェクトの回収と移動をアプリケーションのスレッドと並行して進め、アプリケーションを止める時間をヒープの大きさに比例させない設計になっています。JDK 15で製品版になり、JDK 21で世代別ZGCが追加され、JDK 24以降は世代別の1方式だけになりました。
この記事では、ZGCの仕組み、JDKバージョンごとの違い、-XX:+UseZGC による有効化とオプション、G1GC・Shenandoahとの違いを整理します。あわせて、JDK 25 LTSと2026年9月15日公開のJDK 27で、同じ負荷をG1・ZGCに流した停止時間の実測値、Allocation Stallを意図的に起こしたときのログ、JFRとラージページで実際に起きた制約を載せています。JavaのGC全般の仕組みはJavaのガベージコレクション(GC)とは|仕組み・種類・ログ確認とチューニング手順で扱っています。
まとめ:ZGCの要点
- ZGCは、マーク・再配置(リロケーション)といった重い処理をアプリケーションと並行して行い、停止をごく短いポーズに限る低遅延GCです。
- 有効化は
-XX:+UseZGCだけです。JDK 23以降は世代別ZGCが既定で、JDK 26以降は-XX:+ZGenerationalを付けるとJVMが起動しません(JDK 27でも同じ)。 - 2026年10月23日の実測では、ZGCのセーフポイント停止の中央値はJDK 25で0.20ms、JDK 27で0.18msでした。同じ負荷のG1は45〜47msで、JDK 27のG1は20秒間にFull GCが5〜6回出ました。
- ZGCは圧縮オブジェクト参照(Compressed Oops)を使えず、参照は8バイトのままです。JDK 27で既定になったCompact Object HeadersはZGCでも効き、2つのintを持つrecordは1個24バイトから16バイトに縮みました。
- 回収が割り当てに追いつかないとAllocation Stallでスレッドが待たされます。ZGCを使う環境では、JFRのリーク調査用イベント(jdk.OldObjectSample)が記録されない点にも注意が要ります。
- 応答時間の外れ値が問題になるサービスではZGC、スループット優先のバッチや小さなヒープではG1かParallel GCが向きます。
ZGC(Z Garbage Collector)の定義と設計目標
ZGCは、スケーラブルな低遅延GCとしてOpenJDKで開発されたガベージコレクタです。OracleのHotSpot GCチューニング・ガイド(JDK 27)は、ZGCの最大停止時間は1ミリ秒未満で、その代わりにスループットを一部犠牲にすること、停止時間は使用中のヒープサイズに依存しないことを説明しています。同ガイドが適切に機能するとしているヒープは数百MBから16TBまでです(8MBの小さなヒープへの対応はJDK 14で追加)。
ここで押さえるべきなのは、停止時間がヒープサイズや生存オブジェクトの量に比例しない点です。G1GCやParallel GCは、生きているオブジェクトを別の領域へコピーする処理(退避)をアプリケーションを止めて行います。ただし、G1は停止時間の目標に応じて回収対象を調整するため、ヒープ容量だけで停止時間は決まりません。停止中に処理する生存オブジェクトの量などにも左右されます。ZGCは退避そのものを並行処理に回すので、数十GB〜TB級のヒープでも停止時間の水準が変わりません。
JDKバージョン別のZGCの変遷
ZGCは版ごとに使い方が変わっています。主な節目は次のとおりです。
| JDK | JEP・変更 | ZGCの変化 |
|---|---|---|
| 11 | JEP 333 | 実験的機能として追加(Linux x64のみ) |
| 13 | JEP 351 | 未使用メモリをOSへ返却(実験的) |
| 14 | JEP 364・365 | macOS・Windowsに対応(実験的) |
| 15 | JEP 377 | 製品版に昇格 |
| 16 | JEP 376 | スレッドスタックの処理を並行化 |
| 21 | JEP 439 | 世代別ZGCを追加(要 -XX:+ZGenerational) |
| 23 | JEP 474 | 世代別ZGCが既定のモードに |
| 24 | JEP 490 | 非世代モードを削除 |
| 25 | リリースノート | マルチマップの最後の使用を除去 |
| 26 | JDK-8369983 | ZGenerationalフラグ自体を削除 |
| 27 | JDK-8360700 | Compact Object Headersが既定(ZGCでも有効) |
JDK 11のZGCは、-XX:+UnlockExperimentalVMOptions と -XX:+UseZGC を併せて指定しないと起動できませんでした。Java 17 LTSのZGCは製品版ですが非世代モードのみです。Java 21 LTSと非LTSのJava 22では、-XX:+UseZGC -XX:+ZGenerational と2つ指定して初めて世代別ZGCになり、指定しないと従来の非世代モードで動きます。Java 25 LTSとJava 27は -XX:+UseZGC だけで世代別ZGCです。Java 21から25へ上げるときに変わる点はJava 21と25の違い|正式化した新機能・サポート期限・移行で止まる箇所を実機で比較にまとめています。
既定のGCはどの版でもZGCではありません。JDK 9以降のサーバー環境の既定はG1で、JDK 27ではCPUやメモリが小さい環境でもG1が選ばれるようになりました(Java 27とは|非LTSの半年運用とG1・オブジェクトヘッダ既定化の影響)。ZGCは明示的に指定したときだけ使われます。
ZGCの仕組み:世代ごとの短い停止と並行処理
Major Collectionで止まるPauseフェーズと所要時間
ZGCの1サイクルは、マーク(生存オブジェクトの特定)、再配置対象の選定、再配置(生存オブジェクトのコピーと空いた領域の回収)で構成されます。このうちアプリケーションを止めるのは、各段階の開始・終了を同期させる短いポーズだけです。JDK 25で -Xlog:gc* を付けて取ったログから、若い世代と古い世代の両方を回収するMajor Collection 1回分の主な行を抜き出すと次のようになります(Y: が若い世代、O: が古い世代)。
[2.146s][info][gc ] GC(3) Major Collection (Allocation Rate)
[2.146s][info][gc,phases ] GC(3) Y: Pause Mark Start (Major) 0.034ms
[2.204s][info][gc,phases ] GC(3) Y: Concurrent Mark 57.451ms
[2.204s][info][gc,phases ] GC(3) Y: Pause Mark End 0.020ms
[2.206s][info][gc,phases ] GC(3) Y: Pause Relocate Start 0.010ms
[2.400s][info][gc,phases ] GC(3) Y: Concurrent Relocate 193.311ms
[2.403s][info][gc,phases ] GC(3) O: Concurrent Mark 2.244ms
[2.403s][info][gc,phases ] GC(3) O: Pause Mark End 0.042ms
[2.406s][info][gc,phases ] GC(3) O: Pause Relocate Start 0.043ms
[2.472s][info][gc,phases ] GC(3) O: Concurrent Relocate 65.109ms
[2.473s][info][gc ] GC(3) Major Collection (Allocation Rate) 1424M(70%)->1406M(69%) 0.327s
「Pause」で始まる5行はいずれも0.05ms未満で、57msのマークや193msの再配置は「Concurrent」、つまりアプリケーションが動いたまま進んでいます。サイクル全体には0.327秒かかっていますが、Pause行の合計は0.149msです。ただしこれはGCの作業時間で、全スレッドが停止位置(セーフポイント)にたどり着くまでの待ちは含みません。実際の停止時間は後述の実測で -Xlog:safepoint から測っています。
カラードポインタとロードバリアによる並行再配置
アプリケーションが動いている最中にオブジェクトを移動できるのは、カラードポインタとロードバリアの組み合わせによるものです。ZGCはオブジェクト参照(ポインタ)の一部のビットに、そのポインタがマーク済みか・再配置済みかといった状態を持たせます。アプリケーションがヒープから参照を読み込むたびにJITコンパイラが埋め込んだ短いチェック(ロードバリア)が走り、ポインタの状態が古ければ、移動先のアドレスへ付け替えてから処理を続けます。
この方式の代償として、ZGCは32ビットに圧縮したオブジェクト参照(Compressed Oops)を使えません。JDK 25とJDK 27で -XX:+UseZGC -XX:+UseCompressedOops と明示しても、-XX:+PrintFlagsFinal の値は UseCompressedOops = false のままで、警告も出ずに無効化されました。G1で32GB未満のヒープを使うアプリケーションは参照が4バイトで済みますが、ZGCに切り替えると8バイトになり、参照の多いデータ構造ほどメモリ使用量が増えます。
JDK 27のCompact Object HeadersとZGCのメモリ使用量
JDK 27では、オブジェクトヘッダーを縮めるCompact Object Headersが既定で有効になりました(OracleのJDK 27リリースノート、JDK-8360700)。参照の圧縮と違い、こちらはZGCでも効きます。record Point(int x, int y) を100万個作って jcmd <pid> GC.class_histogram で測った結果は次のとおりです。
| JDK・GC | Point 1個 | Object[]の合計 |
|---|---|---|
| JDK 25・ZGC | 24バイト | 約8.1MB |
| JDK 25・G1 | 24バイト | 約4.1MB |
| JDK 27・ZGC | 16バイト | 約8.2MB |
| JDK 27・G1 | 16バイト | 約4.1MB |
「Object[]の合計」は、100万要素の配列1本にJVM内部の配列(JDK 25で計633個、JDK 27で計1,081個)を足したクラス別の合計です。小さなオブジェクトはJDK 27でZGCでもG1でも3分の2になりましたが、参照を並べた配列はZGCだとG1の約2倍のままです。JDK 27へ上げてヒープ使用量が減ったように見えても、それはヘッダーの分で、ZGCとG1の参照サイズの差は残ります。-XX:+PrintFlagsFinal では、JDK 27はZGCでも UseCompactObjectHeaders = true {default}、JDK 25は false でした。
世代別ZGC(Generational ZGC)の回収対象・バリア・メモリ表示の変更
世代別ZGCは、ヒープを若い世代と古い世代に分け、すぐ不要になる短命オブジェクトが多い若い世代を頻繁に回収する方式です。JEP 439は、非世代ZGCに比べてAllocation Stallのリスクを下げ、必要なヒープメモリとGCのCPU負荷を減らすことを目標に挙げています。
実装面では、非世代ZGCが使っていたマルチマッピング(同じ物理メモリを3つの仮想アドレス範囲に割り当てる手法)をやめ、ロードバリアとストアバリアに明示的なコードを入れる方式に変わりました。JEP 439によると、マルチマッピングでは ps などのツールが報告するヒープ使用量が実際のおよそ3倍に見えていました。JDK 25のリリースノートでは、ZGCに最後まで残っていたマルチマップの使用もなくなり、JavaヒープのRSSが過大に報告されなくなったとされています。ログの見た目も変わり、上のように Y:(若い世代)と O:(古い世代)の行が分かれ、サイクルは「Minor Collection」「Major Collection」と表示されます。
ZGCの有効化手順と主要オプション
-XX:+UseZGCの指定と有効になったかの確認
ZGCを使うには、起動オプションに -XX:+UseZGC を付けます。ヒープの上限 -Xmx も明示しておくのが基本です。
java -XX:+UseZGC -Xmx4g '-Xlog:gc*:file=gc.log' -jar app.jar
-Xlog:gc* の * は、macOS標準のzshではファイル名の展開対象になり、no matches found でJVMが起動する前に失敗します。引数全体を引用符で囲んでください。起動スクリプトに -XX:+UseG1GC が残ったまま -XX:+UseZGC を足すと、JDK 25では Multiple garbage collectors selected でJVMが起動しません。GCの指定は1つにします。
有効になったかは、GCログの1行目か、実行時のフラグ値で確認できます。JDK 25での出力は次のとおりです。
$ java -XX:+UseZGC -Xlog:gc -version
[0.015s][info][gc] Using The Z Garbage Collector
$ java -XX:+UseZGC -XX:+PrintFlagsFinal -version | grep -E " (UseZGC|UseCompressedOops) "
bool UseCompressedOops = false {product lp64_product} {ergonomic}
bool UseZGC = true {product} {command line}
コンテナや起動スクリプト経由で動いていて起動オプションが見えない場合は、稼働中のプロセスに対して jcmd <pid> GC.heap_info を実行します。ZGCなら先頭行が ZHeap used 694M, capacity 1532M, max capacity 2048M のように「ZHeap」で始まります。
ヒープサイズとSoftMaxHeapSize・ConcGCThreadsの調整
ZGCのチューニングで最初に効くのはヒープサイズです。ZGCはアプリケーションが割り当てを続けている最中に回収を進めるので、回収が終わるまでの間に新しく割り当てられる分の空きが要ります。GCチューニング・ガイドも、ヒープの最大サイズを最も重要なチューニングオプションとしています。JDK 25・26・27で既定値を確認した主なオプションは次のとおりです(16スレッド・16GBのMacで実行。3つの版で同じ値でした)。
| オプション | 既定値 | 役割 |
|---|---|---|
| -Xmx | 物理メモリの25% | ヒープの上限 |
| -XX:SoftMaxHeapSize | -Xmx指定時は同値・未指定時は上限の90% | 普段の目標サイズ |
| -XX:ConcGCThreads | 4 | 並行処理のGCスレッド数 |
| -XX:+ZUncommit | true | 使わない領域をOSに返す |
| -XX:ZUncommitDelay | 300(秒) | 返却までの猶予 |
| -XX:+AlwaysPreTouch | false | 初期ヒープに事前アクセス |
| -XX:+UseLargePages | false | ラージページを使う |
SoftMaxHeapSize の既定値は、-Xmx を指定しないと上限と同じになりません。16GBのMacで何も指定せずに起動すると、上限 MaxHeapSize が4,294,967,296バイト(4GB)、SoftMaxHeapSize はその90%の3,865,470,566バイトでした。-Xmx2g を付けると両方とも2GBになります。GCチューニング・ガイドの説明では、ZGCはこの値を超えないように回収を進め、アプリケーションを止めないためにどうしても必要なときだけ -Xmx まで使います。
SoftMaxHeapSize は実行中に変更できる(manageable)フラグで、jcmd <pid> VM.set_flag SoftMaxHeapSize 1073741824 を実行すると値が {management} に変わりました。ConcGCThreads は同じ方法では変更できず、only 'writeable' flags can be set で拒否されたので、起動時に指定します。なお -Xms と -Xmx を同じ値にすると、OSへのメモリ返却(アンコミット)は暗黙に無効になります。GCチューニング・ガイドは、遅延を最も重視する構成として、この2つを同値にしたうえで -XX:+AlwaysPreTouch で起動時に初期ヒープのページへ事前にアクセスしておく方法を示しています。ヒープの構造とサイズ設計の考え方はJavaヒープとは?既定サイズ・-Xmx設定と「Java heap space」エラーの対処をJDK 25で実測で詳しく扱っています。
Linuxでラージページ(Huge Pages)を使う設定
GCチューニング・ガイドのZGCの章(JDK 27)は、ラージページを使うとスループット・遅延・起動時間のいずれも改善し、設定にroot権限が要ること以外に実質的な欠点はないとしています。Linux x86のラージページは2MBで、16GBのヒープなら8,192ページが必要です。同ガイドの例では、ヒープ以外のJVMの領域の分も含めて9,216ページ(18GB)を要求し、nr_hugepages を読み直して9,216になったことを確認してから、-XX:+UseLargePages を付けて起動します。空きが足りないと要求どおりに確保されないことがあるためです。
$ echo 9216 > /sys/kernel/mm/hugepages/hugepages-2048kB/nr_hugepages
$ cat /sys/kernel/mm/hugepages/hugepages-2048kB/nr_hugepages
9216
$ java -XX:+UseZGC -Xms16G -Xmx16G -XX:+UseLargePages MyApplication
透過的ヒュージページ(THP)を使う場合は、ZGCだけ設定が1つ多く要ります。ZGCはヒープを共有メモリ(shmem)に置くため、/sys/kernel/mm/transparent_hugepage/shmem_enabled でもTHPを許可する必要があります。カーネルの既定は never で、Oracleの設定例では advise を指定します。今回使ったcolimaの仮想マシン(Ubuntu系・カーネル6.8.0)で確認した設定値は、enabled が madvise、shmem_enabled が never でした。この状態で、Linux版のEclipse Temurin 25.0.4.1に同じ -XX:+UseTransparentHugePages を付けて -Xlog:gc+init を見た結果が次です。
$ java -XX:+UseZGC -Xmx1g -XX:+UseTransparentHugePages -Xlog:gc+init -version
[warning][pagesize] Shared memory transparent huge pages are not enabled in the OS. Set /sys/kernel/mm/transparent_hugepage/shmem_enabled to 'advise' to enable them.
[info ][gc,init ] Large Page Support: Disabled (OS enforced)
[info ][gc,init ] Heap Backing File: /memfd:java_heap
$ java -XX:+UseG1GC -Xmx1g -XX:+UseTransparentHugePages -Xlog:gc+init -version
[info][gc,init] Large Page Support: Enabled (Transparent)
同じオプションでも、G1ではTHPが有効、ZGCでは無効になりました。GCを比較するベンチマークをLinuxで取るときは、この差が結果に混ざります。同ガイドは、遅延に敏感なアプリケーションではTHPが突発的な遅延を起こしやすいため通常は勧めないとも書いています。低遅延が目的なら、先に明示的なラージページを検討します。
-XX:+ZGenerationalを残したままJDKを上げたときの挙動
Java 21で世代別ZGCを使うために付けた -XX:+ZGenerational は、JDKを上げたときに扱いが変わります。JDK 25とJDK 27で同じオプションを付けて起動した結果は次のとおりです(JDK 26も27と同じく起動失敗)。
$ java -XX:+UseZGC -XX:+ZGenerational -version # JDK 25.0.4.1
OpenJDK 64-Bit Server VM warning: Ignoring option ZGenerational; support was removed in 24.0
openjdk version "25.0.4.1" 2026-08-18 LTS
$ java -XX:+UseZGC -XX:+ZGenerational -version # JDK 27
Unrecognized VM option 'ZGenerational'
Error: Could not create the Java Virtual Machine.
JDK 24と25では警告を出して無視するだけですが、JDK 26以降は起動に失敗します。Java 21からJava 25 LTSへ上げる時点で、Dockerfile・systemdのユニット・JAVA_TOOL_OPTIONS に残った ZGenerational を削除しておくのが安全です。警告だけで動いてしまうため、25の段階では見落とされやすい箇所です。
ZGCとG1GC・Shenandoah GC・Parallel GCの違い
G1GCとの違いと選び分け
G1GCは既定のGCで、停止時間の目標(-XX:MaxGCPauseMillis、既定200ms)を守るように回収量を調整しながら、スループットとのバランスを取ります。ZGCとの違いは、生存オブジェクトの退避を止めて行うか、並行で行うかにあります。
| 項目 | G1GC | ZGC |
|---|---|---|
| 既定GCか | 既定 | 要 -XX:+UseZGC |
| 停止の目標 | 200ms(調整可) | 1ms未満 |
| 退避(コピー) | 停止して実行 | 並行で実行 |
| Compressed Oops | 使える | 使えない |
| THP(既定のshmem設定) | 使える | 使えない |
| 回収が追いつかないとき | Full GC(全停止) | Allocation Stall |
応答時間のp99やp99.9を守る必要があるAPIサーバーや、数十GB以上のヒープを持つキャッシュ・検索エンジンのようなサービスはZGCが向きます。
Shenandoah GCとの違い
ShenandoahもZGCと同じく、退避を並行で行う低遅延GCです。Red Hatが開発し、JDK 15で製品版になりました(JEP 379)。ZGCがカラードポインタを使うのに対し、Shenandoahはオブジェクト参照を圧縮したまま並行退避できる点が異なります。JDK 25で -Xmx2g を指定して比べると、Shenandoahは UseCompressedOops = true、ZGCは false でした。世代別モードはJDK 24で実験的に入り(JEP 404)、JDK 25で製品機能になりました(JEP 521)。ただし既定には入っておらず、JDK 27でも既定のモードは非世代の ShenandoahGCMode = satb です。世代別を既定にするJEP 535は、2026年10月23日時点の状態がTargeted・対象リリースがJDK 28です。
選ぶうえで大きいのは配布元の違いです。OpenJDKのShenandoah Wikiのベンダー一覧では、OracleはOracle JDKにもOracleが配布するOpenJDKビルドにもShenandoahを収録していません。Oracleのビルドで使える並行退避型の低遅延GCはZGCだけです。Eclipse TemurinなどOpenJDKビルドでは両方使えます(Temurin 25では -XX:+UseShenandoahGC で起動できることを確認)。配布元ごとの違いはOpenJDKは商用利用できる?無料の範囲とOracle JDKとの違い・ライセンス・サポート期限【2026年最新】にまとめています。
JDK 25・27での実測:同じ負荷をG1・ZGC・Parallel GCに流した停止時間
停止時間の違いを具体的に見るため、同じプログラムをGCごとに2回ずつ動かしました。約800MiBのデータ(64KiBの配列)を保持したまま、1〜9KiBの配列の割り当てと、保持している配列のランダムな入れ替えを20秒間繰り返す負荷です。ヒープは -Xmx2g -Xms2g で固定し、-Xlog:safepoint でセーフポイントへの到達待ち・停止中の処理・停止解除を含む時間を記録しました。以下の表では、この処理全体の時間を「停止時間」と表記しています。到達待ちの間は実行を続けているスレッドもあるため、全スレッドが停止していた時間や個々のリクエストの遅延とは一致しません。停止の統計は、初期データの生成を含むログ全体(約21秒)の Total 値から集計しています。
環境はmacOS(Intel Core i9-9880H、8コア16スレッド、メモリ16GB)、計測日は2026年10月23日です。JDK 25はEclipse Temurin 25.0.4.1、JDK 27はAzul Zulu 27(build 27+35)を使いました。TemurinはJDK 27からmacOSのx64版を配布していないためです。Parallel GCはJDK 27だけで計測しています。
| GC・JDK | 停止回数 | 停止の中央値 | 最大停止 | 停止の合計 | Full GC |
|---|---|---|---|---|---|
| G1・25 | 261・265 | 45.4・45.2ms | 280.7・295.2ms | 11.4・11.7秒 | 1・2回 |
| G1・27 | 251・257 | 47.1・45.9ms | 290.9・293.3ms | 12.5・12.2秒 | 6・5回 |
| ZGC・25 | 446・447 | 0.20・0.20ms | 67.2・44.4ms | 0.49・0.55秒 | なし |
| ZGC・27 | 485・526 | 0.18・0.18ms | 37.9・29.6ms | 0.48・0.34秒 | なし |
| Parallel・27 | 90・93 | 102.3・101.4ms | 594.5・402.9ms | 12.9・12.8秒 | 21・22回 |
ZGCは停止の回数こそ最多ですが、中央値は0.2ms以下で、ログ全体のセーフポイント処理時間(Total)の合計は0.3〜0.6秒でした。G1とParallel GCのTotalの合計は11〜13秒です。いずれも到達待ちを含むため、アプリケーション全体が実行できなかった時間を直接表す値ではありません。ヒープ上限に対する保持データ量が約39%のこの条件では、G1は全停止のFull GC(ログ上は Pause Full (G1 Compaction Pause)、1回243〜283ms)をJDK 27で5〜6回、JDK 25で1〜2回起こしました。2回ずつの計測なので、回数の差を版の違いと断定はできません。ZGCはどちらの版でもFull GCに当たる停止がありませんでした。
ZGCの最大停止は、JDK 27の2回とJDK 25の2回目では、内訳のほぼすべてが「Reaching safepoint」、つまり全スレッドが停止位置にたどり着くまでの待ちで、GCの作業そのもの(At safepoint)は0.04〜0.07msでした。JDK 25の1回目の67.2msだけは、起動0.58秒後、初期データを作っている最中の最初のMajor Collectionの開始(ZMarkStartYoungAndOld)で、作業そのものに67.0msかかっていました。1msを超えた停止は各回49〜56回です。ノートPC上の計測で他のプロセスの影響も受けるため、1ミリ秒未満は「大半の停止がそうなる」水準と読むのが妥当です。
計測に使ったプログラムは次のとおりです。javac Alloc.java でコンパイルし、java -XX:+UseZGC -Xmx2g -Xms2g -Xlog:safepoint:file=z_sp.log Alloc 800 20 のように実行しました(G1とParallel GCは -XX:+UseG1GC・-XX:+UseParallelGC に置き換え)。末尾に出る割当量は、コードで加算した配列要素のサイズ合計で、オブジェクトヘッダーやアラインメントを含む実際のヒープ割当量ではありません。
import java.util.*;
public class Alloc {
public static void main(String[] args) {
int liveMb = Integer.parseInt(args[0]);
int seconds = Integer.parseInt(args[1]);
List<byte[]> live = new ArrayList<>();
for (int i = 0; i < liveMb * 16; i++) live.add(new byte[64 * 1024]);
Random r = new Random(1);
long end = System.nanoTime() + seconds * 1_000_000_000L;
long allocated = 0;
while (System.nanoTime() < end) {
byte[] b = new byte[1024 + r.nextInt(8192)];
live.set(r.nextInt(live.size()), new byte[64 * 1024]);
allocated += b.length + 64 * 1024;
}
System.out.printf("allocated=%dMiB%n", allocated >> 20);
}
}
ZGCのログの読み方とAllocation Stallの対処
-Xlog:gcの開始・終了ログの読み方
-Xlog:gc だけを付けると、1サイクルにつき開始と終了の2行が出ます(JDK 25の出力)。
[2.142s][info][gc] GC(3) Major Collection (Allocation Rate)
[2.394s][info][gc] GC(3) Major Collection (Allocation Rate) 1370M(67%)->1174M(57%) 0.252s
[2.484s][info][gc] GC(4) Minor Collection (Allocation Rate)
[2.637s][info][gc] GC(4) Minor Collection (Allocation Rate) 1388M(68%)->1202M(59%) 0.153s
「Major」は若い世代と古い世代の両方、「Minor」は若い世代だけの回収です。括弧内は回収を始めた理由で、起動直後の「Warmup」、割り当て速度から逆算して早めに始める「Allocation Rate」、空きが尽きた「Allocation Stall」などがあります。末尾の 0.252s はサイクル全体の所要時間で、停止時間ではありません。G1のログの Pause Young ... 65.413ms と同じ感覚で読むと、ZGCの停止を大きく見積もってしまいます。停止時間は -Xlog:gc* の Pause 行か、-Xlog:safepoint で確認します。
Allocation Stallが出る条件と対処
Allocation Stallは、ZGCの回収がアプリケーションの割り当てに追いつかず、空きが尽きたときに発生します。回収が進んで空きができるまで、割り当てようとしたスレッドだけが待たされます。G1のFull GCのように全スレッドを止めるわけではありませんが、そのスレッドから見れば停止と同じで、応答時間にそのまま乗ります。
前の計測と同じ負荷のまま、ヒープを -Xms1g -Xmx1g に縮めてJDK 27で10秒動かすと(-Xms2g を残して -Xmx1g だけ変えると、初期ヒープが上限を超えてJVMが起動しません)、保持データがヒープの約8割を占める状態になり、次のようなログが続きました(行はJDK 25で取ったもの。JDK 27でも同じ形式です)。
[1.587s][info][gc] GC(10) Minor Collection (Allocation Stall)
[1.590s][info][gc] GC(10) Minor Collection (Allocation Stall) 1024M(100%)->1024M(100%) 0.004s
[1.590s][info][gc] GC(11) Major Collection (Allocation Stall)
[1.607s][info][gc] Allocation Stall (main) 20.420ms
[1.629s][info][gc] Relocation Stall (main) 19.269ms
JDK 27の10秒間でAllocation Stallは211回、最長94.0ms、合計2,329.7msでした。割り当てた量は6,004MiBで、-Xms2g -Xmx2g で同じく10秒動かしたとき(13,227MiB)の半分以下です。ヒープが足りないZGCは、停止時間の短さという長所を失ったうえに処理量も落ちます。なお -Xmx2g の20秒の計測でも、JDK 27では表の2回と同じ日に先に取った2回の計4回のうち2回で、Allocation Stallが出ました(4回・最長16.3msと、1回・0.3ms)。JDK 25の2回では出ていません。保持データ4割の条件でも余裕は大きくありません。
対処の優先順位は次のとおりです。
-Xmxを増やす。生存オブジェクトの量に対して、回収中の割り当てを受け止める余裕を確保する。-XX:ConcGCThreadsを増やして並行回収を速める。CPUをGCに多く回すぶん、アプリケーションの処理に使えるCPUは減る。- 割り当て量そのものを減らす。ループ内の一時オブジェクトやログ文字列の生成をプロファイラで特定する。
生存オブジェクトが時間とともに増え続けている場合は、ヒープを増やしてもいずれ同じ状態に戻ります。その場合はGCの設定ではなく、メモリリークとは?原因の特定方法と直し方を4言語の再現コードで解説の手順で参照の残り方を調べる段階です。
JFRのOldObjectSample(リーク調査)がZGCで記録されない制約
リークを調べる手段として、JDK Flight Recorder(JFR)の jdk.OldObjectSample イベントがあります。長く生き残っているオブジェクトを標本として記録し、どこから参照されているかを追えるイベントですが、世代別ZGCでは記録されなくなりました。JDK 27のリリースノートは、世代別ZGCでは弱参照の処理が古い世代でしか行われず、このイベントとの組み合わせが許容できない性能低下を招くことを理由に挙げています(JDK-8382740)。この変更はJDK 25.0.4とJDK 26.0.2にもバックポートされています。
実際に -XX:StartFlightRecording=settings=profile と -Xlog:jfr+system=info を付けて起動すると、JDK 25.0.4.1・26.0.2.1・27のいずれもZGCでは次の行が出て、G1では出ませんでした(Shenandoahも以前から非対応で、同様の行が出ます)。
[info][jfr,system] jdk.OldObjectSample event is currently not supported for ZGC.
ZGCで動かしている本番環境でリークを追うときは、jcmd <pid> GC.class_histogram を時間をおいて2回取り、増え続けるクラスを比べる方法か、ヒープダンプの解析に切り替えます。jcmdのマニュアルは、どちらも実行時の影響を「High」(ヒープのサイズと中身しだい)としているので、本番では取得時の負荷と停止を事前に見積もります。JVMのネイティブ領域まで含めた使用量はNative Memory Tracking(NMT)とは?jcmdでJVMのネイティブメモリを調べる手順の手順で測れます。
ZGCを採用すべきでない場面
ZGCは停止時間を短くする代わりに、メモリとCPUを余分に使います。次の条件に当てはまるなら、G1のままにするかParallel GCを選ぶ方が合理的です。
- ヒープが数GB以下で、メモリに余裕がない:Compressed Oopsが使えず参照が8バイトになるうえ、回収中の割り当てを受け止める余裕も要る。コンテナのメモリ上限ぎりぎりで動かしているサービスは、切り替えでOOMやAllocation Stallを招く。
- 夜間バッチなど、総処理時間だけが問題になる処理:停止が数百msあっても誰も待っていない処理では、低遅延の対価を払う意味がない。GCスレッドへのCPUの配分は負荷とコア数で変わるので、採用判断は対象バッチの総実行時間をGCごとに比べて行う。
- G1で停止時間の目標を満たせている:G1のGCログで
Pause Youngが目標内に収まり、Full GCも出ていないなら、GCを変える前に確認すべき問題はほかにある。
逆に、G1のログにFull GCやMaxGCPauseMillisを超える停止が繰り返し出ていて、ヒープを増やす余地があるなら、ZGCを試す価値があります。切り替え前後で -Xlog:safepoint を取り、停止の中央値だけでなく最大値とアプリケーションのp99応答時間を比べて判断してください。応答時間の要件からJavaの業務システムを設計し直す場合は、フルスクラッチ開発の相談先として当社も対応しています。
よくある質問
ZGCはどうやって有効化しますか?
起動オプションに -XX:+UseZGC を付けます。JDK 23以降はこれだけで世代別ZGCになります。Java 21とJava 22で世代別ZGCを使う場合だけ、-XX:+ZGenerational の追加が必要です。JDK 26以降でこのフラグを残すとJVMが起動しません。
ZGCとG1GCはどちらを使うべきですか?
既定のG1で停止時間の目標を満たせているなら、G1のままで構いません。応答時間の外れ値が問題になるサービスや、数十GB以上のヒープを扱う場合にZGCを検討します。ZGCはヒープに余裕を持たせることが前提です。
ZGCにFull GCはありますか?
世代別ZGCでヒープ全体を回収するのは「Major Collection」で、古い世代のマークと並行して若い世代の回収も走らせます(JEP 439)。これも並行処理で進むため、G1の Pause Full のような全スレッドを長時間止める回収にはなりません。回収が割り当てに追いつかない場合は、割り当てようとしたスレッドが待たされるAllocation Stallとして現れるので、ログに Allocation Stall が出たらヒープサイズの不足を疑ってください。
MinecraftでZGCを使うとはどういうことですか?
Minecraft Java版を動かすJVMのGCを、既定のG1からZGCに切り替えることを指します。2026年9月15日公開のMinecraft 26.3は、Mojangの配信メタデータでJava 25(javaVersion.majorVersion が25)を指定しているので、ランチャーの起動構成のJVM引数やサーバーの起動コマンドに -XX:+UseZGC を足すだけで世代別ZGCになります。Java 21時代の解説にある -XX:+ZGenerational は不要で、JVM引数に -XX:+UseG1GC が残っていると、GCが2つ指定されたことになりJVMが起動しません。ヒープの余裕が要る点はほかのJavaアプリケーションと同じです。