結論

ヒープダンプは、OutOfMemoryError の瞬間なら -XX:+HeapDumpOnOutOfMemoryError で自動に、動いているプロセスなら jcmd <pid> GC.heap_dump <ファイル> で取ります。読むときは、先に GC.class_histogram を時間を空けて2回取って「増え続けるクラス」を絞り、ダンプでは「そのオブジェクトを誰が保持しているか(Dominator Tree・Path to GC Roots)」を見ます。

この記事でわかること

  • JDK 21 標準だけでリーク3種(static Map・リスナー・ThreadLocal)を OOM まで再現したエラー原文とダンプ
  • jcmd・jmap・jhsdb・OOM 時の自動取得それぞれの、停止時間とファイル容量の実測
  • MAT を使った読み方(公式の範囲)と、GUI 無しで .hprof の中身を数える JDK だけのリーダー

2026年11月4日更新|OpenJDK 21.0.12.1(Ubuntu 24.04)で実行確認

この記事で実際に動かしたもの:OpenJDK 21 のリークするコード3種、-XX:+HeapDumpOnOutOfMemoryError、jcmd(GC.heap_dump・GC.class_histogram・GC.heap_info・VM.set_flag)、jmap、jhsdb、-Xlog:gc、自作の HPROF リーダー2本です。

メモリリークの調査は、「本当に回収されていないのか」を確かめてから、「何が増えているか」を絞り、最後に「誰が保持しているか」をダンプで突き止める、という順番で進めると、数 GB のダンプを前に途方に暮れずに済みます。

flowchart TD
    A[メモリが増え続ける] --> B{OOM が<br>出た?}
    B -->|出た| C[自動ダンプ<br>HeapDumpOn<br>OutOfMemoryError]
    B -->|まだ| D[GC ログで<br>Full GC 後の<br>床を見る]
    D --> E[class_histogram<br>を2回取り差分]
    E --> F[増えるクラスが<br>あれば heap_dump]
    C --> H[MAT で<br>Dominator Tree<br>Path to GC Roots]
    F --> H

やりたいことから、いちばん早い方法を引けるようにまとめます。

やりたいこと いちばん早い方法 詳しい節
ヒープダンプ 取得(動いているプロセス) jcmd <pid> GC.heap_dump /path/heap.hprof jcmd GC.heap_dump・jmap・jhsdb
OutOfMemoryError の瞬間に取る -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=<dir> HeapDumpOnOutOfMemoryError
起動オプションを付け忘れた本番に後から付ける jcmd <pid> VM.set_flag HeapDumpOnOutOfMemoryError true HeapDumpOnOutOfMemoryError
jmap heap dump の書き方 jmap -dump:live,format=b,file=heap.hprof <pid> jcmd GC.heap_dump・jmap・jhsdb
ダンプを取る前に当たりを付ける jcmd <pid> GC.class_histogram を2回取って差分 GC.class_histogram を2回
java メモリリーク 調査(回収されないことの確認) -Xlog:gc で Full GC 後の使用量が下がらないのを見る GC ログで確かめる
ヒープダンプの読み方(保持元を知る) Eclipse MAT の Leak Suspects → Dominator Tree Eclipse MAT で読む
GUI が使えない所で中身を見る JDK だけで書いた HPROF リーダー(この記事に掲載) GUI 無しで読む
本番で取ってよいか判断する ディスク=ヒープ使用量の約1.1〜1.2倍、停止は数十 ms〜(実測) 本番で取るときの注意

確認環境:Ubuntu 24.04.5 LTS(2 CPU・メモリ 8GB)の上で、OpenJDK 21.0.12.1(Ubuntu のパッケージ。既定の GC は G1)を使い、リークするコード3種を -Xmx64m〜-Xmx256m で実行して OutOfMemoryError を起こしました。ヒープダンプは -XX:+HeapDumpOnOutOfMemoryError、jcmd GC.heap_dump(既定・-all・-gz=1)、jmap -dump:live の4通りで取り、ヒストグラムは jcmd GC.class_histogram・jmap -histo・jhsdb jmap --histo で取りました。取得中の停止時間は、対象プロセスの中で 10ms おきに起きるスレッドが「想定より遅れた最大値」を記録する形で測っています。ダンプの保存先は tmpfs(メモリ上のファイルシステム)なので、書き込み時間は磁気ディスクより短く出ます。.hprof の読み取りは、JDK 標準の API だけで書いた約 80 行のリーダー2本で行いました。Eclipse Memory Analyzer(MAT)と VisualVM はこの環境では起動できないため、画面の操作と用語の定義は公式ヘルプの範囲で書いています。各オプションの意味は Oracle の JDK 21 ツールリファレンス(java・jcmd・jmap・jhsdb)とトラブルシューティングガイドで確かめました(確認日 2026年11月4日)。

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

(記載例)出典:株式会社RJC「Javaヒープダンプの取り方と読み方|jcmd・jmapでメモリリーク調査」

ヒープダンプとは何か、いつ取るか

この節では、ヒープダンプがスレッドダンプや JFR と何が違い、どの場面で取るものかが分かります。

ヒープダンプは、ある瞬間の Java ヒープにあるオブジェクト全部(クラス・フィールドの値・参照関係・GC ルート)を HPROF 形式のファイルに書き出したものです。「どのクラスのオブジェクトが何個あるか」だけでなく「そのオブジェクトを誰が参照しているから回収されないのか」まで辿れるのは、ヒープダンプだけです。Oracle のトラブルシューティングガイドも、メモリリークの調査では「Heap dumps are the most important data」と位置づけています。

一方で、ヒープ全体を書き出すので、ファイルはヒープ使用量と同じ程度の大きさになり、取得中はアプリケーションが止まります。だから「まずヒストグラムで当たりを付け、増え続けるクラスが見えてからダンプを取る」のが、本番向きの順番です。

手段 分かること 負荷・容量 向く場面
GC ログ(-Xlog:gc) GC 後の使用量が下がるか(回収されているか) ほぼ無し。常時出してよい リークかどうかの一次判定
クラスヒストグラム(GC.class_histogram) クラス別のインスタンス数とバイト数 Full GC 1回分の停止(実測 12〜39ms)。出力は数十 KB 増え続けるクラスの特定。2回取って差分
ヒープダンプ(GC.heap_dump) 全オブジェクトと参照関係。保持元まで辿れる Full GC+書き出し。ファイルはヒープ使用量の約1.1〜1.2倍(実測) 保持元の特定。最終手段
スレッドダンプ(Thread.print) 各スレッドが今どこを実行しているか 小さい ハング・デッドロック・CPU 高騰
JFR(JFR.start) 時間軸での割り当て・GC・スレッドの記録 既定設定で約1% 「いつから・どの経路で増えたか」の時系列

スレッドダンプは関連記事「スレッドダンプの取り方と読み方|jstack・jcmdとデッドロック」で、JFR は関連記事「JFR(Java Flight Recorder)入門|記録とjfrコマンド分析」で扱っています。ヒープという領域そのものの位置づけは関連記事「Java ヒープとスタックの違い|変数の保存場所とメタスペースを図解」が入口です。

この記事で動かした、リークするコード3種

この節では、実務で見かけるメモリリークの型3つを、それぞれ最小のコードで確かめられます。

Java のメモリリークは「もう使わないオブジェクトへの参照が、どこかに残り続けること」です。GC は到達できるオブジェクトを回収しないので、参照が残っている限り使用量は増え続けます。以下の3つは、どれも「消す処理を書き忘れた」という同じ形をしています。

