JVMにおける主なメモリ枯渇シナリオ
Javaアプリケーションで発生するOutOfMemoryError(OOM)は、通常以下の3つの領域で観測されます:- Metaspace領域:クラス定義やメタデータの格納場所
- Java仮想マシンスタック:各スレッドの実行コンテキストを保持
- ヒープ領域:インスタンスオブジェクトと配列の動的確保領域
Metaspace領域の枯渇
デフォルトでは、Metaspaceは無制限に拡張可能ですが、明示的にサイズ制限を設けると容易に枯渇します。典型的な設定例:-XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=256m
主な原因:
- 動的クラス生成ライブラリ(例:CGLIB、Byte Buddy)による過剰なクラスロード(特にキャッシュ無効化時)
- クラスローダーのリーク(同一クラスの重複ロードや未解放参照)
再現コード(CGLIBベース):
import net.sf.cglib.proxy.Enhancer;
import net.sf.cglib.proxy.MethodInterceptor;
public class MetaspaceLeakDemo {
public static void main(String[] args) {
int count = 0;
while (true) {
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(Worker.class);
enhancer.setUseCache(false); // キャッシュ無効 → 毎回新規クラス生成
enhancer.setCallback((MethodInterceptor) (obj, method, argsArray, proxy) -> {
System.out.println("Intercepted: " + method.getName());
return proxy.invokeSuper(obj, argsArray);
});
Worker worker = (Worker) enhancer.create();
worker.process();
if (++count % 100 == 0) {
System.out.println("Generated " + count + " dynamic classes");
}
}
}
static class Worker {
public void process() { /* 処理内容 */ }
}
}
実行時に-XX:MetaspaceSize=32m -XX:MaxMetaspaceSize=32mを指定すると、数秒以内にjava.lang.OutOfMemoryError: Metaspaceが発生します。
スタック領域の枯渇
JVMスレッドごとに割り当てられるスタック領域は、再帰呼び出しや極端に深いネスト呼び出しで簡単に消費されます。デフォルト値と調整:
- Linux/x64環境ではデフォルト約1MB(
-Xss1m) - 高並列スレッド数が必要な場合、
-Xss512kなど適宜縮小可能(ただし過度な縮小はリスク)
再現コード(無限再帰):
public class StackOverflowDemo {
private static int depth = 0;
public static void main(String[] args) {
try {
recurse();
} catch (StackOverflowError e) {
System.err.println("Stack overflow at depth: " + depth);
}
}
private static void recurse() {
depth++;
recurse(); // 終了条件なし → スタック枯渇
}
}
実行時引数:-Xss256kで早期にjava.lang.StackOverflowErrorを誘発できます。
ヒープ領域の枯渇
最も頻発するOOMタイプであり、主に以下のようなパターンで発生します:- 短期間での大量オブジェクト生成(例:バッチ処理中の一括リスト構築)
- 参照保持によるGC回避(静的コレクションへの不適切な追加、コールバック登録後の未解除など)
- 外部サービス障害時のバックプレッシャー(Kafka接続失敗時のメッセージ蓄積など)
再現コード(ヒープ飽和):
import java.util.*;
public class HeapSaturationDemo {
public static void main(String[] args) {
List buffers = new ArrayList<>();
long counter = 0;
while (true) {
buffers.add(new byte[1024 * 1024]); // 1MB配列を追加
if (++counter % 100 == 0) {
System.out.println("Allocated " + counter + " MB");
}
}
}
}
実行時引数:-Xms64m -Xmx64mで数秒でjava.lang.OutOfMemoryError: Java heap spaceを再現可能です。
実運用における典型事例と対応
Kafka連携障害によるメモリ圧迫
リアルタイム処理システムで、Kafkaプロデューサーの送信失敗時に指数バックオフ付きリトライ+結果保持を実装していた場合、Kafkaクラスタ障害時に送信不能なメッセージがメモリ上に蓄積し続けます。さらに、継続的な入力データ処理も並行して行われるため、ヒープ使用率が急上昇します。
緩和策:
- 送信失敗時にローカルディスクへの退避(例:RocksDBまたはシリアライズドファイル)
- 最大保持件数/最大保持時間による制限(TTL付きキュー)
- 非同期送信におけるバックプレッシャー伝搬(例:Reactorの
onBackpressureBuffer制御)
ESログ出力における再帰呼び出し
監視用ログ出力メソッド内で例外をキャッチし、その例外を再度同じメソッドで記録しようとしたケース:
public class LoggingService {
public void writeLog(String message) {
try {
esClient.index(message); // ES書き込み
} catch (Exception e) {
writeLog("ES write failed: " + e.getMessage()); // ⚠️ 再帰呼び出し!
}
}
}
このコードはスタック枯渇を引き起こすだけでなく、エラー状態の継続的増幅にもつながります。
修正案:
- 専用のフェイルセーフロガー(例:ConcurrentLinkedQueue + 単一スレッドフラッシュ)を導入
- 再帰呼び出しを禁止し、代替出力先(例:標準エラー、ローカルファイル)へフォールバック
TomcatスレッドとHTTPバッファのメモリ負荷
Tomcatのmax-http-header-sizeが10MBに設定されており、RPC依存先のタイムアウトが4秒の場合、QPS 100の負荷で最大400スレッドが同時にアクティブになり得ます。各スレッドが2つの10MBバッファ(リクエスト/レスポンス)を確保すると、合計8GBのヒープ使用量となります。
最適化ポイント:
max-http-header-sizeを1MB以下に見直し(実際のヘッダサイズに合わせる)- RPCタイムアウトを1–2秒に短縮し、スレッド滞留時間を抑制
- Tomcatの
maxThreadsとJVMヒープサイズのバランス調整
自動診断と監視戦略
JVM起動パラメータの推奨構成
-Xms4g -Xmx4g
-Xmn2g
-Xss512k
-XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/jvm-dumps/
-XX:+PrintGCDetails -XX:+PrintGCDateStamps
-XX:+UseG1GC -XX:MaxGCPauseMillis=200
OOM時の自動ダンプ分析フロー
HeapDumpOnOutOfMemoryErrorで.hprofファイルを生成- VisualVMまたはEclipse MATで開き、Histogramビューでトップクラスを確認
- Dominator Treeで保持関係をトレースし、ルート参照(GC Roots)を特定
- 必要に応じて
Thread Overviewで問題スレッドのスタックトレースを抽出
運用監視の必須指標
| カテゴリ | 監視項目 | アラート閾値例 |
|---|---|---|
| CPU | 平均ロード(15分) | > 3.5(4コア機) |
| メモリ | ヒープ使用率(CMS/G1 GC後) | > 90% 持続5分 |
| GC | Full GC頻度 | > 3回/分 |
| ディスク | /var/log 使用率 | > 95% |
| アプリケーション | リクエスト遅延P99 | > 2000ms |