Javaヒープは、JVMが管理し、newで作ったオブジェクトと配列を置くメモリ領域です。上限は-Xmxで決まります。指定しなければ、HotSpot(OpenJDK系の標準JVM)は物理メモリ(コンテナならメモリ制限)の25%を基準に自動で決め、約125MB以下の小さなヒープになる環境では50%を使います。上限まで埋まって新しいオブジェクトを置けないとjava.lang.OutOfMemoryError: Java heap spaceが出ます。本記事では、Temurin 25.0.4.1で既定値・エラー・ヒープダンプを実際に動かして測った結果をもとに、設定と対処の手順を説明します。
まとめ:Javaヒープの要点
- Javaヒープはオブジェクトと配列の置き場所で、全スレッドが共有し、ガベージコレクション(GC)が回収する。通常のスレッド(プラットフォームスレッド)のスタック・メタスペース・JIT済みコードはヒープの外にあり、
-Xmxの上限には含まれない。 - 既定の最大ヒープは物理メモリの25%、初期ヒープは1.5625%(1/64)が基準で、メモリが小さい環境では最大が50%になる。16GBのMacでは最大4GiB・初期256MiB、メモリ制限512MBのコンテナでは最大128MiB、200MBでは100MiBだった。
- 設定は
-Xms(初期)と-Xmx(最大)。コンテナでは-XX:MaxRAMPercentageで制限に対する割合を指定する。 Java heap spaceが出たら、-XX:+HeapDumpOnOutOfMemoryErrorでダンプを取り、大きなメモリを握っているオブジェクトの保持元をたどって、不要な参照が残っている(リーク)のか、処理に必要な量が上限を超えた(サイズ不足)のかを切り分ける。-Xmxをコンテナの制限より大きくしておくと、ヒープが上限に届く前にプロセス全体の使用量が制限を超え、OutOfMemoryErrorではなくカーネルに強制終了される(実測で終了コード137・OOMKilled=true)。例外もヒープダンプも残らない。
Javaヒープとは:JVMのメモリ領域での位置づけ
JVMプロセスのメモリは、用途ごとに別の領域に分かれています。どの設定がどの領域に効くかを先に押さえておくと、エラーメッセージから原因の領域を絞れます。JVM全体の構成はJava仮想マシン(JVM)とは?仕組み・メモリ構成・JITを実機の出力で解説で扱っています。
| 領域 | 置かれるもの | 共有範囲 | 上限の指定 | 枯渇したとき |
|---|---|---|---|---|
| Javaヒープ | オブジェクト・配列 | 全スレッド | -Xmx | OutOfMemoryError: Java heap space |
| スレッドスタック | フレーム・ローカル変数 | スレッドごと | -Xss | StackOverflowError |
| メタスペース | クラスのメタデータ | プロセス全体 | -XX:MaxMetaspaceSize | OutOfMemoryError: Metaspace |
| その他のネイティブメモリ | JIT済みコード・GC管理領域など | プロセス全体 | -Xmxの対象外 | プロセスごと終了 |
ヒープとスタックの違い
通常のスレッド(プラットフォームスレッド)のスタックはスレッドごとに独立していて、メソッドを呼ぶたびにフレームを積み、メソッドから戻ると破棄します。ローカル変数がプリミティブ型なら値がスタックに載り、オブジェクトなら参照だけがスタックに載って実体はヒープに置かれます。User u = new User();なら、uという参照はスタック、Userの中身はヒープです。深い再帰でスタックが尽きるとStackOverflowError、ヒープが尽きるとOutOfMemoryErrorなので、エラーの型でどちらの領域かが分かります。スタックの大きさは-Xssで決まり、手元のmacOS(x64)では既定のThreadStackSizeが1024KBでした。なお、Java 21で正式化された仮想スレッドは例外で、JEP 444によるとスタックはJavaヒープ上のオブジェクト(stack chunk)として置かれます。仮想スレッドを大量に使うアプリでは、待機中のスレッドの分もヒープを使います。
メタスペース・Cヒープはヒープの外
Java 8でPermGen(永久世代)が廃止され、クラスのメタデータはメタスペースへ移りました。メタスペースはJavaヒープではなくネイティブメモリに置かれ、JDK 25のGCチューニングガイドによると、上限は既定では設けられていません(MaxMetaspaceSizeの既定値は実測でも18446744073709551615=実質無制限でした)。JVM本体がC/C++のmallocで取るメモリは「Cヒープ」や「ネイティブヒープ」と呼ばれ、JIT済みコードを置くコードキャッシュやGCの管理領域なども含めてJavaヒープの外にあります。どれも-Xmxの上限には含まれません。ただし、GCの管理領域のようにヒープの大きさに応じて増えるものもあるため、ヒープを大きくするとヒープ外の使用量も多少増えます。ネイティブ側の内訳はNative Memory Tracking(NMT)とは?jcmdでJVMのネイティブメモリを調べる手順で調べられます。
ヒープの世代構造とガベージコレクション
多くのオブジェクトは作られてすぐ不要になります。GCはこの性質を使い、ヒープを若い世代(新生代)と古い世代(老年代)に分けて、若い世代を頻繁に、老年代をまれに回収します。
新生代・老年代の流れ
newしたオブジェクトは、通常まず新生代のEdenに置かれます(G1でリージョンの半分以上を占める巨大オブジェクトだけは、最初から老年代に置かれます)。Edenが埋まると新生代だけを回収するGC(Young GC、Minor GC)が走り、生き残ったものはSurvivor領域へ移ります。何度か生き残ったものは老年代へ昇格し、老年代まで回収するGCは停止時間が長くなりがちです。jstat -gcの出力では、EC/EUがEdenの容量と使用量、OC/OUが老年代、MC/MUがメタスペースに当たります。GCの種類とログの読み方はJavaのガベージコレクション(GC)とは|仕組み・種類・ログ確認とチューニング手順にまとめています。
既定のGC:G1とSerialの選ばれ方
既定のGCは常にG1ではありません。JDK 25のエルゴノミクスの説明では、プロセッサが2個以上かつメモリが1792MB以上の「サーバークラスマシン」ならG1、それ以外ならSerial GCを選びます。コンテナでメモリ制限を512MBにするとUseSerialGCが選ばれ、2GBにするとG1に戻りました。小さなコンテナで停止時間が想定より長いときは、まずどちらのGCで動いているかを-XX:+PrintFlagsFinalで確かめてください。
System.gc()の回収保証と停止時間への影響
System.gc()のJavadocには、回収するオブジェクトの数や解放する量を保証しないと書かれています。G1では呼ぶたびにヒープ全体のGCを要求することになり、本番コードに置くと停止の原因になります。ライブラリが呼んでしまう場合は、-XX:+DisableExplicitGCで明示的なGC要求を無視させます。メモリが減らないときは、GCを強制するより、どこかに参照が残っていないかを疑うのが先です。
ヒープサイズの既定値:JDK 25での実測
ヒープサイズの既定値は、JVMの実装と版、JVMから見えるメモリ量、起動オプションで変わります。ここではJDK 25のHotSpotを対象にします。JDK 25のjavaコマンドのリファレンスでは、MaxRAMPercentage(既定25%)で最大ヒープ、InitialRAMPercentage(既定1.5625%=1/64)で初期ヒープを算出し、約125MB以下の小さなヒープにはMinRAMPercentage(既定50%)を使います。基準のメモリ量にはコンテナの制限も反映されます。
物理メモリ16GBのMacの場合
次のコマンドで、指定なしで起動したときの値が分かります。
java -XX:+PrintFlagsFinal -version | grep -E " (MaxHeapSize|InitialHeapSize|MaxRAMPercentage|UseG1GC) "
size_t InitialHeapSize = 268435456 {product} {ergonomic}
size_t MaxHeapSize = 4294967296 {product} {ergonomic}
double MaxRAMPercentage = 25.000000 {product} {default}
bool UseG1GC = true {product} {ergonomic}
最大4GiB(16GBの25%)、初期256MiB(16GBの1/64)です。{ergonomic}はJVMが環境から自動で決めた値、{command line}なら起動オプションで指定した値という意味で、設定が効いているかの確認にも使えます。
Dockerのメモリ制限ごとの既定値
同じイメージ(eclipse-temurin:25-jre。検証時の中身はTemurin 25.0.4.1+1で、タグの中身は更新されます)を、colima上のDocker(4CPU・6GB)でdocker run --memory=...の制限を変えて起動し、-XX:+PrintFlagsFinalの値を比べました。
| メモリ制限 | 最大ヒープ | 初期ヒープ | 選ばれたGC |
|---|---|---|---|
| 200MB | 100MiB(50%) | 8MiB | Serial |
| 512MB | 128MiB(25%) | 8MiB | Serial |
| 2GB | 512MiB(25%) | 32MiB | G1 |
| 制限なし(VM 6GB) | 約1.45GiB(25%) | – | G1 |
512MBのコンテナで既定のまま動かすと、Javaヒープの上限は128MiBです。残りの約384MBはメタスペースやスレッドスタックにも使われますが、一般的なアプリでは大半が余ります。200MBで50%になったのは、小さなヒープではMinRAMPercentage(既定50%)側の算定が使われるためです。コンテナのメモリ制限を上げたのに最大ヒープが変わらないときは、起動スクリプトや環境変数JAVA_TOOL_OPTIONSに固定の-Xmxが残っていないかを-XX:+PrintFlagsFinalの{command line}表示で確かめます。
ヒープサイズを設定する:-Xms・-Xmx・MaxRAMPercentage
サイズを変える前に、上のPrintFlagsFinalや次章のjcmdで現在値と実際の使用量を測ります。足りているのにエラーが出ている場合、サイズを増やしても直りません。
-Xms・-Xmx・-Xssの指定
-Xmsはヒープの初期サイズ(兼 最小サイズ)、-Xmxは最大サイズ、-Xssはスレッドスタックのサイズで、いずれもk・m・gの単位を付けられます。
# 初期512MiB・最大2GiB、ヒープ枯渇時にダンプを /var/dumps へ出力
# (/var/dumps は事前に作成し、実行ユーザーの書き込み権限を付けておく)
java -Xms512m -Xmx2g \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/var/dumps \
-jar app.jar
-Xmsを-Xmxと同じ値にすると、起動直後から上限まで確保して、ヒープの拡張・縮小が起きなくなります。応答時間を安定させたいサーバーでは同じ値に、メモリを他のプロセスと分け合う開発機では-Xmsを小さく、と使い分けます。-Xmxと-XmsはJVMの起動時にしか効かないため、Eclipse本体のヒープはeclipse.iniの-vmargs以降に、Eclipseから起動する自分のプログラムのヒープは実行構成の「VM引数」に書き、アプリケーションサーバーでは起動スクリプトのJAVA_OPTSなどに書きます。
コンテナのMaxRAMPercentage設定とヒープ外の余裕
コンテナでは-Xmxを固定値で書くと、メモリ制限を変えたときに書き直しが必要です。-XX:MaxRAMPercentageなら制限に対する割合で決まります。512MBのコンテナに-XX:MaxRAMPercentage=75を付けると最大ヒープは384MiBになり、既定の128MiBではJava heap spaceで止まった約300MBの確保が通りました。残りの25%はメタスペース、スレッドスタック、JIT済みコードなどヒープ外の分です。スレッド数が多いアプリやネイティブライブラリを使うアプリでは、75%でも足りなくなることがあります。Spring BootをDockerで配布するときの起動オプションの書き方はSpring BootのDockerデプロイ手順|イメージ作成3方式・Compose・本番配布を4.1.1で検証で扱っています。業務システムを新しく作る段階で、メモリの見積もりと上限の設計まで含めて任せたい場合は、フルスクラッチ開発でご相談いただけます。
ヒープの使用量を確認する:jcmd・jstat・Runtime
稼働中のプロセスはjcmdで調べます。jcmdを引数なしで実行すると、動いているJVMのプロセスIDが一覧で出ます。jcmdは対象のJVMと同じユーザーで、同じ版のJDKのものを使います。最大512MiBで起動し、1MiBの配列を50個持たせたプロセスで次のように表示されました。
$ jcmd <pid> GC.heap_info
garbage-first heap total reserved 524288K, committed 168960K, used 103587K
region size 1024K, 2 young (2048K), 1 survivors (1024K)
$ jstat -gc <pid> 1000 # 1秒ごとに世代別の容量・使用量とGC回数を表示
$ jcmd <pid> GC.class_histogram | head -5
num #instances #bytes class name (module)
-------------------------------------------------------
1: 1834 52507880 [B ([email protected])
total reservedが-Xmxで予約した上限、committedがJVMがOSにコミットした量(物理メモリに載っている量とは限らない)、usedが使用中の量です。GC.class_histogramはクラスごとの個数とバイト数を大きい順に並べ、[Bはbyte[]を指します。ここで、配列の合計は約50MiBなのにusedは約101MiBと倍になっています。理由は後述のG1の巨大オブジェクトです。GC.class_histogramはヒープ全体を走査するので、大きなヒープの本番プロセスでは実行中にアプリが止まる時間が出ます。プログラムの中からは、Runtime.getRuntime().maxMemory()で最大ヒープ、totalMemory()で確保済みの量、freeMemory()でその中の空きをバイト単位で取得できます。
「OutOfMemoryError: Java heap space」の原因と対処
java.lang.OutOfMemoryErrorは、コロンの後ろのメッセージで足りなくなった領域が分かります。-Xmx64mで小さなプログラムを動かし、メッセージを出し分けました。
OutOfMemoryErrorのメッセージ別の原因
| メッセージ | 起きたこと | -Xmxを増やして直るか |
|---|---|---|
| Java heap space | ヒープにオブジェクトを置けない | サイズ不足なら直る |
| Requested array size exceeds VM limit | 配列の要素数がVMの上限超え | 直らない |
| GC overhead limit exceeded | GCに時間を使い回収が進まない | リークなら直らない |
| Metaspace | クラスのメタデータの領域が枯渇 | 直らない |
| unable to create native thread | OSのスレッドを作れない | 直らない |
1MiBの配列をリストに足し続けるとJava heap space、64MBのヒープで100MBの配列を1つ確保しても同じJava heap spaceでした。一方、new int[Integer.MAX_VALUE]は空きに関係なくRequested array size exceeds VM limitになります。JDK 25のトラブルシューティングガイドは、この2つを「ヒープに置けなかった」と「VMの実装上限を超える大きさを要求した」と区別しています。
GC overhead limit exceededは、GCに時間の約98%を使っても2%未満しか回収できない状態が5回続いたときのエラーです。JDK 25のGCチューニングガイドでこの判定が書かれているのはParallel GCです。HashMapを埋め続けるプログラムを-Xmx64mで動かしたところ、Parallel・G1・Serialのいずれでも結果はJava heap spaceでした。既定のG1で動いているなら、まずJava heap spaceの対処を考えれば足ります。
ヒープダンプを取る:出力先の落とし穴
どのオブジェクトが何に保持されているかまで調べるには、ヒープダンプ(ヒープの中身を書き出した.hprofファイル)を使います。-XX:+HeapDumpOnOutOfMemoryErrorを付けておくと、ヒープの枯渇でOOMが起きた瞬間に自動で書き出されます。javaコマンドのリファレンスでは、Javaコードが自分で投げたOOMや、スレッドなど他の資源の不足によるOOMは対象外とされています。稼働中のプロセスからはjcmd <pid> GC.heap_dump /tmp/heap.hprofで取れますが、既定では到達可能なオブジェクトだけを残すためにフルGCを伴うので、本番では負荷の低い時間に取ります。JDK 25のコマンドラインオプションの説明では、出力先の既定は作業ディレクトリのjava_pid<pid>.hprofで、-XX:HeapDumpPathでファイル名かディレクトリを指定できます。-Xmx64mで出力先の指定を変えると、次の結果になりました。
| HeapDumpPathの指定 | 結果 |
|---|---|
| 指定なし | 作業ディレクトリに java_pid50374.hprof(約35MB) |
| 既存のディレクトリ | その中に java_pid50370.hprof |
| 存在しないパス | そのパス名のファイルとして作成 |
| 既存のファイル | Unable to create …: File exists で出力されない |
ディレクトリを作り忘れると「dumps」という名前のファイルが1つできるだけで、2回目以降のOOMでは上書きされず、ダンプが残りません。HeapDumpPathには作成済みのディレクトリを指定し、ディスクにヒープの上限と同じくらいの空きを確保しておきます。
ヒープダンプによるリークと容量不足の判別
取得したダンプをEclipse Memory Analyzer(MAT)などで開き、Dominator Tree(保持しているメモリが大きい順の一覧)を見ます。特定のListやMap、キャッシュが大半を握っていて、同じくらいの負荷で時間をおいて取ったダンプでも増え続けていれば、その保持元(どのフィールドから参照されているか)をたどります。処理が終わった後も不要なオブジェクトへの参照が残っていればリークです。この場合は-Xmxを増やしてもエラーの時期が遅れるだけなので、不要になった要素を消す、キャッシュに件数の上限を付ける、といった形で参照を切ります。リークの見つけ方はメモリリークとは?原因の特定方法と直し方を4言語の再現コードで解説で、再現コードを使って説明しています。一方、大きなファイルを一度に読み込む処理などでピーク時だけ足りないなら、jstat -gcやGCログでその処理の最中のヒープ全体の使用量(新生代と老年代の合計)とGC直後に残る量を測り、ピークに余裕を足して-Xmxを上げます。
1MiBの配列が2倍のヒープを使う:G1の巨大オブジェクト
ヒープの合計より少ない量しか入らないうちにエラーになる場合は、配列の大きさを確認してください。G1のドキュメントでは、リージョン(G1がヒープを分割する単位)の半分以上の大きさのオブジェクトを巨大オブジェクト(humongous object)として、連続した複数のリージョンにまとめて置き、最後のリージョンの余りは他に使わないと説明しています。-Xmx64m(リージョンは1MiB)で同じ量を詰め込み続けると、次のようになりました。
| 1個あたりの配列 | OOMまでに入った量 |
|---|---|
| byte[1,048,576](1MiB) | 31MiB |
| byte[400,000](約390KiB) | 48MiB |
ちょうど1MiBの配列は、オブジェクトのヘッダの分だけ1リージョンからはみ出し、2リージョンを使います。ヒープの約半分が使われないまま埋まるわけです。大きなバッファを多数持つ処理では、配列を分割するか、-XX:G1HeapRegionSizeでリージョンを大きくすると改善します。
ヒープを大きくしすぎたとき:コンテナでは例外なしで強制終了
「ヒープを大きくすれば安全」とは限りません。-Xmxを指定しただけでは何も起きませんが、実際の使用量がメモリ制限に届くと話が変わります。メモリ制限512MB(スワップなし)のコンテナで-Xmx1gを指定し、配列を書き込みながら700MB確保させると、400MBを過ぎたところでプロセスが消えました。docker inspectの結果はExitCode=137、OOMKilled=trueで、カーネルのOOM Killerに終了させられたことが分かります。終了コード137はSIGKILLで終わったことを示すだけなので、OOMかどうかはOOMKilled(KubernetesならReason: OOMKilled)で確かめます。JVMから見ればまだヒープに空きがあるので、OutOfMemoryErrorは投げられず、-XX:+HeapDumpOnOutOfMemoryErrorを付けていてもダンプは残りません。ログに何も出ないままPodが再起動を繰り返すときは、-Xmxとヒープ外の分の合計がメモリ制限を超えていないかを確認してください。
逆に、OOMが起きたらすぐ終わらせて外側から再起動させたい場合は、-XX:+ExitOnOutOfMemoryErrorを付けます。実測ではTerminating due to java.lang.OutOfMemoryError: Java heap spaceと出力して終了コード3で終わり、オプションなしのとき(例外がmainを抜けて終了コード1)と区別できました。OOMの後は、どのスレッドの処理が中途半端に止まったか分からないため、Kubernetesなどで動かすサーバーはこちらで終わらせる方が安全です。
よくある質問
Javaのヒープサイズのデフォルトはいくつですか?
JDK 25のHotSpotでは、最大ヒープはJVMから見えるメモリの25%、初期ヒープは1/64が基準で、約125MB以下の小さなヒープでは最大が50%になります。実測では、物理16GBのMacで最大4GiB・初期256MiB、メモリ制限512MBのコンテナで最大128MiBでした。実際の値はjava -XX:+PrintFlagsFinal -versionのMaxHeapSizeで確認できます。
Javaのヒープメモリの上限はいくつまで指定できますか?
-Xmxには物理メモリを超える値も書けますが、実際に使えるのは、OSやコンテナがプロセスに許すメモリからメタスペースやスレッドスタックなどヒープ外の分を引いた量までです。コンテナで制限を超える値を指定し、実際の使用量が制限に届くと、OutOfMemoryErrorではなくOOM Killerによる強制終了(終了コード137、OOMKilled)になります。
ヒープダンプはどこに出力されますか?
指定がなければJVMの作業ディレクトリにjava_pid<pid>.hprofという名前で出ます。-XX:HeapDumpPathで変えられますが、存在しないパスを書くとディレクトリではなくその名前のファイルが作られ、同名のファイルがあると上書きされずに出力されません。
Javaでメモリを強制的に解放できますか?
できません。System.gc()はGCを要求するだけで、解放する量は保証されません。オブジェクトはどこからも参照されなくなったときに回収対象になるので、使い終わったコレクションの要素を消す、キャッシュに上限を付けるなど、参照を切る方法で解放します。
「Java heap space」と「Metaspace」のエラーはどう違いますか?
Java heap spaceはオブジェクトを置くJavaヒープの不足で、-Xmxの見直しやリークの調査で対処します。Metaspaceはクラスのメタデータを置くヒープ外の領域の不足で、-Xmxを増やしても直りません。動的にクラスを生成し続ける処理や、再デプロイのたびにクラスローダーが残る構成を疑います。