ガベージコレクション(GC)とは、プログラムがもう使わなくなったオブジェクトを JVM が自動で見つけ、メモリを空ける仕組みです。 Java では new で作ったオブジェクトを自分で解放する必要がなく、どこからも参照されなくなったものを GC が回収します。

GC にはいくつか種類があり、「どれを使うか」で停止時間(アプリが一時的に止まる時間)と処理量のバランスが変わります。この記事では、同じプログラムを Serial・Parallel・G1・ZGC の4種類で動かした実際のログ を並べ、1行ずつ読み方を説明します。後半では、static な List に溜め続けるメモリリークを再現し、jcmd と jstat で原因のクラスを特定するところまで進めます。

確認環境:Ubuntu 24.04 / OpenJDK 25.0.4.1(CPU 2コア)。ログとコマンドの出力はすべてこの環境で実行したものです。Java 21 との違いに触れる箇所は OpenJDK 21.0.10 で実行しています。時間や回数の数値は実行のたびに変わります。

GC種類の早見表(Java 25基準)

GC 起動オプション 特徴 向く用途 デフォルトになる条件
Serial -XX:+UseSerialGC 1スレッドで回収する。仕組みが単純 CPU が1つの環境、データが小さい(約100MBまで)アプリ CPU が2つ未満、またはメモリが 1792MB 未満のマシン
Parallel -XX:+UseParallelGC 複数スレッドで回収する。処理量(スループット)重視 停止時間の要件がない、1秒以上の停止も許容できるバッチ処理 自動では選ばれない
G1 -XX:+UseG1GC ヒープを領域(リージョン)に分け、停止時間の目標を守りながら回収する 応答時間を保ちたい一般的なサーバーアプリ CPU が2つ以上、かつメモリが 1792MB 以上のマシン(ほとんどの環境)
ZGC -XX:+UseZGC 回収のほとんどをアプリと並行して行う。停止は1ミリ秒未満が目標 応答時間を最優先するアプリ、数百MB〜16TBのヒープ 自動では選ばれない
Shenandoah -XX:+UseShenandoahGC Java 25 で世代別モードが正式機能になった -XX:ShenandoahGCMode=generational を足して世代別モードで使う 自動では選ばれない
やりたいこと コマンド
今どの GC で動いているか確かめる java -Xlog:gc -version の1行目(Using G1 など)
GC のログを画面に出す java -Xlog:gc -cp . Main
詳しいログをファイルに出す java '-Xlog:gc*:file=gc.log:time,uptime,level,tags' -cp . Main
動いている Java のプロセスを探す jcmd -l または jps -l
領域ごとの使用率を1秒おきに見る jstat -gcutil <pid> 1000
クラスごとのインスタンス数とサイズを見る jcmd <pid> GC.class_histogram
ヒープダンプを取る jcmd <pid> GC.heap_dump ファイル名
メモリ不足で落ちたときに自動でダンプを残す -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=パス

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Java ガベージコレクションの仕組みとGC種類比較|ログの読み方まで」

ガベージコレクションの仕組み

回収されるのは「どこからも参照されていない」オブジェクト

50MB の配列を作り、変数に null を入れて参照を外してから System.gc() を呼ぶプログラムです。