1static の Map に溜める

「キャッシュ」のつもりで static な Map に入れ続け、消す処理が無い。クラスが生きている限り全部残る。最も多い型。

2リスナーの外し忘れ

リクエストや画面ごとに作るオブジェクトが、アプリで1つのイベントバスに addListener だけして remove しない。本人は捨てたつもりでも、バスが握っている。

3ThreadLocal の remove 忘れ

スレッドプールのスレッドは終わらないので、ThreadLocal に積んだ値もスレッドと一緒に生き続ける。GC ルートがスレッドなので、ヒストグラムからは見えにくい。

1つ目のコードです。1KB の配列を static の Map に入れ続けます。

import java.util.*;

public class LeakStaticMap {
    // "キャッシュ" のつもりで static に置いた Map。消す処理が無い
    static final Map<String, byte[]> CACHE = new HashMap<>();

    public static void main(String[] args) {
        long start = System.currentTimeMillis();
        for (int i = 0; ; i++) {
            CACHE.put("key-" + i, new byte[1024]);   // 1KB ずつ増え続ける
            if (i % 10000 == 0) {
                Runtime rt = Runtime.getRuntime();
                System.out.printf("%6d ms  entries=%d  used=%d MB%n",
                    System.currentTimeMillis() - start, CACHE.size(),
                    (rt.totalMemory() - rt.freeMemory()) / 1024 / 1024);
            }
        }
    }
}

2つ目は、リクエストごとに作る RequestHandler がコンストラクターで自分をイベントバスに登録し、外さない形です。

import java.util.*;

public class LeakListener {
    interface Listener { void onEvent(String e); }
    static final class EventBus {
        private final List<Listener> listeners = new ArrayList<>();
        void addListener(Listener l) { listeners.add(l); }
        void removeListener(Listener l) { listeners.remove(l); }
        void publish(String e) { for (Listener l : listeners) l.onEvent(e); }
    }
    static final EventBus BUS = new EventBus();          // アプリで1つ

    static final class RequestHandler implements Listener {
        final byte[] buffer = new byte[64 * 1024];       // 64KB の作業領域
        RequestHandler() { BUS.addListener(this); }       // 登録だけして、外していない
        public void onEvent(String e) { }
    }

    public static void main(String[] args) {
        for (int i = 0; ; i++) {
            new RequestHandler();   // 参照を持たないので GC されるはず…だが BUS が持っている
            if (i % 200 == 0) BUS.publish("tick");
        }
    }
}

3つ目は、4本の固定スレッドプールで、タスクが ThreadLocal のリストに 16KB を積み、remove() を呼ばない形です。

import java.util.*;
import java.util.concurrent.*;

public class LeakThreadLocal {
    static final ThreadLocal<List<byte[]>> SCRATCH = ThreadLocal.withInitial(ArrayList::new);

    public static void main(String[] args) throws Exception {
        ExecutorService pool = Executors.newFixedThreadPool(4);
        for (int i = 0; ; i++) {
            pool.submit(() -> {
                SCRATCH.get().add(new byte[16 * 1024]);   // 16KB をスレッドの ThreadLocal に積む
                // SCRATCH.remove(); が無い
            });
            if (i % 1000 == 0) Thread.sleep(20);
        }
    }
}

このほかに、本番のサーバーに似せて「毎秒 300 件のセッションを Map に入れ、消さない」ゆっくり漏れるプロセス(SlowLeak)を -Xmx256m で動かし、jcmd・jmap の実験台にしています。この中には 10ms おきに起きて「想定より遅れた最大値」を出すスレッドを入れ、取得時の停止時間を測りました。

OutOfMemoryError: Java heap space を再現する

この節では、OOM が出るまでの出力と、そのとき GC ログに何が残るかが分かります。

-Xmx64m で LeakStaticMap を動かすと、起動から約 0.3 秒で java.lang.OutOfMemoryError: Java heap space になりました。直前まで 10ms ごとに 10〜12MB ずつ増えています。

$ java -Xmx64m -Xlog:gc:file=gc1.log -XX:+HeapDumpOnOutOfMemoryError \
       -XX:HeapDumpPath=/dev/shm/t243/oom.hprof -cp classes LeakStaticMap
    22 ms  entries=1  used=1 MB
    70 ms  entries=10001  used=12 MB
    82 ms  entries=20001  used=24 MB
    96 ms  entries=30001  used=35 MB
   111 ms  entries=40001  used=47 MB
   130 ms  entries=50001  used=60 MB
java.lang.OutOfMemoryError: Java heap space
Dumping heap to /dev/shm/t243/oom.hprof ...
Heap dump file created [71016728 bytes in 0.050 secs]

Exception: java.lang.OutOfMemoryError thrown from the UncaughtExceptionHandler in thread "main"
$ echo $?
1

このときの GC ログ(-Xlog:gc)の後半です。Full GC を何度やっても 63M->63M(64M) で 1MB も減らない、というのがリークの典型の形です。

[0.173s][info][gc] GC(11) Pause Young (Normal) (G1 Evacuation Pause) (Evacuation Failure) 60M->63M(64M) 3.673ms
[0.187s][info][gc] GC(12) Pause Full (G1 Compaction Pause) 63M->56M(64M) 14.334ms
[0.204s][info][gc] GC(15) Pause Full (G1 Compaction Pause) 63M->61M(64M) 12.716ms
[0.219s][info][gc] GC(17) Pause Full (G1 Compaction Pause) 63M->63M(64M) 13.242ms
[0.234s][info][gc] GC(19) Pause Full (G1 Compaction Pause) 63M->63M(64M) 15.120ms
[0.300s][info][gc] GC(21) Pause Full (G1 Compaction Pause) 63M->63M(64M) 13.540ms
[0.314s][info][gc] GC(22) Pause Full (G1 Compaction Pause) 63M->63M(64M) 14.104ms
[0.326s][info][gc] GC(24) Pause Full (G1 Compaction Pause) 63M->63M(64M) 11.078ms
[0.340s][info][gc] GC(26) Pause Full (G1 Compaction Pause) 63M->63M(64M) 14.830ms

見るべき点は3つです。(Evacuation Failure) は若い世代のコピー先が無くなった印、Pause Full が連続するのは「もう Full GC しか手が無い」状態、63M->63M は回収できる物が無いという意味です。ここまで来てから手を打つより、ずっと前の「Full GC 後の床が上がり続ける」段階で気づくのが理想で、それは後の節で SlowLeak のログで示します。

-XX:+HeapDumpOnOutOfMemoryError で OOM の瞬間に取る

この節では、OOM 時の自動ダンプの設定、既定のファイル名、失敗したときの原文、後付けで有効にする方法が分かります。

調査の基本は、本番の起動オプションに -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=<書き込めるディレクトリ> を最初から入れておくことです。java のリファレンスによれば、この機能は既定で無効で、HeapDumpPath を省くとカレントディレクトリに java_pid<pid>.hprof という名前で作られます。HeapDumpPath にディレクトリを指定したときも、その中に既定の名前で作られました。

$ java -Xmx64m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/dev/shm/t243/dumps -cp classes LeakStaticMap
java.lang.OutOfMemoryError: Java heap space
Dumping heap to /dev/shm/t243/dumps/java_pid3942.hprof ...
Heap dump file created [71089767 bytes in 0.120 secs]
$ ls -l /dev/shm/t243/dumps
-rw------- 1 root root 71089767 Nov  4 03:35 java_pid3942.hprof

