快速修复
# 止血:显式隔离堆与直接内存,容器限制给足
java -XX:MaxDirectMemorySize=1000m -XX:+UseG1GC -Xms1000m -Xmx1000m -jar gateway.jar
# K8s Deployment 内存限制 1500Mi -> 2500Mi
# 根治:请求体缓存加大小门槛(超过直接跳过缓存,记 warn),响应式链路补释放兜底
# - Filter 里 MAX_CACHE_BODY_SIZE = 1MB,超限跳过 body 缓存
# - readBody 的 flux 链补 onErrorResume / doFinally 释放
# - AccessLogFilter 同样限制聚合大小
你会遇到什么
Spring Cloud Gateway(reactor-netty)类网关突然偶发 500,日志出现:
io.netty.util.internal.OutOfDirectMemoryError: failed to allocate 16777216 byte(s) of direct memory (used: 1006632967, max: 1013645312)
频率逐渐升高,很快服务不可用、健康检查异常。堆内存(-Xmx)看起来没问题,GC 日志也正常——但直接内存已经见底(used 960MB / max 967MB)。
本文路径:快速修复(30 秒一段命令)→ 一步步排查(可跳读)→ 原理与变体(症状/原因/证据)。赶时间直接跳到「快速修复」。
为什么会这样
Netty 的 ByteBuf 默认走 PooledByteBufAllocator,即堆外直接内存。直接内存不受 GC 管理,分配/释放要自己负责,泄漏的 ByteBuf 不会被回收,只会一路涨到上限。三个叠加因素:
| 因素 | 机制 |
|---|---|
-XX:MaxDirectMemorySize 未显式设置 | 默认有效上限 = -Xmx;K8s 容器内存限制只留了 1500Mi,堆吃 1000MB 后直接内存空间紧张 |
| Filter 缓存请求体无大小上限 | 50MB 上传照读进直接内存;retain 出的新 buffer 释放完全依赖下游消费,异常/熔断超时 → 泄漏 |
| 响应式链路无兜底 | DataBufferUtils.join 中途断连或下游报错,中间态 buffer 变孤儿;AccessLogFilter 把响应体全量聚合进内存 |
低流量时被掩盖,并发上来或几个大请求同时进来就爆。
Netty 的 ByteBuf 默认走 PooledByteBufAllocator:内存从 JVM 堆外(direct memory)分配,不受 GC 管理——分配/释放全靠引用计数(refCnt),retain 增、release 减,漏一次 release 就永久泄漏,直到撞上 MaxDirectMemorySize。
三个叠加因素各有机制:上限未显式设置时默认 = -Xmx(K8s 容器只给 1500Mi 时,堆 1000MB 后 direct 空间所剩无几);Filter 缓存请求体无大小上限(50MB 照读 + 异常路径不 release → 泄漏);响应式链路 join 中途断连/报错 → 中间态 buffer 成孤儿。低流量时池化缓冲复用掩盖了泄漏,并发一上来就爆。
确定性推论:direct OOM 是「泄漏 + 上限」双变量——先修泄漏(retain/release 配对、异常路径 finally release),再调上限(-XX:MaxDirectMemorySize 与容器内存匹配);jcmd <pid> VM.native_memory 看 direct 分配趋势;
判断路径:堆没满却 OOM + 栈里有 Netty = direct 问题,不是 -Xmx 问题。
一步步来
1. 看报错签名确认是直接内存 — 排查
OutOfDirectMemoryError(Netty 包装)或 OutOfMemoryError: Direct buffer memory(JDK 原生),报错里带 used/max 两个数字——先对一下 max 是不是约等于 -Xmx(没设参数时的默认)。
2. 检查 JVM 参数与容器限制 — 排查
# 确认 MaxDirectMemorySize 是否显式设置(0 = 默认取 -Xmx)
java -XX:+PrintFlagsFinal -version 2>&1 | grep MaxDirectMemorySize
# 对照容器 limit:堆 + 直接内存 + 元空间 + 线程栈都要装得下
kubectl get deploy <name> -o yaml | grep -A2 resources
3. 找谁吃了直接内存 — 必做
- 网关:Netty 收发本身就用直接内存(正常);重点查自定义 Filter:缓存请求体/响应体的
byte[]、DataBufferUtils.join、retain后是否保证释放; - 开启泄漏检测(低流量窗口):
-Dio.netty.leakDetectionLevel=paranoid,日志会输出泄漏点;
NaN
4. 修 — 必做
- 请求体缓存加大小门槛(如 1MB),超限跳过缓存只记 warn;
- 响应式链路补
doFinally/onErrorResume保证释放; - 显式
-XX:MaxDirectMemorySize,按网关流量评估(堆与直接内存分离,谁也不挤谁); - K8s 容器内存限制重新核算(堆 + 直接内存 + 元空间 + 线程栈 + 系统开销)。
5. 验证 — 必做
压测原触发路径(大请求 + 并发),直接内存水位平稳不回涨;leak detection 无新泄漏点。
怎么确认修好了
同一触发路径下不再出现 OutOfDirectMemoryError;jcmd <pid> VM.native_memory 或监控里 direct buffer 水位稳定;健康检查不再异常。
其它环境的表现
| 环境 | 说明 |
|---|---|
| 网关类(Spring Cloud Gateway / reactor-netty) | 天生重度使用直接内存,必须显式 MaxDirectMemorySize + 管好 ByteBuf 释放 |
| 普通服务 | 默认 = -Xmx 通常够用,但大文件/大响应体聚合进内存一样会爆 |
| 未实测 | Spring Cloud Gateway + 自定义 Filter 的完整复现未在本机跑(无网关环境),核心机制(直接内存上限默认 = -Xmx、堆空闲时直接内存独立耗尽)已实测 |
症状
网关偶发 500 → 频繁:日志 io.netty.util.internal.OutOfDirectMemoryError: failed to allocate N byte(s) of direct memory (used: X, max: Y),服务不可用、健康检查异常;堆内存与 GC 日志正常。
原因
直接内存(堆外)不受 GC 管理:-XX:MaxDirectMemorySize 未设置时默认 = -Xmx;自定义 Filter 无大小上限地缓存请求/响应体且释放依赖下游正常消费,异常/熔断时 ByteBuf 泄漏,最终顶满直接内存上限。
解法
- 显式
-XX:MaxDirectMemorySize(堆与直接内存隔离),容器内存限制同步给足; - 请求体/响应体缓存加大小门槛,超限跳过;
- 响应式链路补
doFinally/onErrorResume兜底释放; - 开
-Dio.netty.leakDetectionLevel=paranoid定位泄漏点; - 压测验证直接内存水位。
验证证据
- 机制实测(2026-08-30,WSL Ubuntu,OpenJDK 17.0.18):① 未设 MaxDirectMemorySize、
-Xmx64m→ 分配 64MB 直接内存后报OutOfMemoryError: Cannot reserve ... (allocated: 67108864, limit: 67108864),行为证明默认上限 = -Xmx;②-XX:MaxDirectMemorySize=64m -Xmx256m→ 堆很空闲但直接内存照样 64MB OOM(直接内存独立于堆、不受 GC 管控);③ 显式 256m → 分配 200MB 成功。证据:实测复现日志(2026-08-30)。 - 未实测:网关 Filter/ByteBuf 泄漏完整复现(无 Spring Cloud Gateway 环境);Netty 的
OutOfDirectMemoryError与 JDKDirect buffer memory同机制,Netty 只是自定义了异常包装。
适用工具 (1)
本问题方案已挂到下列工具的方案页——从工具页也能回到这里,双向可达。
jdk · 外部工具来源与证据
← 编码代理与终端 问题域 | 问题货架 | 遇到其它问题?去 问题空间 提交求助卡