标签: G1垃圾回收

  • 深入 JVM 堆外内存排查:Netty DirectByteBuffer 泄漏引发的 OOM-Kill 与 G1 延迟回收机制解析

    排查某核心网关 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 申请的直接内存往往会反映在 InternalOther 区域(取决于具体的 JDK 版本和 Unsafe 分配逻辑)。

    进一步通过 OS 级别工具验证,抓取 pmap -x | sort -n -k3,发现大量 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 的触发时机:

    1. 业务流量大,DirectByteBuffer 对象快速晋升到 Old Gen(老年代)。

    2. JVM 配置了 4G 的大堆,而 DirectByteBuffer 的 Java 层对象非常小(几十字节)。

    3. G1 的 Concurrent Mark(并发标记)阶段默认需要老年代使用率达到 InitiatingHeapOccupancyPercent(默认 45%)才会触发。

    4. 由于对象极小,老年代迟迟达不到 45% 的阈值,G1 根本觉得不需要执行 GC

    5. 堆内非常空闲,堆外却已经被 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 的 mallocmmap,这部分内存是绕过 JVM Unsafe API 的,NMT 无法感知,它只会将其归类为 OS 级别的未追踪占用。此时只能通过 Linux 原生的 pmapstrace 或 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 碎片化引起的内存虚高,而非真正的内存泄漏。