面试官:堆外内存爆了,Dump 文件没用,你怎么定位?3招定位线上“幽灵内存泄漏”
2026/4/18 0:47:51 网站建设 项目流程

如果是堆外内存(Direct Memory)溢出怎么办?我看监控面板,Heap用得很少,但机器的内存RSS一直在飙升,最后进程直接被Linux的OOM Killer杀掉了。用MAT打开Dump文件,里面啥也没有,这咋整?”

这种场景,我们业内叫“幽灵内存泄漏”。

它不像堆内OOM一样有明确的异常栈、能通过Dump文件直接定位,罪魁祸首通常藏在这几个地方:NIO框架(Netty)、GZIP解压缩、JNI调用、MappedByteBuffer内存映射。普通的排查工具MAT就像普通内科医生,治不了这种隐藏极深的问题,得请Arthas+Linux原生工具这套“特种部队”出手。

今天Fox教你3招,从应用层到JVM层再到操作系统层,层层剥开堆外内存的伪装,文末附上可直接照着执行的排查SOP和面试满分话术,收藏好,线上遇到了能救命。

第一招:Arthas的“照妖镜”——零重启线上直接排查

堆外内存虽然不在堆里,但JVM和框架本身一定会留下“账本”,我们不用改启动参数、不用重启服务,用大家最熟悉的Arthas就能先完成第一轮排查,这也是线上故障的首选操作。

1. 先实锤:是不是DirectBuffer导致的泄漏

JDK会把直接内存的使用情况,完整暴露在MBean中,一行命令就能查到核心数据。

