TL;DR
JVMのオフヒープ(ダイレクト)メモリが不足しています。3つの素早い対処法:
- ダイレクトメモリの上限を引き上げる:
-XX:MaxDirectMemorySize=512m - Nettyの場合、
-Dio.netty.maxDirectMemory=0を追加して、NettyがJVMの上限を使用するようにする(Netty独自の制限ではなく)。 ByteBufferのリークを調査する — ダイレクトバッファはCleanerが発火するまで自動解放されず、負荷下ではアロケーションから数分遅れることがある。
内部で何が起きているのか
ダイレクトバッファはJavaヒープの外に存在します。ByteBuffer.allocateDirect()またはsun.misc.Unsafe.allocateMemory()でアロケートされ、-XX:MaxDirectMemorySizeという別の上限で管理されます。
デフォルトは通常-Xmxと同じですが、JVMのバージョンによっては64 MBと低い場合もあります。上限に達すると以下のエラーが発生します:
Exception in thread "main" java.lang.OutOfMemoryError: Direct buffer memory
at java.base/java.nio.Bits.reserveMemory(Bits.java:175)
at java.base/java.nio.DirectByteBuffer.<init>(DirectByteBuffer.java:118)
at java.base/java.nio.ByteBuffer.allocateDirect(ByteBuffer.java:318)
このエラーを引き起こす4つの状況:
- Nettyの
PooledByteBufAllocatorは起動時にCPUコアごとにダイレクトアリーナを事前アロケートする — 16コアのマシンでは、アプリがリクエストを1件も処理する前に300〜500 MBを消費することがある。 - タイトなループ内でダイレクトバッファをアロケートし、GCによる解放に頼るコード。継続的な負荷下ではGCの実行頻度が十分でない。
- gRPC、Kafkaクライアント、RxNetty — これらはすべて内部でNettyを使用しており、予算に入れていないダイレクトメモリを静かに消費する。
- 2 GBのメモリ制限があるコンテナで、JVMが
MaxDirectMemorySizeを1 GBの-Xmxに合わせてデフォルト設定した場合。メタスペースとスレッドスタックを加えると、すでに上限を超えてしまう。
修正1 — ダイレクトメモリの上限を引き上げる
これが最も素早い対処法です。実際のワークロードに基づいて値を調整してください:
-XX:MaxDirectMemorySize=1g
Spring Boot fatJARの場合:
java -XX:MaxDirectMemorySize=1g -jar app.jar
Kubernetesでは、JVMの起動方法に関わらず適用されるようJAVA_TOOL_OPTIONSで設定します:
env:
- name: JAVA_TOOL_OPTIONS
value: "-XX:MaxDirectMemorySize=512m -Xmx1g"
守るべき1つのルール:ヒープ + ダイレクトメモリ + メタスペース + スレッドスタックがコンテナの制限内に収まる必要があります。これを超えるとKubernetesがPodをOOMKillします — JVMエラーは出ず、サイレントに再起動するだけです。
修正2 — Netty固有のチューニング
NettyのPooledByteBufAllocatorはデフォルトでCPUコアごとに1つのダイレクトアリーナを作成します。高スペックのマシンでは、アプリがリクエストを1件も処理する前に起動だけで400 MBをプリアロケートすることがあります。
オプションA — Netty独自のダイレクトメモリ上限を削除し、JVMフラグですべてを制御する:
-Dio.netty.maxDirectMemory=0
オプションB — ヒープバッファに切り替える。スループットはわずかに低下しますが、メモリモデルがずっとシンプルになります:
// Nettyサーバーのブートストラップ内
ServerBootstrap b = new ServerBootstrap();
b.childOption(ChannelOption.ALLOCATOR, new UnpooledByteBufAllocator(false)); // false = ヒープ
オプションC — ダイレクトバッファを維持しつつ、アリーナ数を減らしてプリアロケーションのフットプリントを縮小する:
-Dio.netty.allocator.numDirectArenas=1
オプションCはI/Oヘビーなサービスに対して最もバランスの取れた選択肢です:ダイレクトメモリのパフォーマンスメリットを維持しながら、起動時のアロケーションを400 MBから約25 MBに削減できます。
修正3 — バッファリークを発見して修正する
上限を引き上げることは時間稼ぎに過ぎません。使用量が継続的に増加してクラッシュが後ずれするだけなら、リークが存在します。
ダイレクトバッファはガベージコレクションに紐付けられたCleanerオブジェクトによって解放されます。コードが参照を保持したり、GCがクリーンアップできる速度より速くアロケートしたりすると、メモリは際限なく増加します。まず再起動せずに、現在実際にアロケートされているものを確認しましょう:
# 起動時に -XX:NativeMemoryTracking=summary が必要
jcmd <pid> VM.native_memory summary scale=MB
# jmapによる旧来のフォールバック
jmap -histo <pid> | grep Direct
NIOレイヤーでのバッファキャッシュを無効にする(Java 9以降、プロファイリング時に有効):
-Djdk.nio.maxCachedBufferSize=0
生のNIOでは、GCを待たずに即時解放を強制します。注意:これは将来のバージョンで変更される可能性のある内部JDK APIを使用しています:
ByteBuffer buf = ByteBuffer.allocateDirect(1024 * 1024);
try {
// bufを使用
} finally {
if (buf instanceof sun.nio.ch.DirectBuffer) {
((sun.nio.ch.DirectBuffer) buf).cleaner().clean();
}
}
Nettyでは、すべてのByteBufを解放する必要があります。release()の呼び出しを1つでも忘れると、そのバッファはプロセスの終了まで存在し続けます:
ByteBuf buf = ctx.alloc().directBuffer(1024);
try {
// bufに書き込み、パイプラインに渡す
} finally {
buf.release(); // 参照カウントをデクリメント;refCntが0になると解放される
}
ステージング環境でNettyのリーク検出器を有効にしましょう。本番環境ではコストが高いですが、リークがどこでアロケートされたかを正確に追跡するのに非常に有効です:
-Dio.netty.leakDetection.level=PARANOID
リークはログにLEAK: ByteBuf.release() was not called before it's garbage-collectedとして表示され、アロケーション箇所を指す完全なスタックトレースが含まれます。
修正4 — GCの頻度を強制的に増やす(応急処置のみ)
今すぐコードを変更できない場合、JVMにより積極的にガベージコレクションを実行するよう指示できます。これにより、参照されていないダイレクトバッファのCleanerコールバックがより早く発火します:
-XX:+ExplicitGCInvokesConcurrent -XX:MaxGCPauseMillis=50
あるいは、バックグラウンドスレッドからSystem.gc()を呼び出す方法もあります。見た目は悪く、根本原因を修正するものでもありませんが、深夜3時のオンコールエンジニアを何度も救ってきた方法です:
ScheduledExecutorService cleaner = Executors.newSingleThreadScheduledExecutor();
cleaner.scheduleAtFixedRate(System::gc, 0, 30, TimeUnit.SECONDS);
これはホットフィックスとして扱ってください。次のスプリント内に本当の修正 — 明示的な解放またはメモリ予算の増加 — を実装してください。
修正の確認
変更を適用したら、現実的な負荷下でダイレクトメモリを監視します。上昇し続けるのではなく、横ばいになるはずです:
# 起動時に -XX:NativeMemoryTracking=summary が必要
watch -n 2 'jcmd $(pgrep -f app.jar) VM.native_memory summary scale=MB | grep -A5 "Internal"'
# JMX経由 — NativeMemoryTrackingなしで動作
# MBean: java.nio:type=BufferPool,name=direct
# 属性: Count, MemoryUsed, TotalCapacity
Prometheus + JMXエクスポーターを使用している場合、このメトリクスをグラフ化します:
java_nio_buffer_pool_memory_used_bytes{pool="direct"}
正常なアプリはウォームアップ後にフラットな線を示します。リークがあるとスキーの斜面のような形になります — 次のクラッシュまで右肩上がりに増加し続けます。
PARANOIDリーク検出を有効にして実行した場合、クリーンな実行ではLEAK: ByteBuf.release() was not calledの行がゼロになるはずです。1件でも出た場合は調査する価値があります。
クイックリファレンス
- 即時対処:
-XX:MaxDirectMemorySize=512m(またはそれ以上) - Netty起動時の肥大化:
-Dio.netty.allocator.numDirectArenas=1 - リークの発見:
-Dio.netty.leakDetection.level=PARANOID+ 常にbuf.release()を呼び出す - 監視:JMX MBean
java.nio:type=BufferPool,name=directまたはPrometheusjava_nio_buffer_pool_memory_used_bytes{pool="direct"}

