堆外内存(DirectByteBuffer)泄漏是高并发网关的隐形杀手。现象通常是 JVM 堆内存充裕,但进程却被 Linux OOM Killer 无情强杀。本质原因是 NIO 产生的 ByteBuffer 壳对象晋升至 G1 老年代,未达到 Mixed GC 阈值,导致底层 Cleaner 无法执行回收。解决之道:必须显式限制 -XX:MaxDirectMemorySize 触发 Jvm 内部 OOM 实施自保,下调 G1 的 InitiatingHeapOccupancyPercent 提前回收,并配合 pmap 与 JMX 精准追踪堆外分配。
凶案现场:安静死去的网关节点
排查过程中,某高吞吐 API 网关集群(基于 Spring Cloud Gateway / Netty,JDK 11.0.17)频繁出现节点无响应。监控面板显示,节点在崩溃前 CPU 和 Load Average 均无异常,YGC 频率平稳,堆内存使用率仅徘徊在 40% 左右。但容器却频繁发生重启。
登录宿主机查阅系统日志,直接抓到了真凶:
$ dmesg -T | grep -i oom
[xxx] java invoked oom-killer: gfp_mask=0x100cca(GFP_HIGHUSER_MOVABLE), order=0, oom_score_adj=0
[xxx] Out of memory: Killed process 13452 (java) total-vm:14853240kB, anon-rss:8192340kB, file-rss:0kB, shmem-rss:0kB, UID:1000 pgtables:21340kB oom_score_adj:0
anon-rss(匿名常驻内存)高达 8GB,而该容器的物理内存上限即为 8GB。JVM 的启动参数配置了 -Xms4G -Xmx4G,按理说加上 JVM 自身的 MetaSpace、CodeCache 和线程栈,怎么也不该吃掉额外的 4GB 内存。
追踪隐形内存:NMT 的盲区
遇到堆外内存泄漏,标准动作是开启 Native Memory Tracking(NMT):
-XX:NativeMemoryTracking=detail
随后使用 jcmd 查看:
$ jcmd 13452 VM.native_memory summary
Total: reserved=5342MB, committed=4842MB
- Java Heap (reserved=4096MB, committed=4096MB)
- Class (reserved=256MB, committed=128MB)
- Thread (reserved=120MB, committed=120MB)
- Code (reserved=250MB, committed=64MB)
...
陷阱出现:NMT 统计的 committed 总内存仅不到 5GB,与 OS 层面 pmap 或 top 看到的 8GB 存在巨大缺口。
这是 JVM 调优中极其容易踩坑的地方:NMT 无法监控由 Unsafe.allocateMemory() 直接分配的内存,而 java.nio.DirectByteBuffer 的底层恰恰就是基于 Unsafe 实现的。NMT 只能管 JVM 内部的 C++ 代码(如 JIT 编译器、GC 结构等)分配的内存。
我们改用 OS 层面的 pmap 对照,抓出大块内存地址:
$ pmap -x 13452 | sort -k 3 -n -r | head -n 10
Address Kbytes RSS Dirty Mode Mapping
00007f8a10000000 4194304 4194304 4194304 rw--- [ anon ] <-- 堆内存 4G
00007f8840000000 65536 65536 65536 rw--- [ anon ] <-- 巨大的 64M 连续匿名内存块
00007f8844000000 65536 65536 65536 rw--- [ anon ]
...
配合 Arthas 读取 JMX 的 java.nio.BufferPool.direct 属性,确认了 Direct Memory 的实际使用量已经飙升到了 3.5GB。
为什么堆内壳对象存活会导致物理内存被掏空?
这里必须深挖 DirectByteBuffer 的生命周期与 G1 垃圾回收机制之间的致命耦合。
DirectByteBuffer 在 Java 堆内只是一个几十字节的“壳对象”,它内部包含一个 address 指针,指向真正在 OS 层面申请的数百兆甚至上 G 的物理内存。
当这段物理内存需要被释放时,必须依赖 JVM 的 ReferenceHandler 线程处理挂在壳对象上的 Cleaner(继承自 PhantomReference)。
死亡链路如下:
-
网关瞬间承接突发流量(如大文件下载、大 Payload 报文)。Netty 分配了大量堆外 ByteBuffer。
-
突发流量导致 YGC 频繁,部分
DirectByteBuffer壳对象未被及时回收,熬过了-XX:MaxTenuringThreshold,晋升到了 G1 老年代。 -
壳对象非常小,即使有上万个晋升到老年代,也只占了几 MB 的堆内存。
-
JDK 11 中 G1 触发并发标记(Concurrent Mark,随后才是 Mixed GC 回收老年代)的默认条件是自适应的,通常堆使用率达到 45%(
InitiatingHeapOccupancyPercent)才会介入。 -
堆内 4G 空间极其宽裕,老年代迟迟达不到触发线。
-
堆内的壳对象不死,挂在它身上的
Cleaner就不触发,堆外几十 G 的物理内存就一直被占着不放。 -
最终,OS 物理内存耗尽,OOM Killer 直接介入,一刀斩断 Java 进程。
源码层面,在 java.nio.Bits.reserveMemory() 中,如果堆外内存不足,JVM 会调用 System.gc() 尝试触发 Full GC 救场。但很多运维规范中为了防止停顿,习惯性配置了 -XX:+DisableExplicitGC,这直接屏蔽了这道最后的防线,加速了进程的死亡。
防御性调优实战
针对这种场景,不能靠盲目扩大内存,必须通过严苛的参数限制和 GC 调优,让问题在 JVM 内部以异常形式抛出,而不是被 OS 直接 Kill。
1. 显式限制 Direct Memory
这是最重要的防御性配置。如果不配,最大堆外内存默认等同于最大堆内存(-Xmx)。
-XX:MaxDirectMemorySize=2G
强制限制后,当堆外超限时,JVM 会抛出 java.lang.OutOfMemoryError: Direct buffer memory。这不仅能保护宿主机,还能让业务代码触发重试或熔断降级。
2. G1 提前介入清理老年代
打破 G1 老年代因堆内内存充足而“躺平”的僵局。关闭自适应 IHOP,强制调低并发标记的触发阈值,让 G1 尽早清理掉老年代的 ByteBuffer 壳对象。
-XX:-G1UseAdaptiveIHOP
-XX:InitiatingHeapOccupancyPercent=35
这样当整个堆内存使用到达 35% 时,G1 就会开始并发标记并执行 Mixed GC,及时释放堆外的内存孤岛。
3. 保留 System.gc(),但使其并发执行
绝不要粗暴地使用 -XX:+DisableExplicitGC。对于依赖堆外内存的 Netty/NIO 架构,显式的 System.gc() 是 Bits.reserveMemory() 触发内存回收的关键机制。
建议改为:
-XX:+ExplicitGCInvokesConcurrent
当触发 System.gc() 时,不再进行会导致整个应用停顿(STW)的 Full GC,而是启动一次并行的 Concurrent GC 周期。
常见问题 (FAQ)
Q1:为什么 NMT 没发现泄漏,但 top 命令里的 RES 却居高不下,且 JMX 里的 DirectMemory 并不高?
这种情况通常不是 DirectByteBuffer 导致的,而是 glibc 的 malloc 内存碎片和内存池机制机制导致。JVM 中的 JIT 编译器、JNI 调用等在频繁申请/释放小块内存时,glibc 为了性能不会立即返还给 OS,而是缓存在 Arena 中。
解法:在启动脚本中加入环境变量限制 Arena 数量:export MALLOC_ARENA_MAX=4。
Q2:JIT 编译(CodeCache)是否也会导致堆外内存 OOM?
会。在 JDK 11 及以后,分层编译(Tiered Compilation)极其激进。对于复杂的微服务应用,默认的 240M CodeCache 可能被打满,或者在未打满时由于碎片导致 JIT 停止工作,引发 QPS 断崖式下跌。
排查:可以通过 jcmd 查看。若使用率超 80%,需增加 -XX:ReservedCodeCacheSize=512M。CodeCache 是受 NMT 监控的,可以在 NMT Compiler 分类中明确看到。
Q3:Netty 自身有对象池机制,还会引发 DirectMemory 泄漏吗?
会。Netty 的 PooledByteBufAllocator 自己维护了一套内存池结构(Chunk/Page)。如果是业务代码忘记调用 ReferenceCountUtil.release(msg),导致的属于内存池泄漏,Netty 提供了 ResourceLeakDetector(配置 -Dio.netty.leakDetection.level=ADVANCED)会在日志中打印泄漏轨迹。
但如果是网关积压(消费过慢,生产过快),Netty 不断向底层申请新的 Chunk,哪怕没有忘记 release,也会打爆物理内存,这时依然需要依靠 -XX:MaxDirectMemorySize 进行兜底。