SpringCloud Gateway 内存溢出排查:Direct Buffer Memory 泄漏与 Netty 堆外内存调优 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我当时的现场就是堆用了不到 2GGC 频率正常但网关就是不断重启。后来用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:NativeMemoryTrackingsummary用pmap -x pid看进程的内存映射确认哪块内存涨得最快这里有个关键点如果启动时没加 NMT 参数事后是没法开启的只能重启。所以建议所有网关类服务都提前加上-XX:NativeMemoryTrackingsummary对性能影响很小但排查问题时能救命。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:MaxDirectMemorySize512m这样当直接内存快用完时JVM 会主动触发一次 Full GC 来尝试回收而不是等到系统内存耗尽才报错。虽然 Full GC 有停顿但总比进程挂掉强。设置多少合适我的经验是看你的并发量和单请求数据大小。一般网关场景512M 到 1G 足够如果经常处理大文件上传下载可以适当调大但不要超过物理内存的 50%。4.2 调整 Netty 内存池参数Netty 提供了一些系统属性可以调整内存池行为在 Gateway 里可以通过-D参数传入-Dio.netty.allocator.typepooled -Dio.netty.maxDirectMemory0io.netty.maxDirectMemory0这个设置会让 Netty 不使用 JDK 的直接内存计数器而是自己管理配合-XX:MaxDirectMemorySize使用效果更好。另外还可以调整 Arena 的数量-Dio.netty.allocator.numDirectArenas4默认情况下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.maxDirectMemory0让 Netty 走 JDK 的计数器。二是内存泄漏速度太快Full GC 来不及回收就又被占满了。这时候需要先解决泄漏问题参数调整只是辅助。5.2 容器环境下直接内存怎么算在 Docker 或 Kubernetes 里JVM 看到的可用内存可能跟容器限制不一致。如果没设置-XX:MaxRAMPercentage或者-XmxJVM 可能按宿主机内存来算导致直接内存默认值过大。建议在容器里显式设置-XX:MaxRAMPercentage70 -XX:MaxDirectMemorySize256m同时确保容器内存限制至少是堆内存加直接内存再加 20% 的余量给元空间、线程栈、JVM 自身开销留空间。5.3 如何监控直接内存使用情况除了 NMT还可以通过 JMX 的BufferPoolMXBean来监控ListBufferPoolMXBean 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说明肯定有泄漏如果稳定运行那基本没问题。这个方法比看代码快得多也直观得多。