這是最容易誤判的一類 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 有三個疊加的問題:
- 不限制請求體大小,來多大讀多大,50MB 的文件也整個
join進記憶體。 join之後retain()出來的新 buffer,其生命週期完全依賴下游 Filter 鏈能否正常消費。一旦熔斷超時或拋例外,沒人release,就成孤兒物件一直佔著堆外記憶體。join過程沒有onErrorResume或doFinally兜底,連接中斷時中間態的 buffer 直接洩漏。
這類洩漏在高併發下像滾雪球一樣把直接記憶體吃光,最終網關無緩衝可用,整體不可用。
The Fix(解決方案)
先止血:顯式給直接記憶體設一個上限,並和堆隔離:
java -XX:MaxDirectMemorySize=256m -Xmx512m -jar app.jar
預設值「跟隨 -Xmx」往往偏小,單獨顯式設置才能真正放寬。容器裡記得 MaxDirectMemorySize 加 -Xmx 之外的記憶體總和不能超過容器 limit,否則會被 K8s OOM Killer 幹掉。
根子上要堵住洩漏:
- 給請求體加大小上限,超大文件不接受或走分片,別無腦
join。 - 對
retain()出去的 buffer,用doFinally或onErrorResume保證例外路徑也release。 - 能用 heap buffer 的場合就別用 direct,減少堆外佔用。
排查時不要看 -Xmx 和 GC 日誌,去看 Netty 的直接記憶體計數、以及容器實際記憶體水位,定位是誰在堆外一直沒釋放。
