StackTraces
Java已驗證

Java OutOfMemoryError Direct buffer memory 排查

@debug_master32 天前更新閱讀 6 min00

這是最容易誤判的一類 OOM:堆沒滿、-Xmx 還有餘量,進程卻報記憶體不足。因為直接記憶體不在堆裡,GC 根本管不到它。報錯裡的 used/max 是堆外緩衝池,不是 -Xmx,盯著堆調試只會越走越偏。

Issue & Env(案發現場與環境)

  • 現象:網關或調用外部接口的服務,流量上去後偶發 500,日誌裡出現 OutOfDirectMemoryError,隨後實例健康檢查例外。
  • 環境:Spring Cloud Gateway / 任何用 reactor-netty 或 NIO 的服務,底層大量使用 ByteBuffer.allocateDirect 分配堆外記憶體。

StackTrace(堆疊日誌)

Netty 分配堆外緩衝失敗時,報的是 OutOfDirectMemoryError:

io.netty.util.internal.OutOfDirectMemoryError: failed to allocate 16777216 byte(s) of direct memory (used: 1006632967, max: 1013645312)
    at io.netty.util.internal.PlatformDependent.incrementMemoryCounter(PlatformDependent.java:667)
    at io.netty.util.internal.PlatformDependent.allocateDirectNoCleaner(PlatformDependent.java:622)
    at io.netty.buffer.PoolArena$DirectArena.allocateDirect(PoolArena.java:772)
    at io.netty.buffer.PoolArena$DirectArena.newChunk(PoolArena.java:752)
    at io.netty.buffer.PoolArena.allocateNormal(PoolArena.java:293)
    at io.netty.buffer.PoolArena.allocate(PoolArena.java:249)
    at io.netty.buffer.PooledByteBufAllocator.newDirectBuffer(PooledByteBufAllocator.java:411)

標準 JDK NIO 直接記憶體超限時,是另一種寫法:

java.lang.OutOfMemoryError: Direct buffer memory
    at java.nio.Bits.reserveMemory(Bits.java:175)
    at java.nio.DirectByteBuffer.<init>(DirectByteBuffer.java:123)
    at java.nio.ByteBuffer.allocateDirect(ByteBuffer.java:311)
    at org.springframework.core.io.buffer.NettyDataBufferFactory.allocateBuffer(NettyDataBufferFactory.java:157)
    at com.example.gateway.RequestCacheFilter.cache(RequestCacheFilter.java:31)
    at com.example.gateway.RequestCacheFilter.filter(RequestCacheFilter.java:24)

兩行數字是定位關鍵:used 是已用堆外記憶體,max 是上限。這裡 max 約 967MB,說明沒有被顯式放寬過。

Reproduce(復現程式碼)

網關裡有一個 Filter 會把請求體整個 join 到一起,緩存起來給後續鑑權、日誌用:

return DataBufferUtils.join(exchange.getRequest().getBody())
    .flatMap(dataBuffer -> {
        byte[] bytes = new byte[dataBuffer.readableByteCount()];
        dataBuffer.read(bytes);
        DataBufferUtils.release(dataBuffer); // 释放原始 buffer
        DataBuffer buffer = exchange.getResponse().bufferFactory().wrap(bytes);
        DataBufferUtils.retain(buffer); // 又 retain 了一个新 buffer
        return Mono.just(buffer);
    });

平時流量低看不出問題,一旦併發上來、或來了幾個大請求體,直接記憶體就被迅速打爆。

Root Cause(根因分析)

直接記憶體和堆是兩套賬。ByteBuffer.allocateDirect 分配的記憶體由操作系統管理,不走 GC,-Xmx 限制不到它。JVM 對直接記憶體的預設上限是「跟隨 -Xmx」——顯式設置過 -XX:MaxDirectMemorySize 才算數。

上面這個 Filter 有三個疊加的問題:

  1. 不限制請求體大小,來多大讀多大,50MB 的文件也整個 join 進記憶體。
  2. join 之後 retain() 出來的新 buffer,其生命週期完全依賴下游 Filter 鏈能否正常消費。一旦熔斷超時或拋例外,沒人 release,就成孤兒物件一直佔著堆外記憶體。
  3. join 過程沒有 onErrorResume 或 doFinally 兜底,連接中斷時中間態的 buffer 直接洩漏。

這類洩漏在高併發下像滾雪球一樣把直接記憶體吃光,最終網關無緩衝可用,整體不可用。

The Fix(解決方案)

先止血:顯式給直接記憶體設一個上限,並和堆隔離:

java -XX:MaxDirectMemorySize=256m -Xmx512m -jar app.jar

預設值「跟隨 -Xmx」往往偏小,單獨顯式設置才能真正放寬。容器裡記得 MaxDirectMemorySize 加 -Xmx 之外的記憶體總和不能超過容器 limit,否則會被 K8s OOM Killer 幹掉。

根子上要堵住洩漏:

  1. 給請求體加大小上限,超大文件不接受或走分片,別無腦 join。
  2. 對 retain() 出去的 buffer,用 doFinally 或 onErrorResume 保證例外路徑也 release。
  3. 能用 heap buffer 的場合就別用 direct,減少堆外佔用。

排查時不要看 -Xmx 和 GC 日誌,去看 Netty 的直接記憶體計數、以及容器實際記憶體水位,定位是誰在堆外一直沒釋放。

References(參考來源)

最後更新於 2026-09-09

這篇幫到你了嗎?

剛解決了一個棘手的報錯?花兩分鐘記錄下來,幫助下一個遇到同樣問題的開發者。

貢獻一條解法