java.lang.OutOfMemoryError: Direct buffer memoryをJava NIOとNettyで修正する方法

intermediate Java2026-05-08| Java 8〜21、Linux/Windows/macOS、java.nio.ByteBuffer.allocateDirect()、Netty、Grizzly、またはNIOベースのフレームワークを使用するアプリケーション

Error Message

java.lang.OutOfMemoryError: Direct buffer memory
#java#nio#netty#メモリ#ダイレクトバッファ#オフヒープ

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またはPrometheus java_nio_buffer_pool_memory_used_bytes{pool="direct"}

Related Error Notes