这是最容易误判的一类 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 的直接内存计数、以及容器实际内存水位,定位是谁在堆外一直没释放。