ファイルの権限は -rw-------(0600)で作られました。ヒープダンプにはメモリ上の文字列(セッション ID・パスワード・個人情報)がそのまま入るので、この権限は意図どおりです。

指定したディレクトリが無い場合は、OOM は起きたのにダンプだけ失敗します。エラーの原文はこうです。

java.lang.OutOfMemoryError: Java heap space
Dumping heap to /dev/shm/t243/nodir/x.hprof ...
Unable to create /dev/shm/t243/nodir/x.hprof: No such file or directory

オプションを付けずに同じコードを動かすと、残るのは次の1行だけでした。スタックトレースすら出ていません。OOM の後は例外ハンドラーを動かすメモリも残っていないため、「OOM が起きたのにログに何も無い」は珍しくありません。

Exception: java.lang.OutOfMemoryError thrown from the UncaughtExceptionHandler in thread "main"

付け忘れたまま動いている本番には、再起動せずに後から有効にできます。HeapDumpOnOutOfMemoryError と HeapDumpPath は「manageable」なフラグで、jcmd VM.set_flag で書き換えられました。ヒープの上限のような manageable でないフラグは拒否されます。

$ jcmd 1052 VM.set_flag HeapDumpOnOutOfMemoryError true
1052:
Command executed successfully
$ jcmd 1052 VM.set_flag HeapDumpPath /var/log/app/heap.hprof
1052:
Command executed successfully
$ jcmd 1052 VM.flags | tr ' ' '\n' | grep HeapDump
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/app/heap.hprof
$ jcmd 1052 VM.set_flag MaxHeapSize 1000
1052:
only 'writeable' flags can be set

どのフラグが manageable かは java -XX:+PrintFlagsFinal -version で見られます。手元の JDK 21 では、HeapDumpOnOutOfMemoryError・HeapDumpPath・HeapDumpGzipLevel・HeapDumpBeforeFullGC・HeapDumpAfterFullGC が manageable でした。

OOM 後の振る舞いは、ほかのフラグと組み合わせて決めます。

オプション OOM のときにすること 使いどころ
-XX:+HeapDumpOnOutOfMemoryError ダンプを書いてから、例外を投げる(プロセスは続く) ほぼ常に付ける
-XX:HeapDumpPath= 保存先。%p で PID を埋め込める 書き込めて容量のあるディレクトリ
-XX:+ExitOnOutOfMemoryError 直ちに終了する(実測で終了コード 3) 中途半端に生き残るより再起動させたいサービス
-XX:+CrashOnOutOfMemoryError hs_err ログを出してクラッシュ(コアダンプ) コアから jhsdb で調べたいとき
-XX:OnOutOfMemoryError="<cmd>" 最初の OOM で任意のコマンドを実行 通知・ダンプの退避

ExitOnOutOfMemoryError の出力はこの1行で、終了コードは 3 でした。

Terminating due to java.lang.OutOfMemoryError: Java heap space

JVM オプション全般は関連記事「JVMオプション一覧|Xmx・Xms・GCログ設定をJava 21対応で」で扱っています。

jcmd GC.heap_dump・jmap・jhsdb で動いているプロセスから取る

この節では、動いているプロセスからダンプを取る3つの手段と、それぞれの停止時間・ファイル容量の実測が分かります。

JDK 21 で第一候補は jcmd <pid> GC.heap_dump <ファイル> です。Oracle のトラブルシューティングガイドも「jmap ではなく最新の jcmd を使うこと」を推奨しています。PID は jcmd -l か jps -l で調べます。jcmd は、JVM を起動したのと同じ実効ユーザー・グループで、同じマシン上で実行する決まりです(リファレンスの記述)。

$ jcmd -l
1052 SlowLeak 300
$ jcmd 1052 GC.heap_info
1052:
 garbage-first heap   total 86016K, used 64575K [0x00000000f0000000, 0x0000000100000000)
  region size 1024K, 9 young (9216K), 3 survivors (3072K)
 Metaspace       used 788K, committed 1024K, reserved 1114112K
  class space    used 71K, committed 192K, reserved 1048576K
$ time jcmd 1052 GC.heap_dump /dev/shm/t243/dump_live.hprof
1052:
Dumping heap to /dev/shm/t243/dump_live.hprof ...
Heap dump file created [74074228 bytes in 0.055 secs]

real    0m0.197s

GC.heap_dump のオプションは jcmd <pid> help GC.heap_dump で出ます。ここに書かれている「Impact: High」と「-all を付けない限り Full GC を要求する」が、本番で気にする2点です。

$ jcmd 1052 help GC.heap_dump
GC.heap_dump
Generate a HPROF format dump of the Java heap.

Impact: High: Depends on Java heap size and content. Request a full GC unless the '-all' option is specified.

Syntax : GC.heap_dump [options] <filename>

Options: (options must be specified using the <key> or <key>=<value> syntax)
        -all : [optional] Dump all objects, including unreachable objects (BOOLEAN, false)
        -gz : [optional] If specified, the heap dump is written in gzipped format using the given compression level. 1 (recommended) is the fastest, 9 the strongest compression. (INT, 1)
        -overwrite : [optional] If specified, the dump file will be overwritten if it exists (BOOLEAN, false)

同じプロセスで、既定(生きているオブジェクトだけ)・-all・-gz=1 の3通りを続けて取った結果です。

取り方 ファイル容量 JVM が報告した書き出し時間 GC ログに残った停止 アプリ側で観測した最大停止
GC.heap_dump(既定=live) 74,074,228 バイト 0.055 秒 Pause Full (Heap Dump Initiated GC) 11.9ms 48ms
GC.heap_dump -all 79,028,823 バイト(+6.7%) 0.069 秒 Full GC 無し 57ms
GC.heap_dump -gz=1 3,395,965 バイト(1/23) 0.108 秒 Pause Full 1回 97ms
jmap -dump:live,format=b 88,722,718 バイト(ヒープが増えた後) 0.062 秒 Pause Full (Heap Dump Initiated GC) 17.0ms (直前と同じ 97ms)

-gz の圧縮率がここまで高いのは、実験のデータがゼロ埋めの配列だからです。本物のデータではここまで縮まないものの、ダンプの転送先がリモートなら -gz=1 で書き出しておくと移動が楽になります。MAT は gzip 圧縮された .hprof.gz をそのまま開けます(MAT 1.10 以降のリリースノートの範囲)。

同じファイル名で2回目を取ると、-overwrite を付けない限り拒否されます。cron やスクリプトから取るときは、ファイル名に日時を入れるのが確実です。

$ jcmd 1052 GC.heap_dump /dev/shm/t243/dump2.hprof
1052:
Dumping heap to /dev/shm/t243/dump2.hprof ...
Unable to create /dev/shm/t243/dump2.hprof: File exists

jmap は JDK 21 のリファレンスで「experimental and unsupported」と明記されています。書き方は次のとおりで、動きは GC.heap_dump と同じでした(live を付けないと到達できないオブジェクトも入ります)。

$ jmap -dump:live,format=b,file=/dev/shm/t243/jmap_live.hprof 1052
Dumping heap to /dev/shm/t243/jmap_live.hprof ...
Heap dump file created [88722718 bytes in 0.062 secs]

jhsdb は位置づけが違います。リファレンスには「動いているプロセスに attach するとプロセスは停止し、デバッガーが離れるときにクラッシュする可能性がある」と書かれています。実際に jhsdb jmap --histo --pid を動かしたところ、ヒストグラムを数え終わるまでの間、対象プロセスは 12,227ms 止まりました。

