如果是堆外内存(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 baseline2)内存飙升、出现泄漏迹象后,执行差值对比:
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))。
极简操作步骤:
安装perf和火焰图工具
抓取进程的内存分配事件
生成火焰图,直接查看内存申请占比最高的函数调用栈
这个操作通常需要运维配合,适合极端复杂的泄漏场景,绝大多数线上问题,用前两招就能完全定位。
核心总结:堆外内存泄漏排查标准SOP
以后线上遇到“RSS内存飙升但堆内存很空、Dump文件啥也没有”的场景,别慌,严格按这个顺序排查,一步到位:
- 应用层快速排查
用Arthas的mbean命令查看DirectBuffer用量,用ognl命令查看Netty堆外内存占用,定位是不是框架层面的泄漏;
- 快速验证试探
用vmtool强制触发GC,看内存是否下降,排查是不是GC参数配置不当导致的回收不及时;
- JVM层精准定位
开启NMT,用jcmd查看内存分布,通过基线对比找到持续增长的内存区域,缩小排查范围;
- 系统层终极排查
用pmap查看内存段,排查是不是Glibc内存碎片问题,极端场景用perf火焰图定位原生库泄漏。
面试加分项:面试官追问标准答案
如果面试中被问到“堆外内存溢出怎么排查”,直接把下面这段话术说出来,绝对是面试官想要的满分答案:
面试官您好,针对堆外内存溢出的排查,我会按照从应用层到JVM层再到系统层的顺序,由浅入深逐步定位,不会上来就用复杂工具,具体分为四步:
首先我会先确认堆外内存的核心来源,先通过JDK的BufferPool MBean查看DirectBuffer的使用情况,确认是不是JDK的直接内存没有回收;如果项目用了Netty,我会通过Netty的PlatformDependent查看它的内存池占用,同时开启Netty的泄漏检测,定位是不是ByteBuf没有手动释放;
第二步我会通过vmtool工具强制触发一次GC,看内存是否下降,排查是不是JVM参数
-XX:+DisableExplicitGC导致的System.gc()失效,影响了堆外内存的回收;第三步如果上面的排查都没有问题,我会开启JVM的NMT原生内存追踪,通过jcmd查看JVM全内存区域的分布,用基线对比法找到持续增长的内存区域,确认是线程栈、元空间、JIT缓存还是JNI原生内存导致的泄漏;
最后如果NMT也无法定位,我会用Linux的pmap命令查看进程的内存映射,排查是不是Glibc的内存碎片问题,极端场景下用perf工具抓取native层的内存分配火焰图,定位到具体的原生库泄漏点。
同时在生产环境中,我也会提前做好监控,对DirectBuffer用量、Netty内存池占用、进程RSS内存设置告警,提前规避堆外内存泄漏的风险。
生产环境避坑红线
线上禁止无差别开启NMT,故障排查时再开启,避免不必要的性能损耗;
gdb、perf等工具attach进程,极端场景可能导致进程卡顿,生产环境非必要不操作,优先用前两招定位;
Netty的ByteBuf一定要遵循“谁申请谁释放”的原则,开发环境必须开启PARANOID级别的泄漏检测,提前暴露问题;
高并发服务必须配置
MALLOC_ARENA_MAX环境变量,避免Glibc内存碎片导致的RSS内存飙升。
写在最后
堆内OOM看代码,堆外OOM看架构。
堆外内存泄漏,往往和网络IO(Netty)、压缩解压、序列化、JNI原生调用这些底层能力绑定,它不像堆内OOM一样直观,却是中高级Java开发面试必问、线上必踩的坑。