标签: DirectByteBuffer

  • 深入 JVM 陷阱排查:DirectByteBuffer 堆外泄漏引发的 OOM Killer 与 G1 停顿实战

    堆外内存(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)。

    死亡链路如下:

    1. 网关瞬间承接突发流量(如大文件下载、大 Payload 报文)。Netty 分配了大量堆外 ByteBuffer。

    2. 突发流量导致 YGC 频繁,部分 DirectByteBuffer 壳对象未被及时回收,熬过了 -XX:MaxTenuringThreshold,晋升到了 G1 老年代。

    3. 壳对象非常小,即使有上万个晋升到老年代,也只占了几 MB 的堆内存。

    4. JDK 11 中 G1 触发并发标记(Concurrent Mark,随后才是 Mixed GC 回收老年代)的默认条件是自适应的,通常堆使用率达到 45%(InitiatingHeapOccupancyPercent)才会介入。

    5. 堆内 4G 空间极其宽裕,老年代迟迟达不到触发线。

    6. 堆内的壳对象不死,挂在它身上的 Cleaner 就不触发,堆外几十 G 的物理内存就一直被占着不放。

    7. 最终,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 Compiler.codecache 查看。若使用率超 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 进行兜底。

  • 深入 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 申请的直接内存往往会反映在 Internal 或 Other 区域(取决于具体的 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 的 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 碎片化引起的内存虚高,而非真正的内存泄漏。