Javaのガベージコレクション(GC)は、どこからも参照されなくなったオブジェクトをJVMが見つけてヒープを空ける仕組みです。開発者がオブジェクトのメモリを明示的に解放する必要はありません。GCにはアプリケーションを止める処理と、アプリケーションと並行して進む処理があり、運用で調べることが多いのは、ヒープ全体を止めて回収するFull GCです。
この記事では、GCの仕組みと種類を押さえたうえで、Full GCがなぜ起きたのかをGCログの括弧内の原因で切り分ける方法を中心に解説します。原因の表記はJDK 25(LTS)とJDK 27で実際に1つずつ起こして確かめました。古い記事でよく見る「Pause Full (Ergonomics)」がJDK 24以降のログに出ない理由や、CMSの指定が今のJDKでは起動エラーになる点もここで扱います。
まとめ:Java GCとFull GCで最初に押さえる要点
- GCが回収するのは、GCルート(スタック上の変数・static変数など)からたどれないオブジェクトだけ。不要でも参照が残っていれば回収されない。
- Full GCはヒープ全体を止めて回収する処理。原因はGCログの
Pause Full (…)の括弧内に出る。ヒープ逼迫なら G1 はG1 Compaction Pause、Serial と Parallel はAllocation Failure。 - Parallel GCの
ErgonomicsはJDK 23までの表記で、JDK 24で原因コードごと削除された。JDK 25では同じ状況がAllocation Failureと記録される。 - 既定GCはG1。ただしJDK 25はCPU 1個またはメモリ1792MB未満の環境でSerialを選ぶ(コンテナで実測)。JDK 27はどの環境でもG1。
- CMSはJava 14で削除済み。
-XX:+UseConcMarkSweepGCはJDK 15以降、JVMが起動しないエラーになる。 - System.gc()は、明示的GCの動作を変更するオプションがなければ、G1でもParallelでもFull GCを起こす。業務コードでは呼ばず、調査で必要なときは
jcmd <PID> GC.runで外から要求する(実際の動作はGCの種類と明示的GCの設定による)。
JavaのGCの仕組み:GCルートからの到達可能性と回収対象
JavaではnewしたオブジェクトはJVMのヒープに置かれ、開発者は解放の命令を持ちません。GCはGCルート(実行中スレッドのスタックにあるローカル変数、クラスのstaticフィールド、JNIの参照など)から参照をたどり、たどり着けたオブジェクトに印を付けます(マーク)。印の付かなかったものがごみとして回収されます。判定の基準は「最近使ったか」ではなく「GCルートから到達できるか」です。そのため、互いに参照し合う2つのオブジェクトも、外から到達できなければ両方回収されます。
HotSpot JVMのG1・ZGC・Shenandoahは、マークのあと生存オブジェクトを別の領域へ移して詰め(コピー・コンパクション)、断片化を防ぎます。マーク・アンド・スイープなどアルゴリズムの比較や、Python・Go・C#のGCとの違いはガベージコレクション(garbage collection)とは?仕組み・アルゴリズム・言語別の違いとGCログの見方で扱い、この記事はJava(HotSpot JVM)に絞ります。
世代別ヒープとマイナーGC・Mixed GC・Full GCの違い
Serial GCとParallel GCでは、ヒープをYoung領域(Edenと2つのSurvivor)とOld領域に分けます。多くのオブジェクトは作られてすぐ不要になるという経験則(弱い世代仮説)に合わせた構造で、通常の新しいオブジェクトはEdenに置かれ、生存期間などに応じてOldへ移ります(昇格)。G1も世代別ですが、ヒープを同じ大きさのリージョン(最大ヒープ256MiBでサイズを明示しない場合は1MiB)に区切ってリージョンごとにEden・Survivor・Oldの役割を割り当てるため、Survivorを固定の2領域としては持ちません。また、G1のHumongousオブジェクトは直接Oldに割り当てられます。ヒープの容量設定や既定サイズはJavaヒープとは?既定サイズ・-Xmx設定と「Java heap space」エラーの対処をJDK 25で実測、メタスペースなどヒープ外の領域はJava仮想マシン(JVM)とは?仕組み・メモリ構成・JITを実機の出力で解説が対象です。
ログでは回収範囲ごとに表記が分かれます。
| 種類 | 回収する範囲 | 主な契機 | G1のログ表記 |
|---|---|---|---|
| マイナーGC(Young GC) | Young領域 | Edenが満杯 | Pause Young (Normal) |
| Mixed GC | Young+Oldの一部 | 並行マーキングの完了後 | Pause Young (Mixed) |
| Full GC | ヒープ全体 | 回収が割り当てに追いつかない | Pause Full (…) |
G1の並行マーキング開始閾値は、既定ではAdaptive IHOPが実行状況に応じて調整します。JDK 25の初期値はOld容量の45%で、Oldの占有量が開始閾値を超えると、アプリケーションを動かしたまま並行マーキングを始めます。その結果を使ってYoungとOldの一部をまとめて回収するのがMixed GCで、G1はこれでOldを少しずつ片付けるので、通常はFull GCに頼りません。そのため、G1のログにPause Fullが出たらまず原因名を確認し、G1 Compaction Pauseなら、割り当てに必要な空きを通常の回収で確保できず、ヒープ全体の回収に進んだと読みます。
Full GCの発生条件:GCログの原因名による切り分け
GCログの原因名から、Full GCの直接の契機を確認できます。リークや容量不足などの根本原因は、使用量の推移や保持オブジェクトを追加で調べます。-Xlog:gc を付けて起動すると、1回のGCごとに GC(番号) Pause Full (原因) 回収前->回収後(ヒープ容量) 時間 の形で1行出ます。この括弧内の文字列は、OpenJDKのソース gcCause.cpp で定義された原因名です。次の表は、JDK 25(Temurin 25.0.4.1、macOS)でそれぞれの原因を1つずつ起こして記録した結果です。検証はIntel Core i9-9880H(8コア16スレッド)・メモリ16GBのmacOS 26.6上で、保持データを増やし続けるプログラムやクラスローダーを作り続けるプログラムを -Xlog:gc 付きで各1回実行しました。ログに出る時間はこの条件での観測値です。
| 起こした状況 | GC | ログの表記 | Full GCか |
|---|---|---|---|
| 保持データでヒープが満杯 | G1 | Pause Full (G1 Compaction Pause) | はい |
| 保持データでヒープが満杯 | Serial / Parallel | Pause Full (Allocation Failure) | はい |
| System.gc() を呼ぶ | G1 / Parallel | Pause Full (System.gc()) | はい |
| jcmd PID GC.run | G1 | Pause Full (Diagnostic Command) | はい |
| jcmd PID GC.class_histogram | G1 | Pause Full (Heap Inspection Initiated GC) | はい |
| jcmd PID GC.heap_dump | G1 | Pause Full (Heap Dump Initiated GC) | はい |
| Metaspaceが閾値に到達 | Parallel | Pause Full (Metadata GC Threshold) | はい |
| Metaspaceが閾値に到達 | G1 | Pause Young (Concurrent Start) (Metadata GC Threshold) | いいえ |
| リージョンの半分以上の配列を確保 | G1 | Pause Young (Concurrent Start) (G1 Humongous Allocation) | いいえ |
同じ原因でも、GCの種類でFull GCになるかが変わります。Metaspaceの逼迫はParallelではFull GCでしたが、G1では並行マーキングの開始で済みました。原因を見るときは、括弧内の文字列と「Pause Full」か「Pause Young」かをセットで読みます。なお大きな配列(G1ではリージョンの半分以上をHumongousオブジェクトと呼ぶ)は連続した空きリージョンを必要とするため、断片化が進むとヒープを使い切る前でもFull GCの原因になり得ると、JDK 27のG1チューニングガイドに書かれています。
ヒープ逼迫によるFull GC:G1 Compaction PauseとAllocation Failure
ここでは、生きているデータがヒープに収まりきらなくなるケースを確認します。-Xmx256m のG1で保持データを増やし続けたときの原記録から、変化の分かれ目の4行を抜き出します(間の行は省略)。
[0.645s][info][gc] GC(25) Pause Young (Concurrent Start) (G1 Evacuation Pause) 247M->238M(256M) 1.084ms
[0.678s][info][gc] GC(30) Pause Young (Concurrent Start) (G1 Evacuation Pause) (Evacuation Failure: Allocation) 255M->255M(256M) 17.492ms
[0.689s][info][gc] GC(31) Pause Full (G1 Compaction Pause) 255M->228M(256M) 11.919ms
[0.696s][info][gc] GC(34) Pause Full (G1 Compaction Pause) 237M->230M(256M) 2.863ms
読み方の要点は3つです。まず Evacuation Failure: Allocation は、マイナーGCで生き残ったオブジェクトを移す先のリージョンが足りなかったことを示し、Full GCの直前に出る前兆です。次に、回収前と回収後の値(255M->228M)の差が小さいほど、ヒープの大半が本当に生きているデータで占められています。最後に、Full GCが数十ミリ秒おきに連続し、回収後の値が238Mから下がらなくなった時点で、まもなく OutOfMemoryError: Java heap space で止まります。今回の実測でも、Serial・Parallelは「Pause Full (Allocation Failure)」が36回続いたあとにこのエラーで終了しました。OracleのG1チューニングガイド(JDK 25)も、Oldの回収が割り当てに追いつかないときにPause Full (G1 Compaction Pause)が出ると説明しています。
対処は、回収後の値が何を表すかで決まります。回収後の値がアクセス量に応じて上下するだけでは、ヒープ不足とは断定できません。ピーク時の生存データ量と割り当て速度を確認し、利用可能なメモリに余裕があれば -Xmx の増量を検討します。アクセスが減っても回収後の値が下がらない場合は、キャッシュなどの必要な保持データと不要な参照を区別します。同程度の負荷で回収後の使用量が増え続けるならリークを疑い、保持元を調べます。
「Pause Full (Ergonomics)」はJDK 23までのParallel GCの表記
Parallel GCのログでよく検索される Full GC (Ergonomics)(JDK 9以降の統合ログでは Pause Full (Ergonomics))は、マイナーGCが昇格先の不足で完了できなかったとき、またはParallelの自動調整(エルゴノミクス)が「過去の平均昇格量がOldの空きを上回る」と判断したときに、続けて走らせたFull GCの表記です(JDK 21のpsScavenge.cpp)。Old領域の逼迫が原因という意味では、上のAllocation Failureと同じ種類のFull GCです。
この表記はJDK 24以降のログには出ません。OpenJDKでは2024年7月の変更 JDK-8335902 でParallelのマイナーGCからFull GCへ移る処理が書き直され、原因に Allocation Failure を使うようになりました。続く JDK-8337546(2024年8月)で使われなくなった原因コード _adaptive_size_policy 自体が削除されています。実際、JDK 23のgcCause.cppには return "Ergonomics"; がありますが、JDK 24とJDK 25のソースにはありません。JDK 25のParallelで、保持データを増やし続ける負荷と、保持データを入れ替え続ける負荷の両方を流したところ、出たFull GCは計168回すべて「Allocation Failure」でした。
実務で効くのは、ログ監視の条件を「Ergonomics」で書いていると、JDKを上げた途端にFull GCを検知できなくなる点です。アラートは「Pause Full」の行そのもので拾い、原因名は切り分けに使います。
System.gc()・jcmd・MetaspaceによるFull GC
ヒープに余裕があるのにFull GCが出ているなら、括弧内を見れば外からの要求かどうかが分かります。System.gc() はライブラリやフレームワークが内部で呼んでいることがあり、ログに「System.gc()」が並ぶなら呼び出し元を探します。障害調査のために打った jcmd も、表のとおりFull GCを起こします。クラスヒストグラムやヒープダンプを取った時刻にFull GCが出ているのは異常ではありません。
Metaspace(クラスのメタデータ置き場)の逼迫は、クラスを動的に大量生成するアプリケーションで起きます。実測では、別のクラスローダーで同じクラスを2万回読み込むと、-XX:MetaspaceSize=16m のParallelでFull GCが12回出ました。Metaspaceが原因のFull GCが多いときは、ヒープとは別に、初回GCの契機となるMetaspaceSizeとその後の閾値調整を確認し、-XX:MaxMetaspaceSize の上限とクラスローダーの解放漏れを調べます。ヒープ外のメモリをまとめて追うならNative Memory Tracking(NMT)とは?jcmdでJVMのネイティブメモリを調べる手順の方法が使えます。
Java GCの種類と選び方:Serial・Parallel・G1・ZGC・Shenandoah
ここでは、メモリを回収する主要な5種類のGCを比較します。利用できるGCはJDKの配布元やビルドによって異なります。かつてよく解説されたCMS(Concurrent Mark Sweep)はJava 9で非推奨(JEP 291)、Java 14で削除(JEP 363)されており、選択肢にありません。
| GC | 有効化フラグ | 向く用途 | Full GCの扱い |
|---|---|---|---|
| Serial | -XX:+UseSerialGC | 小ヒープ・1CPU | 単一スレッドで実行 |
| Parallel | -XX:+UseParallelGC | 処理量重視のバッチ | 複数スレッドで実行 |
| G1 | -XX:+UseG1GC | 汎用(既定) | Mixed GCで回避が前提 |
| ZGC | -XX:+UseZGC | 大ヒープ・低遅延 | 停止を伴うFull GCを持たない |
| Shenandoah | -XX:+UseShenandoahGC | 低遅延 | 失敗時のみFull GCへ移行 |
特別な理由がなければ既定のG1のまま始めます。G1は -XX:MaxGCPauseMillis(既定200ミリ秒)を目標に回収量を調整する設計で、値を下げると回収頻度が増えて処理量が落ちる場合があります。停止時間の目標は上限保証ではないため、変更前後のログで確認します。総処理時間だけが指標のバッチならParallel、数十GBのヒープで停止を数ミリ秒に抑えたいならZGC(JEP 377、JDK 15で製品版)かShenandoah(JEP 379、同)を検討します。ZGCでSystem.gc()を呼ぶと、ログは「Pause Full」ではなく Major Collection (System.gc()) と出ました。ZGCの仕組みとG1との比較はZGCとは?Javaの低遅延GCの仕組みとG1GCとの違い・有効化手順をJDK 25・27の実測で解説で掘り下げています。
CMS指定によるJDK 15以降の起動エラー
古い起動スクリプトに -XX:+UseConcMarkSweepGC が残っている場合、どうなるかはJDKの版で違います。OpenJDKのフラグ管理表(arguments.cpp)では、このフラグはJDK 14で廃止(obsolete)、JDK 15で失効(expired)と登録されていました。JDK 14では警告を出してフラグを無視し既定GCで起動しますが、JDK 15以降は不明なオプション扱いです。JDK 25で実行すると、次のとおりJVM自体が起動しません。
$ java -XX:+UseConcMarkSweepGC -version
Unrecognized VM option 'UseConcMarkSweepGC'
Error: Could not create the Java Virtual Machine.
Error: A fatal exception has occurred. Program will exit.
Java 8や11から一気に21・25へ上げるときは、起動オプションからCMS関連(-XX:CMSInitiatingOccupancyFraction なども同様)を先に外してください。版を上げたときに止まる箇所の全体像はJava 21と25の違い|正式化した新機能・サポート期限・移行で止まる箇所を実機で比較で整理しています。
既定GCが変わる条件:JDK 25のSerial選択とJDK 27のG1統一
GCを指定しないとき、JDK 25までのJVMは「サーバー級のマシンか」を見て既定GCを決めます。JEP 523 によると、CPUが2個以上かつメモリが1792MB以上ならG1、それに満たなければSerialです。JDK 27ではこの判定がなくなり、環境を問わずG1になりました。コンテナ(colima上のDocker、Temurin公式イメージ)で確かめた結果が次の表です。
| docker runの上限 | JDK 25の既定GC | JDK 27の既定GC | 最大ヒープ |
|---|---|---|---|
--cpus=1 --memory=4g |
Serial | G1 | 1GB |
--cpus=2 --memory=1g |
Serial | G1 | 256MB |
--cpus=2 --memory=1800m |
G1 | (未計測) | 450MB |
--cpus=2 --memory=2g |
G1 | (未計測) | 512MB |
メモリ1GBのコンテナで動くJDK 25のアプリケーションは、指定しなければSerialで動いています。G1前提でチューニングしたつもりでも効いていないので、小さなコンテナでは -XX:+UseG1GC を明示するか、少なくとも後述の方法で実際のGCを確認してください。手元で再現するときの注意として、メモリ量を偽装する -XX:MaxRAM はJDK 26で非推奨、JDK 27では「support was removed in 27.0」の警告とともに無視されます。CPU数の判定は -XX:ActiveProcessorCount=1 で再現でき、JDK 25はSerial、JDK 27はG1を選びました。JDK 27での変更点の全体はJava 27とは|非LTSの半年運用とG1・オブジェクトヘッダ既定化の影響にまとめています。
ZGCについては、JDK 21で世代別ZGC(JEP 439)が入り、JDK 23で世代別が既定(JEP 474)、JDK 24で非世代モードが削除(JEP 490)されました。古い記事にある -XX:+ZGenerational は起動設定から外してください。手元の実行では、JDK 25は「Ignoring option ZGenerational; support was removed in 24.0」の警告を出して無視し、JDK 26と27は Unrecognized VM option 'ZGenerational' で起動しませんでした。
使用中のGCとヒープを確かめる方法:-Xlog:gc・jcmd・jstat
チューニングの前に、実際に動いているGCを確かめます。いちばん短いのは、起動時のGC選択を1行で出す方法です。
$ java -Xlog:gc -version
[0.020s][info][gc] Using G1
$ java -XX:+PrintCommandLineFlags -version
-XX:-AOTInvokeDynamicLinking -XX:-AOTRecordTraining -XX:-AOTReplayTraining -XX:ConcGCThreads=3 -XX:G1ConcRefinementThreads=13 -XX:InitialHeapSize=268435456 -XX:MarkStackSize=4194304 -XX:MaxHeapSize=4294967296 -XX:MinHeapSize=6815736 -XX:+PrintCommandLineFlags -XX:ReservedCodeCacheSize=251658240 -XX:+SegmentedCodeCache -XX:+UseCompressedOops -XX:+UseG1GC
-XX:+PrintCommandLineFlags はJVMが自動で決めたヒープサイズとGCを表示します。コンテナの中で実行すれば、そのコンテナの上限での判断が見えます。最大ヒープの算定に使う -XX:MaxRAMPercentage の既定値は25%です。ただし、小メモリ環境では別の算定が適用されるため、実際の最大ヒープは起動フラグの出力で確認します。
動いているプロセスは、JDK付属の jcmd と jstat で外から観察できます。再起動もオプションの追加も要りません。
$ jcmd -l
83569 Slow
$ jcmd 83569 GC.heap_info
83569:
garbage-first heap total reserved 262144K, committed 262144K, used 71744K [0x00000007f0000000, 0x0000000800000000)
region size 1024K, 71 young (72704K), 13 survivors (13312K)
jstat -gcutil PID 1000 5 は1秒ごとに5回、領域ごとの使用率とGCの回数・累積時間を出します。次は -Xmx64m のG1でデータを少しずつ保持し続けた別のプロセスに jstat -gcutil PID 1000 12 を当て、12行のうち6行を抜き出したものです。
S0 S1 E O M CCS YGC YGCT FGC FGCT CGC CGCT GCT
- 75.00 8.57 0.00 96.30 81.17 5 0.007 0 0.000 0 0.000 0.007
- 93.75 17.65 10.58 96.30 81.17 10 0.014 0 0.000 0 0.000 0.014
- 62.50 55.56 24.04 96.30 81.17 13 0.451 0 0.000 0 0.000 0.451
- 81.25 44.12 40.38 96.30 81.17 28 0.469 0 0.000 0 0.000 0.469
- 81.25 17.65 54.81 96.30 81.17 37 0.481 0 0.000 0 0.000 0.481
- 81.25 17.65 82.69 96.30 81.17 47 0.494 0 0.000 0 0.000 0.494
見る列はO(Oldの使用率)、YGCとFGC(マイナーGCとFull GCの回数)、FGCT(Full GCの累積秒)です。この例ではマイナーGCが進むたびにOが0%から82%まで下がらずに上がり続けており、このあとFull GCが連続しました。Oが上下せず右肩上がりなら、Full GCが出る前にリークやヒープ不足を疑えます。G1でS0列が「-」になるのは異常ではありません。G1はjstat向けのカウンターでSurvivorをS1の1本にまとめて報告し、S0を容量0の未使用領域として登録しています(g1MonitoringSupport.cpp)。Spring Bootのアプリケーションなら同じ値をHTTPで取れるSpring Boot Actuatorの設定|exposure.includeとaccessの使い分け【4.1対応】も使えます。
GCログの見方:-Xlog:gcの指定方法と1行の読み方
JDK 9以降、GCログは統合ログ(JEP 158、GCログの移行は JEP 271)に一本化され、-Xlog で指定します。本番ではファイルに出し、ローテーションもJVMに任せます。
java '-Xlog:gc*:file=gc.log:time,uptime,level,tags:filecount=5,filesize=10m' -jar app.jar
zshなど一部のシェルでは * が展開されるので、上のように引数全体を引用符で囲みます。gc ではG1の各停止の要約などが出力され、gc* にするとFull GCの内訳まで出ます。G1のFull GCを gc* で記録すると、次のように5つのフェーズの時間が並びます(先頭の日時の装飾と、並行マーキングの行は省略)。
[0.525s][info][gc,start ] GC(21) Pause Full (G1 Compaction Pause)
[0.525s][info][gc,task ] GC(21) Using 6 workers of 13 for full compaction
[0.528s][info][gc,phases ] GC(21) Phase 1: Mark live objects 2.151ms
[0.528s][info][gc,phases ] GC(21) Phase 2: Prepare compaction 0.403ms
[0.529s][info][gc,phases ] GC(21) Phase 3: Adjust pointers 0.552ms
[0.542s][info][gc,phases ] GC(21) Phase 4: Compact heap 13.505ms
[0.542s][info][gc,phases ] GC(21) Phase 5: Reset Metadata 0.132ms
[0.543s][info][gc ] GC(21) Pause Full (G1 Compaction Pause) 255M->125M(256M) 18.542ms
Full GCが遅いとき、時間の大半がPhase 1(生存オブジェクトのマーク)にあれば生きているデータが多く、Phase 4(詰め直し)にあれば移動するデータ量が多いと読めます。この例では18.5ミリ秒のうち13.5ミリ秒がPhase 4でした。停止時間の分布やヒープの推移をグラフで見たいときは、出力したログをGCViewerやGCeasyなどの解析ツールに読み込ませます。
古い記事にある -XX:+PrintGCDetails や -Xloggc はJDK 25でも27でもまだ起動はできますが、「-XX:+PrintGCDetails is deprecated. Will use -Xlog:gc* instead.」の警告が出て、-Xlogの指定に読み替えられます。javaコマンドのリファレンスの「Convert GC Logging Flags to Xlog」に沿った主な対応は次のとおりです。
| 旧フラグ | -Xlogでの指定 |
|---|---|
| -XX:+PrintGC | -Xlog:gc |
| -XX:+PrintGCDetails | -Xlog:gc* |
| -Xloggc:gc.log | -Xlog:gc:file=gc.log |
| -XX:+PrintHeapAtGC | -Xlog:gc+heap=trace |
| -XX:+PrintGCApplicationStoppedTime | -Xlog:safepoint |
| -XX:+PrintGCDateStamps | 装飾子 time で代替 |
System.gc()による手動実行・強制実行の判断基準
業務コードでSystem.gc()を呼ぶべきではありません。System.gc()のJavadoc では、GCを実行するよう「示唆する」メソッドで、どこまで回収するかはJVMに任されています。実際の動きは、強制実行に近い一方で重い処理です。50MB分の配列を確保してから1回呼んだ実測は次のとおりでした。
| GCとオプション | System.gc()で起きたこと |
|---|---|
| G1(既定) | Pause Full (System.gc()) 6.972ms |
| Parallel | Pause Full (System.gc()) 37.920ms |
| G1+-XX:+ExplicitGCInvokesConcurrent | Pause Young (Concurrent Start) (System.gc()) 1.701ms |
| G1+-XX:+DisableExplicitGC | GCの行が出ない(無視) |
| ZGC | Major Collection (System.gc()) |
既定のG1でもParallelでも、1回の呼び出しがヒープ全体のFull GCになります。停止時間は生存データ量やCPU性能などに左右されるため、リクエスト処理の途中で呼ぶと応答遅延の原因になります。ライブラリが呼ぶSystem.gc()を止めたいときは -XX:+DisableExplicitGC で無視させるか、G1なら -XX:+ExplicitGCInvokesConcurrent で並行マーキングの開始に置き換えられます。DisableExplicitGCを付けても、JVMは必要なときに通常どおりGCを実行します。
手動実行は、調査で回収後の保持量を比較する場合などに用途を限定します。ベンチマーク前に実行しても、同一のヒープ状態になる保証はありません。実行するときもコードは書き換えず、jcmd <PID> GC.run で外から要求できます。今回のG1の既定設定ではFull GC(Diagnostic Command)になりました。対象GCと明示的GCの設定によって動作が変わるため、本番では停止の影響を確認したうえで実行します。
GCがあってもメモリリークは起きる:再現とリーク元の特定
GCは到達できないオブジェクトしか回収しないので、論理的には不要でも参照が残っていれば回収されません。Javaで多いのは、staticなコレクションに追加した要素の削除漏れ、登録したリスナーの解除漏れ、上限のない自前キャッシュの3つです。1つ目をそのまま実行できる最小コードで再現します。JDK 11以降はソースファイルを直接javaコマンドに渡せます。
import java.util.ArrayList;
import java.util.List;
public class LeakDemo {
static final List<byte[]> CACHE = new ArrayList<>();
public static void main(String[] args) {
for (int i = 0; ; i++) {
CACHE.add(new byte[64 * 1024]);
if (i % 320 == 0) {
System.out.println("added " + (i / 16) + " MiB");
}
}
}
}
$ java -Xmx128m -Xlog:gc -XX:+HeapDumpOnOutOfMemoryError LeakDemo.java
...
added 100 MiB
...
[1.667s][info][gc] GC(9) Pause Full (G1 Compaction Pause) 127M->117M(128M) 17.118ms
...
[1.759s][info][gc] GC(17) Pause Full (G1 Compaction Pause) 120M->120M(128M) 12.691ms
[1.767s][info][gc] GC(18) Pause Full (G1 Compaction Pause) 120M->120M(128M) 8.583ms
java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid84968.hprof ...
Heap dump file created [130940427 bytes in 0.285 secs]
100MiBを超えたあとFull GCが6回続き、最後の2回は回収前と回収後がどちらも120Mのまま、その直後にOutOfMemoryErrorとヒープダンプの出力が出ました。エラーのあとも例外の出力中にFull GCが続き、実行全体では14回記録されています。-XX:+HeapDumpOnOutOfMemoryError を付けておくと、その瞬間のヒープが約131MBのHPROFファイルとして残ります。ヒープと同じくらいのディスクを使う点だけ見込んでおけば、本番でも付けておく価値があります。
リーク元を探すときは、まずクラスヒストグラムでクラス別の使用量を調べます。ただし停止への影響が大きいため、採取可能な時間帯に間隔を空けて2回取り、インスタンス数が増え続けるクラスが候補です。
$ jcmd 84175 GC.class_histogram | head -6
84175:
num #instances #bytes class name (module)
-------------------------------------------------------
1: 1806 5321712 [B ([email protected])
2: 517 68840 java.lang.Class ([email protected])
3: 1710 41040 java.lang.String ([email protected])
今回の実測(G1・既定オプション)では、クラスヒストグラムとヒープダンプのどちらの採取でもFull GCが起きました。大きなヒープではロードバランサーから外したインスタンスで取ります。なお GC.heap_dump は -all を付けると到達できないオブジェクトも含めて書き出し、Full GCを要求しません(jcmdのリファレンス)。直し方は、参照が残る期間を設計で決めることです。次のキャッシュ例は単一スレッドで件数を制限するものです。複数スレッドで共有する場合は同期が必要で、値のサイズが変動する場合は件数だけでメモリ量を制限できません。
import java.util.LinkedHashMap;
import java.util.Map;
public class BoundedCache<K, V> extends LinkedHashMap<K, V> {
private final int maxEntries;
public BoundedCache(int maxEntries) {
super(16, 0.75f, true);
this.maxEntries = maxEntries;
}
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > maxEntries;
}
public static void main(String[] args) {
BoundedCache<Integer, byte[]> cache = new BoundedCache<>(30);
for (int i = 0; i < 100_000; i++) {
cache.put(i, new byte[1024 * 1024]);
}
System.out.println("entries=" + cache.size());
}
}
$ java -Xmx128m -Xlog:gc BoundedCache.java
...
entries=30
上限30件で1MiBの値を10万回入れたところ、同じ -Xmx128m でFull GCは0回、最後まで30件のまま終わりました。有効期限や統計が要るならCaffeineなどのキャッシュライブラリを使います。言語をまたいだリークの調べ方はメモリリークとは?原因の特定方法と直し方を4言語の再現コードで解説で扱っています。
GCチューニングの判断順と見送ってよい場面
ログを見ないまま、検索で見つけたフラグを積み上げるのは避けます。古い記事のフラグはCMS前提や旧ログ形式が多く、今のJDKでは効かないか起動しません。変更は次の順に1項目ずつ行い、前後のGCログで比べます。
- 保持しているものを調べる:Full GC後の使用量が下がらないときは、前章の手順で保持しているオブジェクトと参照元を確かめる。不要な参照ならリークでGC設定では直らない。必要なデータならヒープ不足として次へ進む。
- ヒープを合わせる:
-Xms・-Xmx、コンテナなら-XX:MaxRAMPercentageで、実測した生存データ量に余裕を持たせる。 - 停止目標を調整する:G1のまま
-XX:MaxGCPauseMillisを応答要件に合わせる。 - GCを変える:それでも停止時間が要件を満たさないときに初めてZGCを試す。バッチならParallelに替えて総処理時間を比べる。
反対に、GCログでFull GCがほぼ出ず、マイナーGCの停止が応答時間の数%に収まっているなら、設定を触る理由はありません。数十ミリ秒のGC停止より、SQLや外部APIの待ち時間のほうが応答時間を支配している例も多く、計測せずにGCを原因と決めつけないことが先です。コンテナで動かすなら、JDKの版とメモリ上限で既定GCが変わるので、Dockerfileで JAVA_TOOL_OPTIONS などにGCとヒープを明示して環境差を消します。書き方はSpring BootのDockerfileの書き方|マルチステージ・レイヤー展開・AOTキャッシュを4.1.1で検証に実例があります。
GCの停止時間は、ピーク時の応答目標やヒープに常駐させるデータ量と同じく、設計の段階で決める非機能要件です。一創では、Javaを含む業務システムを性能要件の定義から設計・実装まで行うフルスクラッチ開発を提供しています。既存システムのGC停止や応答遅延の原因切り分けからでもご相談ください。
よくある質問
JavaのGC(ガベージコレクション)とは何ですか?
プログラムがどこからも参照しなくなったオブジェクトをJVMが自動で見つけ、ヒープのメモリを再利用できるようにする仕組みです。開発者が解放のコードを書かずに済み、解放し忘れや二重解放のバグを防げます。GCにはアプリケーションが一時停止する処理がありますが、G1やZGCなどでは多くの処理をアプリケーションと並行して進めます。
Full GCとは何ですか?マイナーGCとどう違いますか?
Full GCはYoung領域とOld領域を含むヒープ全体を回収する処理で、停止時間が長くなります。マイナーGCはYoung領域だけを対象にした短い回収です。マイナーGCは頻繁でも問題にならないことが多く、対策が要るのは主にFull GCの連続や長時間化です。
Full GCの発生条件は何ですか?
主な条件は、生存データでヒープが埋まり回収が割り当てに追いつかないこと、System.gc()やjcmdによる外部からの要求、ParallelやSerialでのMetaspaceの逼迫です。どれが原因かはGCログの「Pause Full (…)」の括弧内に出ます。G1のヒープ逼迫なら「G1 Compaction Pause」、Serial・Parallelなら「Allocation Failure」です。
Full GC (Ergonomics) とは何ですか?
Parallel GCで、マイナーGCが昇格先の不足で完了できなかったときや、過去の平均昇格量からOld領域の空きが足りないと判断したときに、続けて実行したFull GCの表記です。JDK 23まではこの名前で出ますが、JDK 24で原因コードが削除され、JDK 25では同じ状況が「Allocation Failure」と記録されます。
JavaでGCを手動・強制実行する方法はありますか?
コードからはSystem.gc()で要求でき、既定のG1ではFull GCとして実行されました。ただしJavadoc上は示唆にすぎず、-XX:+DisableExplicitGCが付いていれば無視されます。調査目的で1回だけ実行したいなら、コードを変えずに jcmd <PID> GC.run で外から要求できます。ただし、停止を引き起こし得るため、実行前に対象GCと負荷への影響を確認します。