$ jhsdb jmap --histo --pid 1052
Attaching to process ID 1052, please wait...
Debugger attached successfully.
Server compiler detected.
JVM version is 21.0.12.1+1-1-24.04.4-Ubuntu
Iterating over heap. This may take a while...
Object Histogram:
...
(対象プロセス側の出力)[probe] max pause so far: 12227 ms

動いている本番から取るとき

避けるjhsdb jmap --binaryheap --pid。ptrace で止めて外から読むため、実測で 12 秒止まった。応答しない JVM やコアダンプ(--exe --core)のための道具
→
代わりにjcmd <pid> GC.heap_dump。JVM 自身が安全点で書き出すため、停止は Full GC + 書き出し(実測 48〜97ms/64MB)で済む

jcmd が反応しない(attach できない)ときに初めて jhsdb か、kill -3・コアダンプを考える

ヒープが 155MB まで増えた時点でもう一度取ると、ファイルは 141,825,790 バイト、書き出しは 0.113 秒、Full GC は 25ms でした。ファイル容量はおおむね「生きているオブジェクトのバイト数 × 1.1〜1.2」と見ておくと、ディスクの見積もりに使えます。

1.15倍ダンプ容量 ÷ ヒープ使用量64MB→74MB、130MB→142MB の実測
48msjcmd 取得時の最大停止(64MB)tmpfs 上。Full GC 12ms を含む
12.2秒jhsdb attach 中の停止111MB のヒープで --histo

GC.class_histogram を時間を空けて2回取り、増え続けるクラスを絞る

この節では、ダンプを取る前にヒストグラムで当たりを付ける手順と、差分の読み方が分かります。

ヒストグラムは「クラス別のインスタンス数と合計バイト数」の一覧で、数十 KB の出力で済みます。1回だけ見ても「byte[] が多い」としか分からないので、数分〜数十分あけて2回取り、増えたクラスを見ます。Oracle のガイドも「2分おきなど一連のヒストグラムを取って傾向を見る」としています。

SlowLeak を起動して 40 秒後と 105 秒後に取った上位です。

$ jcmd 1052 GC.class_histogram | head -8        # 起動 40 秒後
 num     #instances         #bytes  class name (module)
