排查某核心网关 OOM-Kill 时确认,堆内存仅使用 40%,容器却因 RSS 物理内存超限被内核干掉。根因是底层 Netty 大量分配 DirectByteBuffer,而 G1 垃圾回收跟不上堆外内存分配速度,PhantomReference 未及时触发 Cleaner 回收。解决方案:显式配置 -XX:MaxDirectMemorySize 拦截溢出,开启 NMT 追踪,并修正代码中未 release() 的 ByteBuf 泄漏。
故障现场:消失的 Pod 与飙升的 RSS
近期某基于 Netty 的 RPC 网关集群频繁发生 Pod 重启。监控大盘显示,该服务在 QPS 触达 5000 左右时,P99 耗时从平稳的 15ms 突增至 200ms+,随后实例直接失联。
登录宿主机执行 dmesg -T,拿到内核级的死亡判决书:
[Fri Oct 20 10:14:23] java invoked oom-killer: gfp_mask=0xd0, order=0, oom_score_adj=974
[Fri Oct 20 10:14:24] Task in /kubepods/burstable/pod-xxx killed as a result of limit of /kubepods/burstable/pod-xxx
[Fri Oct 20 10:14:24] memory: usage 8388608kB, limit 8388608kB, failcnt 3241
[Fri Oct 20 10:14:24] Memory cgroup out of memory: Kill process 12345 (java) score 1000 or sacrifice child
这是典型的 Linux Cgroup OOM-Kill。核对该 Pod 的资源配置:Limit 设为 8G,而 JVM 核心参数配置如下(基于 OpenJDK 11.0.17):
-Xms4G -Xmx4G -XX:+UseG1GC -XX:MaxMetaspaceSize=256M -XX:+PrintGCDetails
堆内存(4G)加上 Metaspace(256M)和线程栈,理论上 JVM 吃掉的物理内存撑死在 5G 左右。剩下的 3G 空间去哪了?毫无疑问,堆外内存泄漏。
抽丝剥茧:利用 NMT 与 pmap 锁定真凶
排查堆外内存,第一步永远是开启 Native Memory Tracking (NMT)。在启动参数中追加 -XX:NativeMemoryTracking=detail,并在应用 RSS 达到 7G 时,抓取内存分布快照:
jcmd <pid> VM.native_memory summary
输出结果的重点片段如下:
Native Memory Tracking:
Total: reserved=7345MB, committed=7120MB
- Java Heap (reserved=4096MB, committed=4096MB)
- Class (reserved=260MB, committed=128MB)
- Thread (reserved=120MB, committed=120MB)
- Code (reserved=250MB, committed=40MB)
- GC (reserved=180MB, committed=180MB)
- Internal (reserved=2320MB, committed=2320MB) <-- 重点在这里
...
NMT 明确指出 Internal 部分占用了 2.3G。在 JVM 语境下,DirectByteBuffer 申请的直接内存往往会反映在 Internal 或 Other 区域(取决于具体的 JDK 版本和 Unsafe 分配逻辑)。
进一步通过 OS 级别工具验证,抓取 pmap -x ,发现大量 64MB 大小的匿名内存块(anon)。这是典型的 glibc malloc Arena 内存分配特征,高度吻合 Java 通过 Unsafe.allocateMemory 绕过 JVM 堆直接向 OS 拿内存的行为。
立刻打一个 Heap Dump,用 MAT(Memory Analyzer Tool)分析,直接查看 java.nio.DirectByteBuffer 实例,发现堆内虽然只有不到 50MB 的 DirectByteBuffer 对象,但它们持有的 capacity 总和高达 2.5G!
为什么 G1 无法及时回收 DirectByteBuffer 引发的堆外内存溢出?
很多人会有疑问:DirectByteBuffer 虽然分配在堆外,但 Java 堆内依然有它的代理对象。既然堆内对象失去引用,为什么 G1 没有把它们回收掉,进而释放堆外内存?
这涉及底层 DirectByteBuffer 的分配与回收机制。直接看 JDK 源码 java.nio.DirectByteBuffer 的构造函数:
DirectByteBuffer(int cap) {
super(-1, 0, cap, cap);
boolean pa = VM.isDirectMemoryPageAligned();
int ps = Bits.pageSize();
long size = Math.max(1L, (long)cap + (pa ? ps : 0));
Bits.reserveMemory(size, cap); // 记录分配的直接内存大小
long base = 0;
try {
base = unsafe.allocateMemory(size); // 真正的 OS 级别 malloc
} catch (OutOfMemoryError x) {
Bits.unreserveMemory(size, cap);
throw x;
}
unsafe.setMemory(base, size, (byte) 0);
// 绑定 Cleaner (底层是 PhantomReference)
cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
}
堆外内存的释放依赖于 sun.misc.Cleaner。它是一个 PhantomReference(虚引用)。只有当 G1 垃圾回收器发现 DirectByteBuffer 对象不再可达,才会将对应的 Cleaner 放入 ReferenceQueue,随后由后台的 ReferenceHandler 线程执行 Deallocator.run() 调用 Unsafe.freeMemory() 归还给 OS。
惨案的根本原因在于 G1 的触发时机:
-
业务流量大,
DirectByteBuffer对象快速晋升到 Old Gen(老年代)。 -
JVM 配置了 4G 的大堆,而
DirectByteBuffer的 Java 层对象非常小(几十字节)。 -
G1 的 Concurrent Mark(并发标记)阶段默认需要老年代使用率达到
InitiatingHeapOccupancyPercent(默认 45%)才会触发。 -
由于对象极小,老年代迟迟达不到 45% 的阈值,G1 根本觉得不需要执行 GC。
-
堆内非常空闲,堆外却已经被 OS 级的 malloc 撑爆,最终触发 Linux OOM-Killer 绞杀。
更致命的是,如果没有显式设置 -XX:MaxDirectMemorySize,JDK 默认的直接内存上限几乎等于堆内存的最大值(-Xmx)。这意味着 JVM 认为可以申请最多 4G 的堆外内存,完全忽略了容器 8G 的硬限制。
防御性加固与最佳实践落地
明确了机制,修复方案就不应该仅仅是“改 Bug”,而是要从架构和 JVM 配置上进行系统级加固。
1. 锁死 MaxDirectMemorySize,让异常暴露在 JVM 层
永远不要依赖 OS OOM-Killer 来终结应用,那会导致现场完全丢失。必须在启动参数中显式限制直接内存:
-XX:MaxDirectMemorySize=1536M
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/logs/
当直接内存超过 1.5G 时,JVM 的 Bits.reserveMemory() 会主动抛出 java.lang.OutOfMemoryError: Direct buffer memory,同时触发 HeapDump,精准保留第一案发现场。
2. Netty 代码层的内存泄漏防御
在业务逻辑中,通过 Netty ByteBuf 读取数据后,由于各种异常分支未调用 ReferenceCountUtil.release(msg),导致内存泄漏。
除了修复代码逻辑,必须开启 Netty 的高级内存泄漏检测:
-Dio.netty.leakDetection.level=PARANOID
该参数会强制追踪所有 ByteBuf 的生命周期,一旦发现未释放的 Buffer 被 GC 垃圾回收,直接在日志中打印泄漏时的创建堆栈。实战中靠这一行配置揪出了 3 个隐藏极深的 try-catch 遗漏点。
3. 主动触发系统 GC (针对特定场景)
如果在特定的古老系统(不支持显式 release)中,可以通过 JNI 或 System.gc() 的策略干预。其实 Bits.reserveMemory() 源码中,在申请不到内存时会主动调用 System.gc() 尝试触发 Full GC 来拯救堆外内存。但如果你配置了 -XX:+DisableExplicitGC,这条退路就被切断了。结论:在使用大量 Direct Memory 的场景中,慎用 -XX:+DisableExplicitGC。
常见问题 (Q&A)
Q1:排查时,为什么有时候 NMT (Native Memory Tracking) 抓不到堆外内存的异常?
如果第三方库(如 JNA、直接调用 C/C++ 动态链接库的 JNI 模块)直接调用了 OS 的 malloc 或 mmap,这部分内存是绕过 JVM Unsafe API 的,NMT 无法感知,它只会将其归类为 OS 级别的未追踪占用。此时只能通过 Linux 原生的 pmap、strace 或 eBPF 工具去追踪 malloc 相关的系统调用。
Q2:如果把 G1 换成 ZGC,能解决这种堆外内存泄漏问题吗?
不能。ZGC 和 G1 一样,都是并发收集器。虽然 ZGC 的停顿时间极短(亚毫秒级),但它的回收触发同样依赖堆内对象的分配速率。如果 DirectByteBuffer 的分配速度远高于 ZGC 能够处理并把 PhantomReference 推入队列的速度,依然会导致堆外内存无限膨胀。核心解法依然是规范 -XX:MaxDirectMemorySize 和代码层的释放。
Q3:遇到 glibc 导致的假性内存泄漏(MALLOC_ARENA_MAX)怎么判断?
Linux 默认配置下,glibc 会为每个线程分配独立的内存池(Arena)以避免锁竞争(最大数量通常是 CPU 核心数 * 8)。在 Netty 这种多线程高并发场景下,会产生大量的 64M 内存块,表现为 top 命令下 VIRT 和 RES 飙高,但 JVM NMT 显示正常。可以通过设置环境变量 MALLOC_ARENA_MAX=4 限制内存池数量,如果 RSS 显著下降,则证明是 glibc 碎片化引起的内存虚高,而非真正的内存泄漏。