1. 一次线上告警引发的排查:Gateway 内存溢出到底怎么回事
那天晚上十一点多,监控群里突然弹出一连串告警,网关节点响应时间飙升,紧接着两个实例直接掉了。登录机器一看日志,满屏的java.lang.OutOfMemoryError: Direct buffer memory,也就是我们常说的OutOfDirectMemoryError。这个错误跟普通的堆内存溢出完全不是一回事,堆内存看着还挺健康,GC 日志也正常,但进程就是活不过来。
如果你正在用 SpringCloud-Gateway 做微服务网关,或者任何基于 Netty 做底层通信的组件,这个问题你迟早会碰到。它不像业务代码里的 NPE 那么直观,也不像堆溢出那样容易用 MAT 分析,因为出问题的地方在堆外内存,也就是直接内存(Direct Memory)。很多同学第一次遇到会懵:明明-Xmx给了 4G,堆才用了 1G 多,怎么就 OOM 了?
这篇内容就是把我自己踩过的坑、排查思路和最终解决方案完整梳理一遍。适合正在使用 Gateway 的开发和运维同学,也适合任何对 Netty 堆外内存管理感兴趣的人。我会从直接内存的基本原理讲起,一步步带你定位问题,最后给出可落地的参数配置和代码层面的优化方案。整个过程不需要你有多深的 JVM 底层功底,跟着思路走就能复现和解决。
2. 先搞懂 Direct Memory 和 Gateway 的关系
2.1 为什么 Gateway 会用到堆外内存
SpringCloud-Gateway 底层是基于 Spring WebFlux 和 Reactor Netty 构建的,它跟传统的 Spring MVC 网关(比如 Zuul 1.x)最大的区别就是全异步非阻塞。Netty 在处理网络 IO 的时候,为了减少数据从内核态到用户态的拷贝次数,会大量使用直接内存(Direct ByteBuffer)。数据从网卡读进来直接放在堆外,不需要先复制到堆里再处理,这对高并发场景下的性能提升非常明显。
但代价就是,这块内存不受堆内存管理器的直接控制。你设置-Xmx4g只管堆,堆外内存默认情况下约等于-Xmx的值(具体取决于 JDK 版本和 GC 策略),但它不会像堆那样有完善的 GC 回收机制。直接内存的释放依赖于Cleaner机制和引用队列,如果对象一直被引用或者释放不及时,就会越积越多,最终撑爆。
2.2 OutOfDirectMemoryError 和普通 OOM 的本质区别
普通堆内存溢出,你会看到java.lang.OutOfMemoryError: Java heap space,这时候用jmapdump 堆文件,用 MAT 一分析就能找到哪个对象占了大头。但OutOfDirectMemoryError是另一回事:
- 堆 dump 文件里几乎看不出问题,因为泄漏的内存根本不在堆里
jstat看 GC 一切正常,Full GC 也不频繁- 进程可能突然就挂了,或者响应变得极慢
- 错误信息明确写着
Direct buffer memory
我当时的现场就是:堆用了不到 2G,GC 频率正常,但网关就是不断重启。后来用jcmd看 Native Memory Tracking 才发现,直接内存已经涨到了 3G 多,而机器总共才 8G 内存,加上堆和其他开销,直接把系统内存吃满了。
2.3 直接内存的分配和回收机制
要理解这个问题,得先知道直接内存是怎么分配和回收的。当你调用ByteBuffer.allocateDirect(size)或者 Netty 的PooledByteBufAllocator分配内存时,JVM 会通过Unsafe.allocateMemory()向操作系统申请一块本地内存。这块内存的引用被一个DirectByteBuffer对象持有,这个对象本身在堆里,但它指向的内存块在堆外。
回收的时候,DirectByteBuffer对象被 GC 标记为不可达后,会触发Cleaner的clean()方法,进而调用Unsafe.freeMemory()释放堆外内存。问题在于,这个释放动作是在 GC 之后异步执行的,而且如果堆内存压力不大,GC 可能很久都不触发,导致堆外内存一直不释放。Netty 自己实现了一套引用计数机制来管理ByteBuf,如果引用计数没有正确归零,内存就永远不会被回收。
3. 排查过程全记录:从现象到根因
3.1 第一步:确认错误类型和现场信息
看到OutOfDirectMemoryError之后,第一件事不是急着改代码,而是先把现场信息收集全。我当时做了这几件事:
- 保存完整的错误日志,包括堆栈信息,看看是哪个组件触发的分配失败
- 记录 JVM 启动参数,特别是
-Xmx、-XX:MaxDirectMemorySize、-XX:+UseG1GC这些 - 用
jcmd <pid> VM.native_memory summary查看 Native Memory Tracking 的汇总信息(前提是启动时加了-XX:NativeMemoryTracking=summary) - 用
pmap -x <pid>看进程的内存映射,确认哪块内存涨得最快
这里有个关键点:如果启动时没加 NMT 参数,事后是没法开启的,只能重启。所以建议所有网关类服务都提前加上-XX:NativeMemoryTracking=summary,对性能影响很小,但排查问题时能救命。
3.2 第二步:用 NMT 定位内存增长点
加上 NMT 之后,我对比了服务刚启动和运行几小时后的内存快照。命令很简单:
jcmd <pid> VM.native_memory summary输出里重点关注Internal、Other和Thread这几项。如果Internal项持续增长,通常就是直接内存或者元空间的问题。更细的可以用detail模式:
jcmd <pid> VM.native_memory detail在detail输出里搜索Direct相关的分配点,能看到具体是哪个类在申请内存。我当时看到的是io.netty.buffer.PooledByteBufAllocator相关的分配占了绝大部分,这就把方向锁定在了 Netty 的内存池上。
3.3 第三步:分析 Netty 内存池的分配行为
Netty 默认使用PooledByteBufAllocator,它会预先申请大块内存(Arena),然后按需切分给各个ByteBuf。这种池化设计本来是为了提升性能,减少频繁分配释放的开销,但如果配置不当或者使用有误,反而会导致内存堆积。
我检查了 Gateway 的配置,发现几个可疑点:
spring.cloud.gateway.httpclient.pool.maxConnections设得很大,导致连接池里的连接迟迟不释放- 没有显式配置
maxInFlight和acquireTimeout,请求积压时连接被占满 - 自定义的过滤器里有几处对
DataBuffer的读取没有正确释放
特别是第三点,WebFlux 里的DataBuffer跟 Netty 的ByteBuf类似,都需要手动释放或者确保被框架正确回收。如果过滤器里调用了DataBufferUtils.join()之后没有正确处理,内存就会泄漏。
3.4 第四步:复现问题并验证假设
为了验证我的判断,我在测试环境写了一个简单的压测脚本,用 JMeter 模拟大量请求打到网关,同时观察 NMT 的输出。果然,跑了不到半小时,直接内存就从 200M 涨到了 1.5G,而且请求停止后也没有明显回落。
然后我把自定义过滤器里可疑的DataBuffer操作注释掉,重新压测,内存增长明显放缓,请求停止后也能回落到正常水平。这就基本确认了泄漏点。
4. 解决方案与参数调优实战
4.1 显式限制直接内存大小
很多人以为设置了-Xmx就万事大吉,其实直接内存需要单独控制。JVM 参数-XX:MaxDirectMemorySize就是干这个的。如果不设置,默认值约等于-Xmx减去一个 Survivor 区的大小。对于网关这种堆外内存使用大户,我建议显式设置,比如:
-XX:MaxDirectMemorySize=512m这样当直接内存快用完时,JVM 会主动触发一次 Full GC 来尝试回收,而不是等到系统内存耗尽才报错。虽然 Full GC 有停顿,但总比进程挂掉强。设置多少合适?我的经验是看你的并发量和单请求数据大小。一般网关场景,512M 到 1G 足够,如果经常处理大文件上传下载,可以适当调大,但不要超过物理内存的 50%。
4.2 调整 Netty 内存池参数
Netty 提供了一些系统属性可以调整内存池行为,在 Gateway 里可以通过-D参数传入:
-Dio.netty.allocator.type=pooled -Dio.netty.maxDirectMemory=0io.netty.maxDirectMemory=0这个设置会让 Netty 不使用 JDK 的直接内存计数器,而是自己管理,配合-XX:MaxDirectMemorySize使用效果更好。另外还可以调整 Arena 的数量:
-Dio.netty.allocator.numDirectArenas=4默认情况下,Arena 数量跟 CPU 核数相关,核数多的时候 Arena 也多,每个 Arena 都会预分配内存,导致初始占用就很高。对于容器环境,CPU 核数可能显示很多但实际配额有限,这时候手动限制 Arena 数量很有必要。
4.3 连接池和超时配置优化
Gateway 的 HttpClient 连接池配置直接影响内存占用。以下是我在生产环境验证过的一套配置:
spring: cloud: gateway: httpclient: pool: maxConnections: 500 maxIdleTime: 30s acquireTimeout: 10s connect-timeout: 5000 response-timeout: 30s关键点在于maxIdleTime和acquireTimeout。空闲连接如果不及时关闭,会一直占着内存;获取连接没有超时限制,请求积压时会把连接池撑爆。另外response-timeout也要设置,防止后端服务慢响应导致连接被长期占用。
4.4 代码层面确保 DataBuffer 正确释放
这是最容易出问题的地方。在自定义 GatewayFilter 或 GlobalFilter 里操作请求体或响应体时,必须确保DataBuffer被释放。常见的错误写法:
// 错误示范:join 之后没有释放 DataBuffer buffer = DataBufferUtils.join(exchange.getRequest().getBody()).block(); byte[] bytes = new byte[buffer.readableByteCount()]; buffer.read(bytes); // 这里 buffer 没有被 release,内存泄漏正确做法是用try-finally或者DataBufferUtils.release():
// 正确示范 DataBuffer buffer = DataBufferUtils.join(exchange.getRequest().getBody()).block(); try { byte[] bytes = new byte[buffer.readableByteCount()]; buffer.read(bytes); // 业务处理 } finally { DataBufferUtils.release(buffer); }如果是在响应式流里操作,要用doFinally来确保释放:
exchange.getResponse().getBody() .doFinally(signalType -> DataBufferUtils.release(dataBuffer)) .subscribe();注意:
DataBufferUtils.join()返回的DataBuffer是合并后的新对象,原始的多个DataBuffer已经被释放,但合并后的这个必须手动释放,否则每次请求都会泄漏一块内存。
5. 常见问题速查与避坑指南
5.1 为什么设置了 MaxDirectMemorySize 还是 OOM
这种情况通常有两个原因。一是 Netty 使用了Unsafe直接分配内存,绕过了 JDK 的计数器,MaxDirectMemorySize对它不起作用。解决办法是加上-Dio.netty.maxDirectMemory=0,让 Netty 走 JDK 的计数器。二是内存泄漏速度太快,Full GC 来不及回收就又被占满了。这时候需要先解决泄漏问题,参数调整只是辅助。
5.2 容器环境下直接内存怎么算
在 Docker 或 Kubernetes 里,JVM 看到的可用内存可能跟容器限制不一致。如果没设置-XX:MaxRAMPercentage或者-Xmx,JVM 可能按宿主机内存来算,导致直接内存默认值过大。建议在容器里显式设置:
-XX:MaxRAMPercentage=70 -XX:MaxDirectMemorySize=256m同时确保容器内存限制至少是堆内存加直接内存再加 20% 的余量,给元空间、线程栈、JVM 自身开销留空间。
5.3 如何监控直接内存使用情况
除了 NMT,还可以通过 JMX 的BufferPoolMXBean来监控:
List<BufferPoolMXBean> pools = ManagementFactory.getPlatformMXBeans(BufferPoolMXBean.class); for (BufferPoolMXBean pool : pools) { System.out.println(pool.getName() + " count=" + pool.getCount() + " memoryUsed=" + pool.getMemoryUsed()); }输出里的direct池就是直接内存的使用量。可以把这个指标接入 Prometheus 或者你用的监控系统,设置告警阈值,比如超过MaxDirectMemorySize的 80% 就报警。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| OutOfDirectMemoryError 频繁出现 | 直接内存泄漏 | NMT detail 查看分配点 | 检查 DataBuffer 释放逻辑 |
| 堆内存正常但进程被 kill | 系统内存耗尽 | pmap 查看内存映射 | 限制 MaxDirectMemorySize |
| 请求停止后内存不回落 | 连接池未释放 | 查看连接池活跃数 | 调整 maxIdleTime |
| 压测时内存增长过快 | Arena 预分配过多 | 查看 numDirectArenas | 限制 Arena 数量 |
| Full GC 后内存不降 | 对象被强引用 | 堆 dump 分析引用链 | 修复代码引用泄漏 |
5.5 几个我踩过的坑
第一个坑是以为-Xmx设大点就行,结果直接内存默认值也跟着变大,反而更容易触发系统 OOM。第二个坑是在过滤器里用了subscribe()但没有处理背压,导致请求体堆积。第三个坑是忽略了 Netty 的引用计数,手动retain()之后忘了release()。
还有一个容易被忽视的点:Gateway 的spring.cloud.gateway.httpclient.websocket相关配置。如果开启了 WebSocket 代理,长连接会一直占着直接内存,需要单独设置maxFramePayloadLength和空闲超时。
6. 写在最后的一些个人体会
排查这个问题的过程让我对堆外内存有了完全不一样的认识。以前总觉得 JVM 调优就是调堆,现在才明白对于网关这类 IO 密集型应用,堆外内存的管理同样关键。我的建议是,所有基于 Netty 的服务,上线前一定要做三件事:显式设置MaxDirectMemorySize、加上 NMT 参数、把直接内存指标接入监控。
另外,不要等到出问题了才去看代码里的DataBuffer操作。在 Code Review 阶段就要把内存释放作为检查项,特别是那些看起来"只是读一下"的操作,往往就是泄漏的源头。我现在的习惯是,只要在过滤器里碰了 body,就条件反射地检查有没有对应的 release 调用。
最后分享一个小技巧:如果你不确定某个过滤器有没有泄漏,可以在测试环境把-XX:MaxDirectMemorySize设得很小,比如 64M,然后跑正常业务流量。如果很快就 OOM,说明肯定有泄漏;如果稳定运行,那基本没问题。这个方法比看代码快得多,也直观得多。