-------------------------------------------------------
   1:         37547       55977320  [B (java.base@21.0.12.1)
   2:         24139         579336  java.lang.String (java.base@21.0.12.1)
   3:          3131         537200  [Ljdk.internal.vm.FillerElement; (java.base@21.0.12.1)
   4:         15545         497440  java.util.concurrent.ConcurrentHashMap$Node (java.base@21.0.12.1)
   5:         13320         426240  SlowLeak$Session

$ jcmd 1052 GC.class_histogram | head -8        # 起動 105 秒後
   1:         68927      121373240  [B (java.base@21.0.12.1)
   2:         31235         999520  java.util.concurrent.ConcurrentHashMap$Node (java.base@21.0.12.1)
   3:         39829         955896  java.lang.String (java.base@21.0.12.1)
   4:         29010         928320  SlowLeak$Session
Total        190558      126200544

2つの出力をクラス名で突き合わせた差分です。[B(byte 配列)が 65MB 増えていますが、それだけでは誰のバッファか分かりません。同じ数だけ増えているクラスを探すと、ConcurrentHashMap$Node と SlowLeak$Session がどちらも「+15,690 個」で、Session が Map に溜まっていることが読めます。

クラス 40 秒後 105 秒後 バイト数の差 読み
[B(byte[]) 37,547 68,927 +65,395,920 増えた本体。誰のバッファかはまだ不明
ConcurrentHashMap$Node 15,545 31,235 +502,080 Map のエントリーが同数増えている
SlowLeak$Session 13,320 29,010 +502,080 アプリのクラス。Node と同数=Map に溜まっている
java.lang.String 24,139 39,829 +376,560 Session の id
java.lang.reflect.Field ほか 13 13 ±0 JDK 内部。増えていなければ無視

ヒストグラムで分かること

どのクラスが何個・何バイトあるか

2回の差分で「増え続けるクラス」

同数で増えるクラスの組(Map の Node と値、など)

ヒープダンプでないと分からないこと

その byte[] を誰のフィールドが参照しているか

GC ルートまでの経路(static か、スレッドか)

1つのオブジェクトが保持している合計(retained)

気をつける点が1つあります。GC.class_histogram は既定で生きているオブジェクトだけを数えるため、Full GC が1回走ります。GC ログには Pause Full (Heap Inspection Initiated GC) として残り、実測で 12〜39ms 止まりました。-all を付けると GC 無しで数えますが、回収待ちのゴミも含まれます。

[44.917s][info][gc] GC(31) Pause Full (Heap Inspection Initiated GC) 64M->56M(95M) 38.661ms

jmap -histo:live も同じ結果を返し、:live を外すと [Ljdk.internal.vm.FillerElement;(G1 が空きを埋めるために置く内部オブジェクト)などのゴミが上位に混ざりました。差分を取るときは、2回とも同じ条件(live か all か)で取ります。

GC ログで「回収されていない」ことを確かめる

この節では、-Xlog:gc の行のどこを見ればリークかどうか判断できるかが分かります。

使用量が増えているだけではリークとは言えません。GC 後の使用量(「→」の右側)が時間とともに上がり続けていれば、回収できないオブジェクトが増えています。-Xlog:gc は負荷がほぼ無く、常時出しておける設定です。

SlowLeak の GC ログから、およそ 10 秒おきに1行ずつ抜き出しました。右辺(GC 後)が 3M → 17M → 31M → 46M → 56M → 68M → 80M → 98M → 105M → 118M と、一度も下がらず上がり続けています。これがリークの形です。

[1.390s]   Pause Young   7M->3M(96M)    3.561ms
[11.874s]  Pause Young   19M->17M(24M)  1.488ms
[23.330s]  Pause Young (Mixed) 37M->31M(47M) 2.231ms
[34.3s]    Pause Young   52M->46M(64M)  3.983ms
[44.917s]  Pause Full (Heap Inspection Initiated GC) 64M->56M(95M) 38.661ms
[55.304s]  Pause Full (Heap Dump Initiated GC) 72M->68M(115M) 12.346ms
[65.339s]  Pause Full (Heap Dump Initiated GC) 91M->80M(137M) 16.972ms
[79.1s]    Pause Young   113M->98M(137M) 5.585ms
[109.004s] Pause Full (Diagnostic Command) 130M->118M(200M) 21.786ms

読み方の要点です。

見る所 健全なとき リークのとき
Full GC 後の使用量(A->B の B) 毎回ほぼ同じ水準に戻る 回を追うごとに上がる(床が上がる)
Full GC の頻度 めったに無い(G1 なら Mixed で足りる) 間隔が縮み、最後は連続する
括弧の中(コミット済み) 安定 -Xmx に向かって増え、張り付く
Young GC の A->B の差 大きく減る(短命なゴミが多い) 減り幅が小さくなる(生き残りが多い)
末期の兆候 — (Evacuation Failure)・63M->63M(64M) の連続

GC ログの行にある Pause Full (Diagnostic Command) は jcmd GC.run、(Heap Dump Initiated GC) は GC.heap_dump、(Heap Inspection Initiated GC) は GC.class_histogram が引き起こした Full GC です。調査の操作自体がログに残るので、後から「この Full GC は自分が起こしたもの」と区別できます。GC の種類とログの詳しい読み方は関連記事「Java ガベージコレクションの仕組みとGC種類比較|ログの読み方まで」で扱っています。

ヒープダンプの読み方(1)Eclipse MAT の Leak Suspects・Dominator Tree

この節では、MAT で .hprof を開いてから保持元に辿り着くまでの順番と、用語の正確な意味が分かります。この節は MAT の公式ヘルプの範囲で書いています。

MAT(Eclipse Memory Analyzer)は、ダンプを開くと「Leak Suspects」というレポートで疑わしい保持元を提示し、Dominator Tree で「誰がどれだけ抱えているか」を見せてくれるツールです。無料で、.hprof と .hprof.gz を直接開けます。

先に、MAT の表に出る2つのサイズの定義を押さえます(公式ヘルプの原文の訳)。

用語 定義 リーク調査での意味
Shallow heap そのオブジェクト1つが消費しているメモリ ヒストグラムの #bytes と同じ見方。Map 本体の shallow は小さい
Retained set X が GC されたときに一緒に取り除かれるオブジェクトの集合 「X を消せば空く物」
Retained heap X の retained set にある全オブジェクトの shallow サイズの合計=X が生かしているメモリ これが大きいオブジェクトが保持元
Dominator(支配) ルートから y への全経路が x を通るとき、x は y を支配する x を切れば y は必ず回収される
Dominator Tree 各オブジェクトを子の直接支配者として並べた木 x の部分木=x の retained set
1

.hprof を開き、Leak Suspects を実行する

File > Open Heap Dump で開く。ツールバーの Run Expert System Test > Leak Suspects で HTML のレポートが出る。レポートはダンプと同じ場所に保存され、次に開いたときも表示される。

公式ヘルプ「Running Leak Suspect Report」

2

Problem Suspect の「Accumulation Point」を読む

多数の小さなオブジェクトのメモリが1つに集まっている点(自身の retained と子の retained の差が大きいオブジェクト)が集積点。この記事の例なら HashMap や ArrayList の配列がここに出る。

Keywords 欄に出るクラス名が検索の手がかり

3

Common Path To the Accumulation Point で GC ルートからの経路を見る

GC ルートから集積点までの最短経路。static フィールド(クラス)なのか、スレッド(ThreadLocal)なのかがここで分かる。アプリ側のどのクラスが参照しているかを推定する手がかりになる。

経路の中に自分のパッケージ名を探す

4

Dominator Tree を Retained Heap の降順で見る

上位に来たオブジェクトを展開すると、抱えている物がクラス別に見える。「Group by class」でクラス単位に、右クリックの「Path To GC Roots > exclude weak/soft references」で弱参照を除いた本当の保持経路に絞る。

Histogram からも同じ操作ができる

5

時間差の2ダンプを比べる

Histogram の Compare で、前のダンプとの差分(増えたクラス)を出す。1枚のダンプで「大きい物」より、2枚の差で「増える物」を見るほうが、キャッシュと漏れを区別しやすい。

ヒストグラムの差分と同じ考え方

MAT 自身もメモリを使います。数 GB のダンプを開くときは、MAT の MemoryAnalyzer.ini の -Xmx をダンプと同程度以上に上げ、本番機ではなく作業用のマシンで開くのが定石です。VisualVM でも .hprof を File > Load で開け、Classes・Instances のビューと「Show Nearest GC Root」で最も近い GC ルートを表示できます(VisualVM 公式ドキュメントの範囲)。

ヒープダンプの読み方(2)GUI 無しで .hprof の中身を数える

この節では、MAT が使えない環境(踏み台サーバー・コンテナの中)で、JDK だけを使ってダンプの中身を確かめる方法が分かります。

.hprof は仕様が公開されたバイナリ形式(HPROF binary format)で、ヘッダー・文字列・クラス定義・ヒープダンプセグメントの並びです。JDK 9 で jhat が削除されて以降、JDK 標準に .hprof を読むコマンドはありませんが、形式は OpenJDK のソース(heapDumper.cpp 冒頭のコメント)に書かれているので、約 80 行の Java で「クラス別のインスタンス数とバイト数」を数えられます。

この記事で書いた1本目 HprofHisto は、ファイルをメモリマップして、INSTANCE DUMP・OBJECT ARRAY DUMP・PRIMITIVE ARRAY DUMP のサブレコードをクラス別に集計します。OOM 時に取れた 71MB のダンプを 0.25 秒で読み、結果はこうでした。

$ java HprofHisto oom.hprof 6
format=JAVA PROFILE 1.0.2 idSize=8 time=2026-11-04 03:35:57 file=71,089,767 bytes
records=42,980 objects=256,780 gc-roots=810 classes=858

 num   #instances   #bytes(dump)  class name
   1      122,413     60,673,275  byte[]
   2       57,809      3,006,068  java.util.HashMap$Node
   3       65,805      2,500,590  java.lang.String
   4          273      1,100,248  [Ljava.util.HashMap$Node;
   5        1,151        570,151  int[]
   6        1,449        299,552  [Ljava.lang.Object;

レコードを読む中心部です。全体は約 80 行で、外部ライブラリは使っていません。

// ヘッダー: "JAVA PROFILE 1.0.2\0" + u4 idSize + u8 timestamp
StringBuilder ver = new StringBuilder();
for (byte c; (c = b.get()) != 0; ) ver.append((char) c);
int id = b.getInt(); long ts = b.getLong();
while (b.hasRemaining()) {
    int tag = b.get() & 0xff; b.getInt(); long len = b.getInt() & 0xffffffffL;
    int end = (int) (b.position() + len);
    switch (tag) {
        case 0x01 -> { /* STRING: id + UTF-8 */ }
        case 0x02 -> { /* LOAD CLASS: serial, classObjId, stackSerial, nameStringId */ }
        case 0x0C, 0x1C -> {            // HEAP DUMP / HEAP DUMP SEGMENT
            while (b.position() < end) {
                int sub = b.get() & 0xff;
                switch (sub) {
                    case 0x20 -> { /* CLASS DUMP: 定数プール・static・インスタンスフィールド定義を読み飛ばす */ }
                    case 0x21 -> { /* INSTANCE DUMP: objId, stackSerial, classObjId, u4 len, bytes */ }
                    case 0x22 -> { /* OBJECT ARRAY DUMP: objId, stackSerial, u4 n, classObjId, n 個の id */ }
                    case 0x23 -> { /* PRIMITIVE ARRAY DUMP: objId, stackSerial, u4 n, u1 type, data */ }
                    default -> { /* 0x01〜0x08, 0xFF は GC ルートのサブレコード */ }
                }
            }
        }
        default -> {}
    }
    b.position(end);
}

HprofHisto の全文です(javac でそのままコンパイルできます)。

import java.io.*;
import java.nio.*;
import java.nio.channels.*;
import java.nio.file.*;
import java.util.*;

/**
 * JDK 標準だけで .hprof(HPROF binary)を読み、クラス別のインスタンス数と
 * ダンプ内のバイト数を集計する最小のリーダー(上位 N 件を表示)。
 * 仕様は OpenJDK の hprof 形式(heapDumper.cpp 冒頭のコメント)に基づく。
 * 目的は「ダンプの中で何が多いか」を GUI 無しで確かめること。保持サイズ(retained)は出さない。
 */
public class HprofHisto {
    public static void main(String[] a) throws IOException {
        Path p = Path.of(a[0]); int top = a.length > 1 ? Integer.parseInt(a[1]) : 15;
        try (FileChannel ch = FileChannel.open(p)) {
            long size = ch.size();
            MappedByteBuffer b = ch.map(FileChannel.MapMode.READ_ONLY, 0, size);
            // ヘッダー: "JAVA PROFILE 1.0.2\0" + u4 idSize + u8 timestamp
            StringBuilder ver = new StringBuilder();
            for (byte c; (c = b.get()) != 0; ) ver.append((char) c);
            int id = b.getInt(); long ts = b.getLong();
            System.out.printf("format=%s idSize=%d time=%tF %<tT file=%,d bytes%n", ver, id, new Date(ts), size);
            Map<Long, String> strings = new HashMap<>();
            Map<Long, String> classNames = new HashMap<>();   // classObjId -> name
            Map<Long, Long> arrayClassOf = new HashMap<>();
            Map<String, long[]> hist = new HashMap<>();
            long records = 0, instances = 0, roots = 0;
            while (b.hasRemaining()) {
                int tag = b.get() & 0xff; b.getInt(); long len = b.getInt() & 0xffffffffL; records++;
                int end = (int) (b.position() + len);
                switch (tag) {
                    case 0x01 -> { long sid = rid(b, id); byte[] s = new byte[(int) (len - id)]; b.get(s); strings.put(sid, new String(s, "UTF-8")); }
                    case 0x02 -> { b.getInt(); long cid = rid(b, id); b.getInt(); long nid = rid(b, id); classNames.put(cid, strings.getOrDefault(nid, "?").replace('/', '.')); }
                    case 0x0C, 0x1C -> {
                        while (b.position() < end) {
                            int sub = b.get() & 0xff;
                            switch (sub) {
                                case 0xFF -> { rid(b, id); roots++; }
                                case 0x01 -> { rid(b, id); rid(b, id); roots++; }
                                case 0x02, 0x03 -> { rid(b, id); b.getInt(); b.getInt(); roots++; }
                                case 0x04, 0x06 -> { rid(b, id); b.getInt(); roots++; }
                                case 0x05, 0x07 -> { rid(b, id); roots++; }
                                case 0x08 -> { rid(b, id); b.getInt(); b.getInt(); roots++; }
                                case 0x20 -> { // CLASS DUMP
                                    rid(b, id); b.getInt(); for (int i = 0; i < 6; i++) rid(b, id); b.getInt();
                                    int cp = b.getShort() & 0xffff; for (int i = 0; i < cp; i++) { b.getShort(); skipVal(b, b.get(), id); }
                                    int st = b.getShort() & 0xffff; for (int i = 0; i < st; i++) { rid(b, id); skipVal(b, b.get(), id); }
                                    int fi = b.getShort() & 0xffff; for (int i = 0; i < fi; i++) { rid(b, id); b.get(); }
                                }
                                case 0x21 -> { // INSTANCE DUMP
                                    int s = b.position(); rid(b, id); b.getInt(); long cid = rid(b, id); int n = b.getInt();
                                    b.position(b.position() + n); add(hist, classNames.getOrDefault(cid, "?"), b.position() - s); instances++;
                                }
                                case 0x22 -> { // OBJECT ARRAY DUMP
                                    int s = b.position(); rid(b, id); b.getInt(); int n = b.getInt(); long cid = rid(b, id);
                                    b.position(b.position() + n * id); add(hist, classNames.getOrDefault(cid, "?"), b.position() - s); instances++;
                                }
                                case 0x23 -> { // PRIMITIVE ARRAY DUMP
                                    int s = b.position(); rid(b, id); b.getInt(); int n = b.getInt(); int t = b.get();
                                    b.position(b.position() + n * tsize(t, id)); add(hist, tname(t), b.position() - s); instances++;
                                }
                                default -> throw new IOException("unknown sub-record 0x" + Integer.toHexString(sub) + " at " + b.position());
                            }
                        }
                    }
                    default -> {}
                }
                b.position(end);
            }
            System.out.printf("records=%,d objects=%,d gc-roots=%,d classes=%,d%n%n", records, instances, roots, classNames.size());
            System.out.printf("%4s %12s %14s  %s%n", "num", "#instances", "#bytes(dump)", "class name");
            List<Map.Entry<String, long[]>> es = new ArrayList<>(hist.entrySet());
            es.sort((x, y) -> Long.compare(y.getValue()[1], x.getValue()[1]));
            int i = 0; for (var e : es) { if (i++ >= top) break; System.out.printf("%4d %,12d %,14d  %s%n", i, e.getValue()[0], e.getValue()[1], e.getKey()); }
        }
    }
    static void add(Map<String, long[]> h, String k, long n) { long[] v = h.computeIfAbsent(k, x -> new long[2]); v[0]++; v[1] += n; }
    static long rid(ByteBuffer b, int id) { return id == 8 ? b.getLong() : (b.getInt() & 0xffffffffL); }
    static int tsize(int t, int id) { return switch (t) { case 2 -> id; case 4, 8 -> 1; case 5, 9 -> 2; case 6, 10 -> 4; case 7, 11 -> 8; default -> throw new IllegalArgumentException("type " + t); }; }
    static void skipVal(ByteBuffer b, int t, int id) { b.position(b.position() + tsize(t, id)); }
    static String tname(int t) { return switch (t) { case 4 -> "boolean[]"; case 5 -> "char[]"; case 6 -> "float[]"; case 7 -> "double[]"; case 8 -> "byte[]"; case 9 -> "short[]"; case 10 -> "int[]"; case 11 -> "long[]"; default -> "?[]"; }; }
}

ここまでならヒストグラムと同じです。2本目の HprofWho は一歩進めて、「1KB 以上の配列を直接参照しているのは、どのクラスのどのフィールドか」を数えます。1回目の走査で CLASS DUMP からフィールドの型の並びを集め、2回目の走査で全インスタンスのオブジェクト型フィールドを辿り、大きな配列の ID に一致した参照元を集計する作りです。3種のリークのダンプに掛けた結果を並べます。

$ java HprofWho oom.hprof 1024 3                    # static Map のリーク
large arrays (>= 1,024 bytes): 56,478 objects, 59,674,133 bytes
      56,423 java.util.HashMap$Node.value
           7 [Ljava.lang.Object; [element]
           6 java.lang.String.value

$ java HprofWho LeakListener.hprof 1024 3           # リスナーの外し忘れ
large arrays (>= 1,024 bytes): 981 objects, 61,739,013 bytes
         938 LeakListener$RequestHandler.buffer
          12 [Ljava.lang.Object; [element]
           5 java.lang.String.value

$ java HprofWho LeakThreadLocal.hprof 1024 3        # ThreadLocal の remove 忘れ
large arrays (>= 1,024 bytes): 3,947 objects, 64,171,989 bytes
       3,909 [Ljava.lang.Object; [element]
           5 java.lang.String.value
           4 java.util.ArrayList.elementData

リスナーの例では、ヒストグラムだと「byte[] が 61MB」としか出なかったものが、「RequestHandler.buffer が 938 個」まで一気に絞れました。static Map の例は「HashMap の値」まで分かり、あとは HashMap を参照している static フィールドを探すだけです。

一方、ThreadLocal の例は「Object[] の要素」で止まり、その Object[] を持つ ArrayList が 4 個(スレッド数と同じ)と分かるところまでです。Thread → ThreadLocalMap → Entry → ArrayList → Object[] → byte[] という経路を一気に見せられるのは、支配木と GC ルートまでの経路を計算する MAT のほうです。この手製のリーダーは「MAT に持っていく前に、サーバー上で当たりを付ける」「1段だけ参照元を見る」ための道具と割り切るのが正しい使い方です。

3種のリークのダンプを読み比べる(原因と直し方)

この節では、同じ「byte[] が上位」でも、保持元の違いがどう見え、それぞれどう直すかが分かります。

ヒストグラムの上位は3つとも byte[] で、ここだけ見ていると区別がつきません。違いは「誰が参照しているか」と「GC ルートが何か」に出ます。

リークの型 ヒストグラムの見え方 参照元(1段) GC ルート 直し方
static Map に溜める byte[] と HashMap$Node が同数で増える HashMap$Node.value クラスの static フィールド 上限付きキャッシュ(LRU)・TTL・WeakHashMap・そもそも static にしない
リスナーの外し忘れ byte[] とアプリのクラスが同数で増える RequestHandler.buffer バスを持つ static/シングルトン try-finally で removeListener・弱参照のリスナー・ライフサイクルに結びつける
ThreadLocal の remove 忘れ byte[] が増えるが、対になるアプリのクラスが無い Object[](ArrayList の中身) プールのスレッド(Thread オブジェクト) タスクの finally で remove()・値を溜める構造にしない
  • 1static のコレクションに「入れる所」と「消す所」が両方あるか消す所が無ければ、上限(LinkedHashMap の removeEldestEntry など)か期限を付ける
  • 2addListener・register・subscribe には対になる remove が、finally か close() で呼ばれているかリクエストの寿命のオブジェクトを、アプリの寿命のオブジェクトに登録するのが典型
  • 3ThreadLocal は、スレッドプールで使うなら必ず finally で remove() しているかWeb サーバーのワーカースレッドは再利用される。値が次のリクエストに漏れる問題も同時に起きる
  • 4キャッシュのキーに equals/hashCode を実装していない独自クラスを使っていないか同じ内容でも別のキーと見なされ、ヒットせずに増え続ける
  • 5直した後、同じ条件でヒストグラムを2回取って「増えなくなった」ことを数字で確かめたかGC ログの Full GC 後の床が水平になれば完了

本番でヒープダンプを取るときの注意

この節では、本番環境で取る前に確かめること(ディスク・停止時間・機密・転送)と、その順番が分かります。

本番で問題になるのは、停止時間よりもディスクと機密のほうが多いです。実測では 130MB のヒープでも停止は 25ms+書き出し 0.11 秒で済みましたが、ヒープが 8GB なら書き出しは秒単位、ファイルは 9GB 前後になります。

  1. 1ディスクの空きを確かめる(ヒープ使用量 × 1.2 以上)jcmd <pid> GC.heap_info の used を見て df -h と比べる。足りなければ -gz=1、または別ボリューム
  2. 2ロードバランサーから外すか、負荷の低い時間を選ぶ停止はヒープの大きさに比例する。ヘルスチェックのタイムアウトより長く止まると切り離される
  3. 3先にヒストグラムを2回取るそれで原因が分かることも多い。ダンプは「保持元が分からないとき」に取る
  4. 4JVM と同じユーザーで jcmd を実行する別ユーザーからは attach できない。コンテナなら docker exec -u <user> か kubectl exec で中から
  5. 5ファイルは機密として扱う0600 で作られる。暗号化して転送し、解析が終わったら消す。チャットやチケットに添付しない
  6. 6本番機で MAT を開かないMAT 自身がダンプと同程度のメモリを使う。作業用のマシンに持ち帰る

コンテナで動かしている場合は、HeapDumpPath をコンテナ内の一時領域にすると、再起動で消えます。永続ボリュームか emptyDir にし、OOM で再起動する前にファイルが残る場所を選びます。また、コンテナのメモリ上限に当たって OS 側に殺される(exit code 137)のは Java の OutOfMemoryError とは別の事象で、ヒープダンプは出ません。

OutOfMemoryError の種類と、ヒープダンプが役立つ範囲

この節では、OOM のメッセージごとに、ヒープダンプで調べられるものと、別の道具が要るものが分かります。

ヒープダンプが直接役立つのは「Java heap space」と「GC overhead limit exceeded」です。Metaspace・ネイティブ・スレッドの OOM は、ヒープの外で起きているので、ダンプを見ても原因は写っていません。

メッセージ 枯渇している所 ヒープダンプ 先に見るもの
Java heap space Java ヒープ 役立つ GC ログ・ヒストグラム → ダンプ
GC overhead limit exceeded Java ヒープ(GC が 98% 以上の時間で 2% 未満しか回収できない状態が続いた) 役立つ 同上。-Xmx 不足か、リークか
Metaspace/Compressed class space クラスメタデータ(ネイティブ) クラスローダーの数は分かる jcmd VM.classloader_stats・VM.metaspace
unable to create native thread OS のスレッド数・スタック用メモリ 役立たない スレッドダンプ・ulimit -u
Direct buffer memory ヒープ外の ByteBuffer DirectByteBuffer の数は分かる -XX:MaxDirectMemorySize・NMT
Requested array size exceeds VM limit 配列1つの大きさが上限超え 不要 スタックトレースの割り当て箇所

メッセージ別の原因と対処は関連記事「Java OutOfMemoryErrorの原因と対処|メッセージ別に解説」で1つずつ扱っています。この記事は、その中の「Java heap space」で止まらずに原因を突き止めるための手順書です。

よくあるつまずきを、原文と一緒にまとめます。

症状・原文 原因 直し方
Unable to create /path/x.hprof: No such file or directory HeapDumpPath のディレクトリが無い 起動前にディレクトリを作る。デプロイ手順に含める
Unable to create /path/x.hprof: File exists 同名のファイルが既にある -overwrite を付けるか、ファイル名に日時を入れる
OOM のログに Exception: java.lang.OutOfMemoryError thrown from the UncaughtExceptionHandler しか無い 例外処理を動かすメモリも残っていない スタックトレースをあてにせず、自動ダンプとヒストグラムで調べる
jcmd が Could not find any processes matching/attach できない 別ユーザーで実行している・別のコンテナ名前空間 JVM と同じユーザーで、同じコンテナの中から実行する
only 'writeable' flags can be set manageable でないフラグを VM.set_flag した 再起動で付ける。manageable かは PrintFlagsFinal で確認
ダンプを取ったらアプリが十数秒止まった jhsdb で attach した/ヒープが大きくディスクが遅い jcmd GC.heap_dump を使う。速いディスクか -gz
MAT で開くと Java heap space で落ちる MAT 側のヒープがダンプより小さい MemoryAnalyzer.ini の -Xmx を上げる
ヒストグラムの上位が全部 JDK のクラス(byte[]・String) それ自体は正常。保持元が見えていないだけ 2回の差分で「同数で増えるアプリのクラス」を探す。無ければダンプ

最後に、どの手段を使うかの判断表です。

状況 取るもの 理由
まだ OOM していないが、使用量が右肩上がり GC ログ → ヒストグラム2回 負荷がほぼ無く、増えるクラスが分かれば十分な手がかり
増えるクラスは分かったが、保持元が分からない jcmd GC.heap_dump(既定の live) ゴミを除いたダンプで MAT の支配木を見る
OOM が夜間に再現し、人が居ない -XX:+HeapDumpOnOutOfMemoryError+ExitOnOutOfMemoryError 自動で証拠を残し、再起動させる
Full GC を起こしたくない GC.heap_dump -all/GC.class_histogram -all GC 無し。代わりに回収待ちのゴミが混ざる
JVM が応答せず jcmd も通らない jhsdb jmap --binaryheap --pid、またはコアダンプ 外から読む。止まる前提の最後の手段
「いつから・どの経路で」増えたか知りたい JFR(JFR.dump path-to-gc-roots=true) 時系列と割り当てスタックが取れる

よくある質問

Q. ヒープダンプの取得方法で、いちばん簡単なものは何ですか? A. 動いているプロセスなら jcmd <pid> GC.heap_dump /path/heap.hprof の1行です。OOM の瞬間に取りたいなら、起動時に -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=<dir> を付けます。

Q. jmap と jcmd はどちらを使うべきですか? A. JDK 21 では jcmd です。jmap はリファレンスで「experimental and unsupported」とされており、Oracle のトラブルシューティングガイドも jcmd を推奨しています。動き自体は同じで、この記事の実測でも停止時間・容量に差はありませんでした。

Q. jmap -dump:live の live は付けるべきですか? A. リーク調査なら付けます(GC.heap_dump の既定と同じ)。付けないと回収待ちのゴミも入り、実測で容量が 6.7% 増え、MAT の集計にノイズが混ざります。Full GC を起こしたくない場合だけ外します。

Q. ヒープダンプを取るとアプリはどれくらい止まりますか? A. ヒープの大きさとディスクの速さに比例します。この記事の実測では 64MB で最大 48ms、130MB で Full GC 25ms+書き出し 0.11 秒でした。数 GB のヒープなら数秒〜数十秒と見込み、ロードバランサーから外してから取ります。

Q. ヒープダンプのファイルはどのくらいの大きさになりますか? A. 生きているオブジェクトのバイト数の 1.1〜1.2 倍が目安です(実測 64MB→74MB、130MB→142MB)。-gz=1 で圧縮して書き出せます。

Q. 起動オプションに HeapDumpOnOutOfMemoryError を付け忘れました。再起動しないと有効にできませんか? A. jcmd <pid> VM.set_flag HeapDumpOnOutOfMemoryError true と VM.set_flag HeapDumpPath <path> で、動いたまま有効にできます。どちらも manageable なフラグです。

Q. OutOfMemoryError が出たのにスタックトレースがログにありません。 A. OOM の後は例外処理を動かすメモリも無く、Exception: java.lang.OutOfMemoryError thrown from the UncaughtExceptionHandler の1行しか残らないことがあります(この記事でも再現)。スタックトレースに頼らず、自動ダンプと GC ログで調べます。

Q. ヒストグラムの1位が byte[] や String ですが、これがリークの原因ですか? A. ほぼ違います。byte[] や String は「中身」で、問題は「それを誰が握っているか」です。時間を空けて2回取り、byte[] と同じ数だけ増えているアプリのクラス(Session・Handler など)を探します。見つからなければヒープダンプで参照元を辿ります。

Q. Eclipse MAT では最初に何を見ればよいですか? A. 開いた直後の Leak Suspects レポートです。Problem Suspect ごとに、集積点(Accumulation Point)と GC ルートからの経路(Common Path To the Accumulation Point)が出ます。そこから Dominator Tree を Retained Heap 順に見ます。

Q. GC が走れば回収されるのでは? リークとキャッシュの違いは何ですか? A. 参照が残っている限り GC は回収しません。上限や期限があり、設計どおりの大きさで止まるのがキャッシュ、止まらずに増え続けるのがリークです。GC ログの「Full GC 後の使用量」が水平になるか、上がり続けるかで見分けます。

Q. コンテナの中で OOM が起きたのにダンプが残りません。 A. 2つ確かめます。HeapDumpPath がコンテナの再起動で消える場所でないか(永続ボリュームにする)、そして本当に Java の OutOfMemoryError か(コンテナのメモリ上限で OS に殺された exit code 137 なら、ヒープダンプは出ません)。

Q. jhsdb はいつ使うのですか? A. JVM が応答せず jcmd も通らないとき、またはコアダンプ(--exe --core)から読むときです。動いているプロセスに attach すると止まります(実測 12 秒)。通常の調査では使いません。

まとめ

  • 取り方は2つ。OOM の瞬間は -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=<dir>、動いているプロセスは jcmd <pid> GC.heap_dump <file>。付け忘れは VM.set_flag で後付けできる。
  • ダンプの前に -Xlog:gc で「Full GC 後の床が上がる」ことと、GC.class_histogram 2回の差分で「増え続けるクラス」を確かめる。byte[] の相棒として同数で増えるアプリのクラスを探す。
  • 実測では、ダンプの容量はヒープ使用量の約 1.15 倍、jcmd の停止は 64MB で最大 48ms。jhsdb の attach は 12 秒止めた。jmap は experimental で、jcmd を使う。
  • 読むときは MAT の Leak Suspects → Dominator Tree → Path to GC Roots。GUI が無い所では、JDK だけで書いた HPROF リーダーで「クラス別の数」と「1段の参照元」まで絞れる。
  • 本番では、ディスク(×1.2)・ロードバランサーからの切り離し・同じユーザーでの実行・0600 のファイルを機密として扱うこと、を先に確かめる。

参考・出典

確認日はいずれも 2026年11月4日です。

  • Oracle「JDK 21 Tool Specifications: The java Command」(-XX:+HeapDumpOnOutOfMemoryError・-XX:HeapDumpPath・-XX:OnOutOfMemoryError・-Xlog):https://docs.oracle.com/en/java/javase/21/docs/specs/man/java.html
  • Oracle「JDK 21 Tool Specifications: jcmd」(GC.heap_dump・GC.class_histogram・VM.set_flag、同一ユーザーの要件):https://docs.oracle.com/en/java/javase/21/docs/specs/man/jcmd.html
  • Oracle「JDK 21 Tool Specifications: jmap」(experimental and unsupported、-dump:live):https://docs.oracle.com/en/java/javase/21/docs/specs/man/jmap.html
  • Oracle「JDK 21 Tool Specifications: jhsdb」(attach すると対象が停止する注意、jmap モード):https://docs.oracle.com/en/java/javase/21/docs/specs/man/jhsdb.html
  • Oracle「Java Platform, Standard Edition Troubleshooting Guide: Troubleshoot Memory Leaks」(OOM のメッセージ別の意味、ヒストグラムとヒープダンプ、jcmd の推奨):https://docs.oracle.com/en/java/javase/21/troubleshoot/troubleshooting-memory-leaks.html
  • OpenJDK「heapDumper.cpp」(HPROF バイナリ形式の仕様コメント):https://github.com/openjdk/jdk/blob/master/src/hotspot/share/services/heapDumper.cpp
  • Eclipse Memory Analyzer「Shallow vs. Retained Heap」:https://help.eclipse.org/latest/topic/org.eclipse.mat.ui.help/concepts/shallowretainedheap.html
  • Eclipse Memory Analyzer「Dominator Tree」:https://help.eclipse.org/latest/topic/org.eclipse.mat.ui.help/concepts/dominatortree.html
  • Eclipse Memory Analyzer「Running Leak Suspect Report」:https://help.eclipse.org/latest/topic/org.eclipse.mat.ui.help/tasks/runningleaksuspectreport.html
  • VisualVM「Browsing a Heap Dump」:https://visualvm.github.io/docs/heapdump.html

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

(記載例)出典:株式会社RJC「Javaヒープダンプの取り方と読み方|jcmd・jmapでメモリリーク調査」

株式会社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分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

成長のヒミツ、 ぜんぶ丸わかり