在Arthas控制台直接输入:

    # 查看Direct BufferPool的完整MBean信息mbean java.nio:type=BufferPool,name=direct

    核心看两个指标

    • MemoryUsed:当前已使用的直接内存大小
    • TotalCapacity:直接内存总容量

    判断标准:如果MemoryUsed已经无限接近你JVM启动参数设置的-XX:MaxDirectMemorySize,直接实锤——就是DirectByteBuffer对象未回收导致的堆外泄漏。

    2. 快速试探:强制GC排查引用持有问题

    很多时候堆外内存不释放,根本原因是堆内的DirectByteBuffer对象(虚引用)还被持有,没被GC回收,导致底层关联的堆外内存无法释放

    我们可以用Arthas手动触发一次Full GC,快速验证问题:

      # Arthas强制触发GC,无额外性能影响,线上可安全执行vmtool --action forceGc

      现象与解决方案

      • 如果GC后,系统RSS内存瞬间下降,说明是“GC触发不及时”导致的临时占用,不是泄漏;

      • 常见坑:检查JVM启动参数是不是加了-XX:+DisableExplicitGC,这个参数会禁用System.gc(),而DirectByteBuffer的堆外内存回收,恰恰依赖System.gc()的主动触发;

      • 最优解:要么移除该参数,要么搭配-XX:+ExplicitGCInvokesConcurrent(适配CMS/G1等并发收集器),让System.gc()触发并发GC,既不会导致长时间STW,又能正常回收堆外内存。

      3. Netty专属排查:90%微服务都会踩的泄漏坑

      如果你的项目用了微服务、RPC框架,99%的概率依赖了Netty。这里有个绝大多数人都会忽略的盲区:Netty自己维护的PooledByteBufAllocator内存池,分配的堆外内存,不会被JDK的MBean统计到

      也就是说,哪怕你用上面的命令查到DirectBuffer用量很小,也可能是Netty的堆外内存泄漏了。

      用Arthas的ognl表达式,一行命令直接读取Netty内部的内存统计:

        # 查看Netty当前已使用的堆外内存总量,类名随Netty版本略有差异,通用为PlatformDependentognl '@io.netty.util.internal.PlatformDependent@usedDirectMemory()'

        Fox提示:如果这里返回的数值持续飙升、居高不下,100%是Netty的ByteBuf泄漏了——根本原因几乎都是业务代码里,申请的ByteBuf用完没有手动调用release()方法释放。

        配套解决方案

        在JVM启动参数中加上Netty自带的泄漏检测开关,直接在日志里打印出泄漏对象的完整调用栈,精准定位到代码行:

          # 生产环境建议用advanced级别,性能损耗极低,能覆盖99%的泄漏场景-Dio.netty.leakDetectionLevel=advanced

          级别可选:DISABLED(关闭)、SIMPLE(默认)、ADVANCED(详细栈)、PARANOID(极致排查,开发环境用)。

          4.补充高频坑:MappedByteBuffer

          很多人用MappedByteBuffer做大文件内存映射,它的堆外内存回收有个致命坑:只能靠Full GC触发回收,普通的Young GC完全无效,而且JDK没有提供显式的unmap API。如果频繁创建MappedByteBuffer却不主动释放,会导致堆外内存持续飙升,解决方案是通过Unsafe类手动调用unmap方法释放。

          第二招:JVM的“自白书”——NMT原生内存追踪

          如果Arthas排查下来,DirectBuffer和Netty的内存用量都正常,但RSS内存还在涨,说明泄漏点不在Java应用层,而是在JVM内部开销、JNI调用、系统原生库中。

          这时候就要启用JVM自带的核武器:NMT(Native Memory Tracking)原生内存追踪,它能把JVM进程的所有内存占用,拆解得明明白白。

          1. 开启NMT(需重启服务)

          在JVM启动参数中加上这一行即可开启:

            # 可选summary/detail级别,排查问题用detail,能看到更完整的信息-XX:NativeMemoryTracking=detail

            Fox提示:开启NMT会带来5%-10%的轻微性能损耗,生产环境建议先在预发验证,或故障复现时开启,不建议长期无差别开启。

            2. 实时查看内存分布

            服务运行一段时间后,在服务器终端执行以下命令,就能拿到完整的内存分布报告:

              # PID替换为你的Java进程号jcmd <PID> VM.native_memory summary

              3. 报告核心解读,一眼找到泄漏点

              你会看到JVM把所有内存分成了明确的区域,重点关注这几个:

              进阶技巧:差值对比,精准定位持续增长的区域

              想要快速找到“哪个区域在偷偷涨内存”,用基线对比法,一步到位:

              1)服务刚启动、内存稳定时,建立基线:

                jcmd <PID> VM.native_memory baseline

                2)内存飙升、出现泄漏迹象后,执行差值对比:

                  jcmd <PID> VM.native_memory summary.diff

                  报告里会直接显示每个区域的内存增量,哪个区域在涨、涨了多少,一目了然。

                  第三招:Linux的“手术刀”——原生工具终极排查

                  如果连NMT都看不出明确异常,但RSS内存还在疯涨,说明泄漏点完全脱离了JVM的管控,大概率是第三方C++库、JNI自定义代码、Glibc内存碎片导致的,这时候就要上Linux原生工具做终极排查。

                  1. pmap定位内存段,识别Glibc内存碎片

                  一行命令,按内存占用排序,找到进程里最大的内存块:

                    # PID替换为Java进程号,按内存大小倒序,取前10条pmap -x <PID> | sort -rn -k3 | head -10

                    核心看什么

                    找大量连续的64MB内存块,这是Glibc的ptmalloc内存分配器的典型特征。高并发场景下,多线程频繁申请释放内存,会导致Glibc创建大量的内存分区(Arena),每个分区默认64MB,产生大量内存碎片,这些内存不会被释放还给操作系统,最终导致RSS持续飙升。

                    解决方案

                    在Java服务的启动脚本中,添加环境变量,限制Glibc的Arena数量,完美解决内存碎片问题:

                      # 通用最优配置,设置为CPU核心数,最高不超过8export MALLOC_ARENA_MAX=4

                      2. 高频场景补全:原生库泄漏排查

                      两个最容易被忽略的堆外泄漏场景,这里直接给排查方向:

                      1)GZIP解压缩泄漏

                      业务代码中使用Inflater/Deflater做GZIP压缩解压,用完没有调用end()方法释放原生内存,会导致堆外内存持续泄漏,这是Java原生API最常见的坑;

                      2)JNI/第三方原生库泄漏

                      比如自定义的JNI代码、加密解密的C++库、音视频处理组件,这些代码里的malloc申请的内存,完全脱离JVM管控,NMT也无法追踪,只能用原生工具定位。

                      3) perf火焰图:终极定位内存申请调用栈

                      如果必须精准定位到“哪一行C代码申请的内存没释放”,用perf工具抓取native层的内存分配火焰图,这是最终极的排查手段,能直接把调用栈定位到对应的so库(比如[libzip.so](libzip.so)、[libnetty_transport_native.so](libnetty_transport_native.so))。

                      极简操作步骤:

                      1. 安装perf和火焰图工具

                      2. 抓取进程的内存分配事件

                      3. 生成火焰图,直接查看内存申请占比最高的函数调用栈

                      这个操作通常需要运维配合,适合极端复杂的泄漏场景,绝大多数线上问题,用前两招就能完全定位。

                      核心总结:堆外内存泄漏排查标准SOP

                      以后线上遇到“RSS内存飙升但堆内存很空、Dump文件啥也没有”的场景,别慌,严格按这个顺序排查,一步到位:

                      1. 应用层快速排查

                        用Arthas的mbean命令查看DirectBuffer用量,用ognl命令查看Netty堆外内存占用,定位是不是框架层面的泄漏;

                      2. 快速验证试探

                        用vmtool强制触发GC,看内存是否下降,排查是不是GC参数配置不当导致的回收不及时;

                      3. JVM层精准定位

                        开启NMT,用jcmd查看内存分布,通过基线对比找到持续增长的内存区域,缩小排查范围;

                      4. 系统层终极排查

                        用pmap查看内存段,排查是不是Glibc内存碎片问题,极端场景用perf火焰图定位原生库泄漏。

                      面试加分项:面试官追问标准答案

                      如果面试中被问到“堆外内存溢出怎么排查”,直接把下面这段话术说出来,绝对是面试官想要的满分答案:

                      面试官您好,针对堆外内存溢出的排查,我会按照从应用层到JVM层再到系统层的顺序,由浅入深逐步定位,不会上来就用复杂工具,具体分为四步:

                      1. 首先我会先确认堆外内存的核心来源,先通过JDK的BufferPool MBean查看DirectBuffer的使用情况,确认是不是JDK的直接内存没有回收;如果项目用了Netty,我会通过Netty的PlatformDependent查看它的内存池占用,同时开启Netty的泄漏检测,定位是不是ByteBuf没有手动释放;

                      2. 第二步我会通过vmtool工具强制触发一次GC,看内存是否下降,排查是不是JVM参数-XX:+DisableExplicitGC导致的System.gc()失效,影响了堆外内存的回收;

                      3. 第三步如果上面的排查都没有问题,我会开启JVM的NMT原生内存追踪,通过jcmd查看JVM全内存区域的分布,用基线对比法找到持续增长的内存区域,确认是线程栈、元空间、JIT缓存还是JNI原生内存导致的泄漏;

                      4. 最后如果NMT也无法定位,我会用Linux的pmap命令查看进程的内存映射,排查是不是Glibc的内存碎片问题,极端场景下用perf工具抓取native层的内存分配火焰图,定位到具体的原生库泄漏点。

                      同时在生产环境中,我也会提前做好监控,对DirectBuffer用量、Netty内存池占用、进程RSS内存设置告警,提前规避堆外内存泄漏的风险。

                      生产环境避坑红线

                      1. 线上禁止无差别开启NMT,故障排查时再开启,避免不必要的性能损耗;

                      2. gdb、perf等工具attach进程,极端场景可能导致进程卡顿,生产环境非必要不操作,优先用前两招定位;

                      3. Netty的ByteBuf一定要遵循“谁申请谁释放”的原则,开发环境必须开启PARANOID级别的泄漏检测,提前暴露问题;

                      4. 高并发服务必须配置MALLOC_ARENA_MAX环境变量,避免Glibc内存碎片导致的RSS内存飙升。

                      写在最后

                      堆内OOM看代码,堆外OOM看架构。

                      堆外内存泄漏,往往和网络IO(Netty)、压缩解压、序列化、JNI原生调用这些底层能力绑定,它不像堆内OOM一样直观,却是中高级Java开发面试必问、线上必踩的坑。

                      需要专业的网站建设服务?

                      联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

                      立即咨询