public class Sys {
    public static void main(String[] args) {
        byte[] data = new byte[50 * 1024 * 1024];
        data = null;
        System.gc();
    }
}
$ java -Xlog:gc Sys.java
[0.005s][info][gc] Using G1
[0.352s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 15M->4M(96M) 4.391ms
[0.472s][info][gc] GC(1) Pause Young (Concurrent Start) (G1 Humongous Allocation) 11M->5M(96M) 6.696ms
[0.473s][info][gc] GC(2) Concurrent Undo Cycle
[0.475s][info][gc] GC(2) Concurrent Undo Cycle 1.980ms
[0.509s][info][gc] GC(3) Pause Full (System.gc()) 56M->2M(14M) 9.377ms

最後の行で 56M → 2M に減っています。配列を指していた変数 data に null を入れたので、50MB の配列はどこからも参照されなくなり、回収されました。逆に、変数・フィールド・コレクションのどれかから参照がたどれる間は、何度 GC が走っても回収されません。後半のメモリリークは、この「参照が残り続ける」状態です。

なお System.gc() は GC を促すための呼び出しで、通常のアプリでは自分で呼ぶ必要はありません。同じプログラムに -XX:+DisableExplicitGC を付けて実行すると、最後の Pause Full の行は出ませんでした。

世代別GC:若い世代と Old 世代に分ける

Oracle の GC チューニングガイドでは、多くの GC が前提にしている考え方として「ほとんどのオブジェクトは短い時間しか生き残らない」という経験則(弱い世代仮説)が紹介されています。そこでヒープを世代に分け、作られたばかりのオブジェクトがいる領域だけを頻繁に掃除します。

領域 役割
Eden(エデン) 新しいオブジェクトが最初に置かれる場所
Survivor(サバイバー)2つ Eden の掃除で生き残ったオブジェクトが移る場所。片方は常に空
Old(オールド) Survivor を何度か行き来しても生き残ったオブジェクトが移る場所
回収の種類 対象 ログでの表示
マイナーGC(Young GC) Eden と Survivor(若い世代)だけ Pause Young
メジャーGC/フルGC ヒープ全体 Pause Full

同ガイドには「若い世代がいっぱいになるとマイナーコレクションが起き、若い世代だけが回収される」「メジャーコレクションは対象のオブジェクトがずっと多いため、通常マイナーコレクションよりかなり長くかかる」と書かれています。ログで Pause Full が続いていないかを見るのが、GC の調子を確かめる最初の一歩 です。

Serial GC で領域ごとの内訳を出す(-Xlog:gc,gc+heap)と、この移動がそのまま見えます。

[0.077s][info][gc,heap] GC(0) DefNew: 69931K(78656K)->8704K(78656K) Eden: 69931K(69952K)->0K(69952K) From: 0K(8704K)->8704K(8704K)
[0.077s][info][gc,heap] GC(0) Tenured: 1156K(174784K)->24542K(174784K)
[0.077s][info][gc     ] GC(0) Pause Young (Allocation Failure) 69M->32M(247M) 19.136ms
  • Eden: 69931K → 0K:Eden が空になった
  • From: 0K → 8704K:生き残りの一部が Survivor に移った
  • Tenured: 1156K → 24542K:Survivor に入りきらなかったぶんが Old(Serial のログでは Tenured)に移った

同じプログラムを4種類のGCで動かす

64KB の配列を2万個(合計 1,250MB)作り、直近の500個(約32MB)だけを持ち続けるプログラムです。501個目からは先頭を手放すので、手放したものがごみになります。

import java.util.ArrayDeque;
import java.util.Deque;

public class GcDemo {
    public static void main(String[] args) {
        Deque<byte[]> recent = new ArrayDeque<>();   // 直近の500個だけ持ち続ける
        long total = 0;
        for (int i = 0; i < 20_000; i++) {
            byte[] block = new byte[64 * 1024];      // 64KB の配列を作る
            block[0] = (byte) i;
            recent.addLast(block);
            if (recent.size() > 500) {
                recent.removeFirst();                // 501個目からは先頭を手放す → ごみになる
            }
            total += block.length;
        }
        System.out.printf("確保した合計: %,d MB / 最後に持っている数: %d%n",
            total / (1024 * 1024), recent.size());
    }
}

ヒープを 256MB に固定し、GC だけを切り替えて実行します。

javac GcDemo.java
java -XX:+UseSerialGC   -Xms256m -Xmx256m -Xlog:gc GcDemo
java -XX:+UseParallelGC -Xms256m -Xmx256m -Xlog:gc GcDemo
java -XX:+UseG1GC       -Xms256m -Xmx256m -Xlog:gc GcDemo
java -XX:+UseZGC        -Xms256m -Xmx256m -Xlog:gc GcDemo

Serial GC のログを1行ずつ読む

[0.008s][info][gc] Using Serial
[0.125s][info][gc] GC(0) Pause Young (Allocation Failure) 69M->32M(247M) 19.321ms
[0.152s][info][gc] GC(1) Pause Young (Allocation Failure) 100M->55M(247M) 19.025ms
[0.174s][info][gc] GC(2) Pause Young (Allocation Failure) 123M->78M(247M) 15.002ms
[0.197s][info][gc] GC(3) Pause Young (Allocation Failure) 146M->100M(247M) 15.241ms
[0.221s][info][gc] GC(4) Pause Young (Allocation Failure) 169M->123M(247M) 15.111ms
[0.247s][info][gc] GC(5) Pause Young (Allocation Failure) 192M->146M(247M) 14.975ms
[0.268s][info][gc] GC(6) Pause Young (Allocation Failure) 214M->169M(247M) 15.078ms
[0.282s][info][gc] GC(7) Pause Full (Allocation Failure) 237M->32M(247M) 6.814ms
[0.294s][info][gc] GC(8) Pause Young (Allocation Failure) 100M->63M(247M) 4.903ms

2行目を分解すると、次のように読めます。

部分 意味
[0.125s] JVM が起動してからの経過時間
[info] ログのレベル
[gc] ログの種類(タグ)
GC(0) GC の通し番号。0 から始まる
Pause Young 若い世代の回収。Pause は、その間アプリのスレッドが止まっていたことを表す
(Allocation Failure) きっかけ。新しいオブジェクトを置く場所が足りなくなった
69M->32M ヒープ使用量が、回収前 69MB → 回収後 32MB
(247M) 現在のヒープの大きさ
19.321ms 停止していた時間

流れを追うと、GC(0)〜GC(6) では 回収後の使用量が 32M → 55M → 78M → … → 169M と増えていきます。若い世代の回収では Old に移ったごみが片づかないためです。Old がいっぱいになった GC(7) で Pause Full が走り、237M → 32M まで戻りました。この 32M が、プログラムが持ち続けている500個ぶん(約32MB)にあたります。

「Young GC のたびに少しずつ増え、Full GC で元の水準に戻る」のこぎり形は、正常な動きです。 見るべきなのは Full GC の後の値で、ここが毎回同じ水準に戻っていれば、溜まり続けているものはありません。

Parallel GC のログ

[0.004s][info][gc] Using Parallel
[0.103s][info][gc] GC(0) Pause Young (Allocation Failure) 65M->32M(245M) 32.839ms
[0.131s][info][gc] GC(1) Pause Young (Allocation Failure) 96M->53M(245M) 20.225ms
[0.152s][info][gc] GC(2) Pause Young (Allocation Failure) 117M->74M(245M) 14.206ms
[0.174s][info][gc] GC(3) Pause Young (Allocation Failure) 138M->95M(245M) 15.250ms
[0.194s][info][gc] GC(4) Pause Young (Allocation Failure) 159M->115M(245M) 14.308ms
[0.219s][info][gc] GC(5) Pause Young (Allocation Failure) 179M->136M(210M) 14.749ms
[0.227s][info][gc] GC(6) Pause Young (Allocation Failure) 165M->155M(228M) 5.380ms

行の形は Serial と同じです。違いは、GC(5) からカッコの中のヒープの大きさが 245M → 210M → 228M と変わっている点です。Parallel GC が動きながら領域の配分を変えていると読めます。Oracle のガイドでは、Parallel は「スループットコレクタとも呼ばれ、Serial との主な違いは回収に複数スレッドを使うこと」と説明されています。

G1 GC のログ

[0.004s][info][gc] Using G1
[0.055s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 15M->14M(256M) 9.322ms
[0.115s][info][gc] GC(1) Pause Young (Normal) (G1 Evacuation Pause) 97M->46M(256M) 22.718ms
[0.140s][info][gc] GC(2) Pause Young (Normal) (G1 Evacuation Pause) 116M->68M(256M) 17.378ms
[0.167s][info][gc] GC(3) Pause Young (Normal) (G1 Evacuation Pause) 141M->90M(256M) 18.396ms
[0.193s][info][gc] GC(4) Pause Young (Normal) (G1 Evacuation Pause) 156M->113M(256M) 17.847ms
[0.214s][info][gc] GC(5) Pause Young (Normal) (G1 Evacuation Pause) 170M->136M(256M) 15.276ms
[0.238s][info][gc] GC(6) Pause Young (Concurrent Start) (G1 Evacuation Pause) 179M->160M(256M) 16.467ms
[0.241s][info][gc] GC(7) Concurrent Mark Cycle
[0.251s][info][gc] GC(8) Pause Young (Normal) (G1 Evacuation Pause) 190M->187M(256M) 7.572ms
[0.251s][info][gc] GC(7) Pause Remark 187M->63M(256M) 0.303ms
[0.252s][info][gc] GC(7) Pause Cleanup 64M->64M(256M) 0.012ms
[0.257s][info][gc] GC(7) Concurrent Mark Cycle 15.639ms
[0.260s][info][gc] GC(9) Pause Young (Prepare Mixed) (G1 Evacuation Pause) 84M->79M(256M) 3.625ms
[0.271s][info][gc] GC(10) Pause Young (Mixed) (G1 Evacuation Pause) 145M->107M(256M) 5.276ms

G1 では Pause Young の後ろに種類が付きます。Oracle のガイドの説明と合わせて読むと次のとおりです。

ログの表示 意味
Pause Young (Normal) 通常の若い世代の回収
Pause Young (Concurrent Start) 若い世代の回収に加えて、Old の印付け(マーキング)を開始する
Concurrent Mark Cycle アプリを動かしたまま、生きているオブジェクトに印を付けていく処理。終わりの行に所要時間が出る
Pause Remark 印付けの仕上げ。まるごと空になったリージョンをここで回収する
Pause Cleanup このあと Old の回収に進むかどうかを決める
Pause Young (Prepare Mixed) Mixed に入る前の準備の回収
Pause Young (Mixed) 若い世代に加えて、Old の一部のリージョンも回収する

GC(7) の Pause Remark で 187M → 63M に減り、停止は 0.303ms です。Serial では Full GC で止めて片づけていた Old のごみを、G1 は印付けをアプリと並行して進め、短い停止で回収しています。この実行では Pause Full は1回も出ませんでした。同ガイドには、回収しても空きがまったく作れない最悪の場合に G1 が Full GC を行う、と書かれています。G1 のログに Pause Full が出ていたら、ヒープの大きさや割り当ての量を見直す合図です。

詳細ログ('-Xlog:gc*')にすると、1回の GC でリージョンがどう動いたかも出ます。

[0.099s][info][gc,heap     ] GC(1) Eden regions: 83->0(70)
[0.099s][info][gc,heap     ] GC(1) Survivor regions: 2->11(11)
[0.099s][info][gc,heap     ] GC(1) Old regions: 14->37
[0.099s][info][gc,heap     ] GC(1) Humongous regions: 0->0
[0.099s][info][gc          ] GC(1) Pause Young (Normal) (G1 Evacuation Pause) 97M->46M(256M) 15.941ms

この実行ではリージョン1つが 1MB でした(Heap Region Size: 1M と出力されます)。Eden が 83個 → 0個、Survivor が 2個 → 11個、Old が 14個 → 37個です。Humongous は、リージョンの半分以上の大きさを持つ大きなオブジェクト用の領域です。

ZGC のログ

[0.057s][info][gc] Using The Z Garbage Collector
[0.118s][info][gc] GC(0) Major Collection (Warmup)
[0.129s][info][gc] GC(0) Major Collection (Warmup) 26M(10%)->44M(17%) 0.011s
[0.133s][info][gc] GC(1) Major Collection (Warmup)
[0.140s][info][gc] GC(1) Major Collection (Warmup) 52M(20%)->46M(18%) 0.007s
[0.189s][info][gc] GC(3) Minor Collection (Allocation Rate)
[0.193s][info][gc] GC(3) Minor Collection (Allocation Rate) 138M(54%)->46M(18%) 0.004s

ZGC のログには Pause の文字がありません。1回の回収につき開始と終了の2行が出て、終了の行に「使用量(ヒープに占める割合)の前後」と「回収にかかった時間」が付きます。Minor Collection は若い世代の回収、Major Collection は若い世代と Old 世代の両方の回収です(フェーズ別のログで、Major には Young Generation と Old Generation の両方の行が出ます)。最後の 0.004s は回収サイクル全体の時間で、アプリが止まっていた時間ではありません。

停止時間は、フェーズ別のログ(-Xlog:gc,gc+phases)で確かめられます(1回ぶんの抜粋です)。

[0.203s][info][gc       ] GC(3) Minor Collection (Allocation Rate)
[0.203s][info][gc,phases] GC(3) y: Young Generation
[0.203s][info][gc,phases] GC(3) y: Pause Mark Start 0.009ms
[0.205s][info][gc,phases] GC(3) y: Concurrent Mark 1.720ms
[0.205s][info][gc,phases] GC(3) y: Pause Mark End 0.008ms
[0.206s][info][gc,phases] GC(3) y: Concurrent Select Relocation Set 0.198ms
[0.206s][info][gc,phases] GC(3) y: Pause Relocate Start 0.006ms
[0.207s][info][gc,phases] GC(3) y: Concurrent Relocate 0.134ms
[0.207s][info][gc       ] GC(3) Minor Collection (Allocation Rate) 152M(59%)->46M(18%) 0.004s

Pause が付く行が停止、Concurrent が付く行がアプリと並行して動いた処理です。停止は 0.009ms・0.008ms・0.006ms の3回で、印付け(Concurrent Mark)や移動(Concurrent Relocate)はアプリを止めずに行われています。

4種類の結果を比べる

同じプログラム・同じヒープ(256MB)で、ログから停止(Pause)の行を集計した結果です。

GC Pause の回数 うち Full 停止時間の合計 いちばん長い停止
Serial 18回 2回 169.0 ms 19.321 ms
Parallel 36回 1回 251.9 ms 32.839 ms
G1 43回(Remark・Cleanup を含む) 0回 223.7 ms 22.718 ms
ZGC 50回 なし 0.313 ms 0.011 ms
  • ZGC の停止は合計でも 0.3ms ほど で、ほかの3つと桁が違いました。Oracle のガイドには、ZGC は停止時間を1ミリ秒未満に抑える代わりにスループットをいくらか使う、と書かれています。
  • 停止して回収する3つの中では、Serial が合計時間で最少 でした。今回は生き残るデータが約32MBと小さく、CPU も2コアです。ガイドの「データが小さい(約100MBまで)アプリには Serial」という説明と合う結果です。
  • この表は1回の実行結果です。GC の向き不向きは、ヒープの大きさ・生き残るデータの量・CPU の数で変わるので、自分のアプリで同じようにログを取って比べる のが確実です。

GCの選び方とデフォルトの決まり方

デフォルトのGCはマシンの条件で決まる

Oracle のガイドによると、JVM は CPU が2つ以上、かつ物理メモリが 1792MB 以上のマシンを「サーバークラス」とみなし、サーバークラスなら G1、そうでなければ Serial を選びます。CPU の数を1つに見せて確かめます。

$ java -Xlog:gc -version
[0.004s][info][gc] Using G1

$ java -XX:ActiveProcessorCount=1 -Xlog:gc -version
[0.004s][info][gc] Using Serial

CPU が1つしか割り当てられていない環境では、何も指定しないと Serial で動く ことになります。意図した GC で動いているかは、-Xlog:gc の1行目で確かめられます。ヒープの大きさも同ガイドに既定値があり、初期サイズは物理メモリの 1/64、最大サイズは 1/4 です。今の値は次のコマンドで見られます。

$ java -XX:+PrintFlagsFinal -version | grep -E ' MaxHeapSize| InitialHeapSize| UseG1GC '
   size_t InitialHeapSize                          = 98566144                                  {product} {ergonomic}
   size_t MaxHeapSize                              = 1568669696                                {product} {ergonomic}
     bool UseG1GC                                  = true                                      {product} {ergonomic}

右端の {ergonomic} は JVM が環境から自動で決めた値、{command line} はオプションで指定した値という印です。

選び方の判断表

Oracle のガイドの「コレクタの選択」の指針を表にしたものです。

状況 選ぶもの
特に要件がない まず何も指定せず、JVM に選ばせる
データが小さい(約100MBまで)、または CPU が1つで停止時間の要件がない Serial(-XX:+UseSerialGC)
処理量が最優先で、1秒以上の停止も許容できる JVM に選ばせる、または Parallel(-XX:+UseParallelGC)
全体の処理量より応答時間が大事で、停止を短く保ちたい G1(-XX:+UseG1GC)
応答時間が最優先 ZGC(-XX:+UseZGC)

よく使う調整用のオプションは次のとおりです。

オプション 意味
-Xms256m / -Xmx256m ヒープの初期サイズ/最大サイズ
-XX:MaxGCPauseMillis=200 停止時間の目標(ミリ秒)。G1 の既定値は 200。保証ではなく目標
-XX:GCTimeRatio=19 GC に使ってよい時間の割合の目標。19 なら全体の 1/20(5%)
-XX:ActiveProcessorCount=2 JVM に見せる CPU の数

Java 25 の ZGC は世代別モードのみ

ZGC には以前、世代に分けないモードと世代別モードの2つがあり、-XX:+ZGenerational で切り替えていました。JEP 490 でこの世代に分けないモードが取り除かれ、Java 25 では世代別モードだけになっています。Java 25 で以前のオプションを付けると、次の警告が出て無視されます。

$ java -XX:+UseZGC -XX:+ZGenerational -version
OpenJDK 64-Bit Server VM warning: Ignoring option ZGenerational; support was removed in 24.0
openjdk version "25.0.4.1" 2026-08-18
バージョン ZGC の指定 今回確かめた内容
Java 21 -XX:+UseZGC だけだと世代に分けないモード。世代別は -XX:+UseZGC -XX:+ZGenerational -XX:+PrintFlagsFinal で ZGenerational の既定値が false
Java 25 -XX:+UseZGC だけで世代別モード ZGenerational を付けると上の警告が出る

起動スクリプトに -XX:+ZGenerational や -XX:-ZGenerational が残っている場合は、Java 25 に上げるタイミングで外しておくとログがすっきりします。JEP 490 には、このオプションは将来のリリースで指定すると JVM が起動しなくなる予定とも書かれています。

そのほか、Java 25 で実行して確かめた点です。

試したこと 結果
-XX:+UseG1GC -XX:+UseZGC と2つ指定 Multiple garbage collectors selected というエラーで起動しない
-XX:+UseConcMarkSweepGC(CMS) Unrecognized VM option 'UseConcMarkSweepGC' で起動しない
-XX:+UseShenandoahGC -XX:ShenandoahGCMode=generational 追加のオプションなしで起動する(JEP 521 で正式機能になった)

GCログの出し方

書き方 出る内容
-Xlog:gc 1回の GC につき1行(この記事の主な例)
-Xlog:gc,gc+heap 上に加えて領域ごとの内訳
-Xlog:gc,gc+phases 上に加えてフェーズごとの時間(ZGC の停止時間の確認に使った)
'-Xlog:gc*' gc で始まるすべてのタグ。起動時の設定(CPU 数、ヒープの最小・初期・最大など)も出る
'-Xlog:gc*:file=gc.log:time,uptime,level,tags' ファイルに出力し、行頭に日時・経過時間・レベル・タグを付ける

ファイルに出した場合の行頭は次のようになります。

[2026-10-04T00:13:42.863+0900][0.004s][info][gc     ] Using G1
[2026-10-04T00:13:42.864+0900][0.005s][info][gc,init] CPUs: 2 total, 2 available
[2026-10-04T00:13:42.864+0900][0.005s][info][gc,init] Heap Region Size: 1M
[2026-10-04T00:13:42.864+0900][0.005s][info][gc,init] Heap Max Capacity: 256M

gc* のように * を含むオプションは、シェルがファイル名の展開をしようとすることがあります。今回使った zsh では、引用符なしで書くと no matches found というシェルのエラーになりました。シングルクォートで囲んで渡す と確実です。

メモリリークを再現して原因クラスを特定する

Java のメモリリークは、「もう使わないのに参照が残っていて、GC が回収できない」状態です。static な List に足し続けて、消す処理が無い例で再現します。

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

public class LeakDemo {
    // 1件ぶんの履歴。本体は 1KB の配列
    static class HistoryEntry {
        final long id;
        final byte[] payload = new byte[1024];
        HistoryEntry(long id) {
            this.id = id;
        }
    }

    // static な List に足すだけで、消す処理が無い
    static final List<HistoryEntry> HISTORY = new ArrayList<>();

    public static void main(String[] args) throws InterruptedException {
        long id = 0;
        while (true) {
            for (int i = 0; i < 400; i++) {
                HISTORY.add(new HistoryEntry(id++));   // 0.1秒ごとに約400KB増える
            }
            byte[] temp = new byte[256 * 1024];        // すぐ捨てる一時データ(こちらは回収される)
            temp[0] = 1;
            Thread.sleep(100);
        }
    }
}

ヒープを 64MB に絞って実行します。

javac LeakDemo.java
java -Xmx64m -Xlog:gc LeakDemo

ログの形:Full GC の後も使用量が下がらない

起動から15秒ほどのログです(途中を省いています)。

[0.006s][info][gc] Using G1
[2.079s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 15M->10M(64M) 11.884ms
[6.985s][info][gc] GC(2) Pause Young (Normal) (G1 Evacuation Pause) 34M->29M(64M) 6.707ms
[8.795s][info][gc] GC(3) Pause Young (Concurrent Start) (G1 Evacuation Pause) 41M->37M(64M) 4.622ms
[11.427s][info][gc] GC(7) Pause Remark 47M->47M(64M) 0.209ms
[13.353s][info][gc] GC(11) Pause Remark 56M->56M(64M) 0.637ms
[14.265s][info][gc] GC(15) Pause Remark 60M->60M(64M) 0.442ms
[14.877s][info][gc] GC(18) Pause Full (G1 Compaction Pause) 63M->60M(64M) 15.443ms
[15.209s][info][gc] GC(21) Pause Full (G1 Compaction Pause) 62M->61M(64M) 26.425ms
[15.339s][info][gc] GC(23) Pause Full (G1 Compaction Pause) 62M->62M(64M) 23.619ms
[15.575s][info][gc] GC(25) Pause Full (G1 Compaction Pause) 63M->63M(64M) 28.146ms
[15.724s][info][gc] GC(29) Pause Full (G1 Compaction Pause) 63M->63M(64M) 24.952ms
[15.744s][info][gc] GC(30) Pause Full (G1 Compaction Pause) 63M->63M(64M) 19.988ms
[15.794s][info][gc] GC(37) Pause Full (G1 Compaction Pause) 63M->63M(64M) 13.345ms

正常なプログラムとの違いは3つあります。

見るところ 正常(GcDemo) リーク(LeakDemo)
回収後の使用量 毎回ほぼ同じ水準に戻る 10M → 29M → 37M → 47M → 56M → 60M と上がり続ける
Pause Remark の前後 187M → 63M と大きく減る 47M → 47M、56M → 56M と減らない
Pause Full 出ない、または出ても大きく減る 63M → 63M と減らない Full GC が連続する

Full GC はヒープ全体を調べる回収です。それでも減らないということは、残っているものがすべて「参照されている」ということ です。最後は空きを作れず、OutOfMemoryError で終了しました。-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=leak.hprof を付けて実行すると、次のように出力されてヒープダンプが残ります。

java.lang.OutOfMemoryError: Java heap space
Dumping heap to leak.hprof ...
Heap dump file created [70219265 bytes in 0.172 secs]

手順1:プロセスIDを調べる

LeakDemo を動かしたまま、別のターミナルから調べます。

$ jps -l
2440 LeakDemo

手順2:jstat -gcutil で増え方を見る

jstat -gcutil <pid> 1000 6 は、1000ミリ秒おきに6回、領域ごとの使用率(%)を表示します。

$ jstat -gcutil 2440 1000 6
  S0     S1     E      O      M     CCS    YGC     YGCT     FGC    FGCT     CGC    CGCT       GCT   
     -      -   6.67  40.58  28.68   5.56      1     0.012     1     0.047     0     0.000     0.059
     -      -  46.67  40.58  28.68   5.56      1     0.012     1     0.047     0     0.000     0.059
     -      -  86.67  40.58  28.68   5.56      1     0.012     1     0.047     0     0.000     0.059
     - 100.00  41.67  55.77  28.68   5.56      2     0.019     1     0.047     0     0.000     0.066
     - 100.00   0.00  67.71  28.68   5.56      3     0.023     1     0.047     2     0.001     0.071
     - 100.00  55.56  67.71  28.68   5.56      3     0.023     1     0.047     2     0.001     0.071
列 意味(jstat の公式ドキュメントより)
S0 / S1 Survivor 領域 0 / 1 の使用率(%)
E Eden 領域の使用率(%)
O Old 領域の使用率(%)
M メタスペース(クラスの情報を置く領域)の使用率(%)
CCS 圧縮クラス領域の使用率(%)
YGC / YGCT 若い世代の GC の回数/かかった時間(秒)
FGC / FGCT Full GC の回数/かかった時間(秒)
GCT GC の合計時間(秒)

見るのは O(Old)の列 です。E は GC のたびに 0 近くまで戻りますが、O は 40.58 → 55.77 → 67.71 と上がる一方 です。CGC と CGCT は出力に並ぶ列で、G1 の印付けサイクルの Remark と Cleanup が走るたびに CGC が2ずつ増えていました。なお、jstat は公式ドキュメントに「実験的でサポート対象外」と注記のあるコマンドなので、列の並びはバージョンで変わる前提で使います。

手順3:jcmd GC.class_histogram で原因クラスを特定する

jcmd <pid> GC.class_histogram は、クラスごとのインスタンス数と合計バイト数を、サイズの大きい順に表示します。数秒あけて2回取り、増えているクラスを探します。

1回目(起動から約4秒後):

$ jcmd 2440 GC.class_histogram | head -9
2440:
 num     #instances         #bytes  class name (module)
-------------------------------------------------------
   1:         26586       19367096  [B (java.base@25.0.4.1)
   2:         18000         432000  LeakDemo$HistoryEntry
   3:          1690         223696  java.lang.Class (java.base@25.0.4.1)
   4:          1025         219432  [Ljava.lang.Object; (java.base@25.0.4.1)
   5:          8509         204216  java.lang.String (java.base@25.0.4.1)
   6:           296          90320  [Ljdk.internal.vm.FillerElement; (java.base@25.0.4.1)

2回目(その約6秒後):

   1:         48186       41831096  [B (java.base@25.0.4.1)
   2:         39600         950400  LeakDemo$HistoryEntry
   3:          1025         324824  [Ljava.lang.Object; (java.base@25.0.4.1)
   4:          1688         223456  java.lang.Class (java.base@25.0.4.1)
   5:          8509         204216  java.lang.String (java.base@25.0.4.1)
クラス 1回目 2回目 読み取れること
[B(byte の配列) 26,586個 / 約19MB 48,186個 / 約42MB サイズの大半。誰かが byte 配列を持ち続けている
LeakDemo$HistoryEntry 18,000個 39,600個 21,600個増えた。byte 配列の増加(21,600個)と一致
[Ljava.lang.Object;(Object の配列) 約219KB 約325KB ArrayList の中身の配列が大きくなっている
java.lang.String 8,509個 8,509個 変わらない=リークとは無関係

自分たちのクラス(LeakDemo$HistoryEntry)が、2回の間に同じペースで増え続けている ことから、「HistoryEntry が1個につき byte 配列を1つ持ち、それが ArrayList に溜まっている」と特定できます。コードを見ると、static な HISTORY に add するだけで、remove する処理がありません。

[B や [Ljava.lang.Object; の [ は配列を表す書き方です。上位に来やすいのは byte 配列や String のような汎用のクラスなので、その下にある「自分たちのパッケージのクラス」で、個数が増え続けているものを探す のがコツです。

注意点として、jcmd の公式ドキュメントでは GC.class_histogram の影響度は High(ヒープの大きさと内容による)とされています。今回のログにも、ヒストグラムを取った時刻に次の行が出ていました。

[4.559s][info][gc] GC(1) Pause Full (Heap Inspection Initiated GC) 26M->19M(64M) 47.388ms

ヒストグラムの取得は Full GC を1回起こします。 上の jstat で FGC が 1 になっているのもこのためです。大きなヒープで動いているアプリに対しては、止まってよいタイミングを選んで実行します。

手順4:必要ならヒープダンプを取る

「どこから参照されているか」まで追いたいときは、ヒープダンプを取って解析ツールで開きます。

$ jcmd 5466 GC.heap_dump /home/taro/heap.hprof
5466:
Dumping heap to /home/taro/heap.hprof ...
Heap dump file created [14994999 bytes in 0.033 secs]

(出力先のパスの表示は環境に合わせて置き換えています。)公式ドキュメントによると、GC.heap_dump も影響度は High で、-all を付けない限り Full GC を要求します。GC ログにも Pause Full (Heap Dump Initiated GC) の行が出ました。

直す:上限を決めて、超えたら手放す

import java.util.ArrayDeque;
import java.util.Deque;

public class LeakFixed {
    static class HistoryEntry {
        final long id;
        final byte[] payload = new byte[1024];
        HistoryEntry(long id) {
            this.id = id;
        }
    }

    static final int MAX_ENTRIES = 10_000;                       // 上限を決める
    static final Deque<HistoryEntry> HISTORY = new ArrayDeque<>();

    public static void main(String[] args) throws InterruptedException {
        long id = 0;
        while (true) {
            for (int i = 0; i < 400; i++) {
                HISTORY.addLast(new HistoryEntry(id++));
                if (HISTORY.size() > MAX_ENTRIES) {
                    HISTORY.removeFirst();                       // 上限を超えたら先頭から手放す
                }
            }
            byte[] temp = new byte[256 * 1024];
            temp[0] = 1;
            Thread.sleep(100);
        }
    }
}

同じ条件(-Xmx64m)で動かし、3秒おきに jstat で見た結果です。

$ jstat -gcutil 3484 3000 6
  S0     S1     E      O      M     CCS    YGC     YGCT     FGC    FGCT     CGC    CGCT       GCT   
     - 100.00  12.50  35.84  29.38   5.56      2     0.009     0     0.000     0     0.000     0.009
     - 100.00  35.71  50.28  29.38   5.56      3     0.014     0     0.000     0     0.000     0.014
     - 100.00   0.00  31.52  29.38   5.56      5     0.029     0     0.000     2     0.001     0.029
     - 100.00  64.29  49.16  29.38   5.56      6     0.032     0     0.000     2     0.001     0.032
     - 100.00  33.33  69.39  29.68   5.56      8     0.038     0     0.000     2     0.001     0.038
     - 100.00  35.71  47.73  29.68   5.56     10     0.042     0     0.000     4     0.001     0.043
見るところ 直す前(LeakDemo) 直した後(LeakFixed)
O(Old)の動き 上がる一方 35 → 50 → 31 → 49 → 69 → 47 と上下をくり返す
HistoryEntry の個数 18,000 → 39,600 と増え続ける 10,000個で一定
GC ログの Remark 47M → 47M(減らない) 39M → 17M、41M → 18M(毎回戻る)
FGC 減らない Full GC が連続 0回

メモリリークになりやすい形

形 起きること 対策
static なコレクションに足すだけ アプリが動いている間ずっと残る 上限を決める、不要になったら remove する
キャッシュ用の Map に消す条件が無い キーの種類だけ増え続ける 件数や期限を決めて、使われていないエントリを手放す
リスナーやコールバックの登録だけして解除しない 登録先から参照され続ける 使い終わったら解除する
長生きするオブジェクトのフィールドに一時データを持たせる 持ち主が生きている限り残る ローカル変数にする、使い終わったら参照を外す

どれも「長く生きるものから参照がたどれる」という同じ形です。調べ方も共通で、ログで減らないことを確かめる → jstat で Old の増え方を見る → ヒストグラムを2回取って増えているクラスを探す の順に進めます。

調査の手順チェックリスト

順番 やること 見るポイント
1 -Xlog:gc を付けて動かす Pause Full が続いていないか。回収後の値が上がり続けていないか
2 java -Xlog:gc -version 意図した GC で動いているか(1行目の Using ...)
3 jcmd -l / jps -l 対象のプロセスID
4 jstat -gcutil <pid> 1000 O が上がる一方か、上下しているか。FGC が増えていないか
5 jcmd <pid> GC.class_histogram を2回 個数が増え続けている自分たちのクラス
6 jcmd <pid> GC.heap_dump ファイル名 参照元まで追う必要があるとき
7 -XX:+HeapDumpOnOutOfMemoryError を付けておく 落ちたときのダンプを残す

よくある質問

Q. System.gc() を呼べばメモリは空きますか? A. 参照が外れているオブジェクトは回収されます(今回の例では 56M → 2M)。ただし参照が残っているものは回収されないので、メモリリークの対策にはなりません。通常のアプリでは GC のタイミングは JVM に任せ、ログで様子を確かめるのが基本です。

Q. Full GC が出たら問題ですか? A. 出ること自体は正常な動きの一部です。Serial や Parallel では Old がいっぱいになると Full GC で片づけます。見るのは「Full GC の後に使用量が下がっているか」と「頻度」です。下がらない Full GC が連続していたら、ヒープが足りないか、参照が残り続けています。

Q. G1 と ZGC はどちらを選べばよいですか? A. まずはデフォルトの G1 でログを取り、停止時間が要件に収まっているかを確かめます。収まっていれば変える必要はありません。停止をさらに短くしたいときに ZGC を試し、同じ条件でログを比べて決めます。

Q. ヒープを大きくすればメモリリークは直りますか? A. OutOfMemoryError になるまでの時間が延びるだけで、増え続ける形は変わりません。ヒストグラムで増えているクラスを特定し、参照を手放す処理を入れるのが解決策です。

まとめ

  • GC は「どこからも参照されていないオブジェクト」を自動で回収する仕組み。参照が残っている限り回収されない
  • ログは -Xlog:gc で出せる。69M->32M(247M) 19.321ms は「回収前 → 回収後(ヒープの大きさ)停止時間」
  • Java 25 のデフォルトは、CPU 2つ以上かつメモリ 1792MB 以上なら G1、それ以外は Serial。-Xlog:gc の1行目で確かめられる
  • 同じプログラムで停止時間の合計は Serial 169ms/Parallel 252ms/G1 224ms/ZGC 0.3ms。ZGC は停止が桁違いに短い
  • Java 25 の ZGC は世代別モードのみ。-XX:+ZGenerational は警告が出て無視される
  • メモリリークは「Full GC の後も使用量が下がらない」ログで気づける。今回は 63M → 63M が連続した
  • 原因クラスは jcmd <pid> GC.class_histogram を2回取って、増えているクラスを探す。jstat -gcutil では O の列を見る

参考・出典

  • Java SE 25 HotSpot Virtual Machine Garbage Collection Tuning Guide:Ergonomics、Garbage Collector Implementation、Available Collectors、Garbage-First (G1) Garbage Collector(docs.oracle.com)
  • Java SE 25 ツールリファレンス:jstat、jcmd(docs.oracle.com)
  • JEP 490: ZGC: Remove the Non-Generational Mode(openjdk.org)
  • JEP 521: Generational Shenandoah(openjdk.org)

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Java ガベージコレクションの仕組みとGC種類比較|ログの読み方まで」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?