ElIauk

线上内存泄漏排查实录

内存涨了,不一定是泄漏 服务跑几天内存涨一点,是正常的:缓存预热、JIT 代码缓存、连接池建立、直接内存分配都会让 RSS 缓慢上升。真正需要警惕的是“Full GC 之后 Old 区仍然降不下来”,以及“重启就恢复、跑一段时间又涨”的锯齿形曲线。 内存泄漏排查最怕两件事:一上来就抓 dump,结果

评论
线上内存泄漏排查实录

内存涨了,不一定是泄漏

服务跑几天内存涨一点,是正常的:缓存预热、JIT 代码缓存、连接池建立、直接内存分配都会让 RSS 缓慢上升。真正需要警惕的是“Full GC 之后 Old 区仍然降不下来”,以及“重启就恢复、跑一段时间又涨”的锯齿形曲线。

内存泄漏排查最怕两件事:一上来就抓 dump,结果文件几十 GB 打不开;或者盯着堆看半天,最后发现是堆外内存或容器 limit 的问题。先分类,再动手。

先分清堆内、堆外和容器

现象 可能区域 先看什么
Full GC 后 Old 仍高 堆内对象泄漏 heap dump + MAT
堆正常但 RSS 持续涨 直接内存、线程栈、元空间 NMT、jcmd VM.native_memory
Metaspace 持续涨 类加载器/动态类泄漏 类直方图、加载器引用链
进程突然消失,退出码 137 容器内存超限 kubectl describe pod、cgroup 指标
Direct buffer memory OOM Netty/NIO 堆外泄漏 MaxDirectMemorySize、NMT

堆内泄漏的判定标准是:连续几次 Full GC 后,Old 区使用率仍呈上升趋势。只看单次 used 没有意义,必须看“回收后”的基线。

抓 dump 与分析

先确认堆的使用情况,再决定要不要 dump:

jcmd 12345 GC.heap_info
jstat -gcutil 12345 1000 10
jcmd 12345 GC.heap_dump /tmp/heap-$(date +%H%M).hprof

GC.heap_dump 会触发一次 Full GC,生产上要选低峰期,或者先把实例从负载均衡摘掉。dump 文件大小接近堆的实际占用,容器里要提前确认磁盘空间,别 dump 到一半把节点写满。

拿到文件后用 MAT 打开,按这个顺序看:

  1. Leak Suspects:MAT 自动给出的可疑点,适合快速定位,但不保证准确;
  2. Dominator Tree:按 retained size 排序,找到真正占内存的对象树,比 shallow size 有用得多;
  3. Path to GC Roots:排除弱引用、软引用后,看是谁强引用着这个对象;
  4. Histogram:按类统计实例数和占用,适合看“某个类实例数异常多”。

堆内看起来正常,但 RSS 一直涨,就开 NMT:

java -XX:NativeMemoryTracking=summary -jar app.jar
jcmd 12345 VM.native_memory summary

NMT 有 5% 左右的性能开销,排查完记得关掉。重点看 Java Heap、Class、Thread、Code、GC、Internal 和 Other 这几块,哪块持续增长就是方向。

几个高频泄漏模式

模式 典型写法 修复
静态集合只增不减 static Map 做本地缓存 换 Caffeine,设上限和过期
ThreadLocal 未清理 线程池线程持有请求上下文 try-finally 里 remove()
监听器未注销 注册后随对象生命周期未解绑 在销毁回调里反注册
无界队列 LinkedBlockingQueue 不设容量 有界队列 + 背压
连接/流未关闭 手动 open 后异常路径漏关 try-with-resources
类加载器泄漏 热部署、动态代理反复生成类 限制类生成,重启而非热部署

ThreadLocal 是最隐蔽的一类,因为线程池的线程会一直活着,ThreadLocalMap 里的 Entry 只要 key 还被引用,value 就回收不掉:

private static final ThreadLocal<RequestContext> CONTEXT = new ThreadLocal<>();

public void handle(Request request) {
    CONTEXT.set(RequestContext.from(request));
    try {
        doHandle(request);
    } finally {
        CONTEXT.remove();
    }
}

remove() 必须放在 finally 里。只在正常路径清理,异常一发生就漏了;用 @Async、自定义线程池时更要注意,任务结束不代表线程结束。

常见坑

坑一:只看 used,不看回收后的基线。 堆用 80% 但每次 Full GC 都能回到 30%,那是正常波动,不是泄漏。

坑二:dump 抓在 Full GC 之前。 文件里一半是垃圾对象,分析时被误导。先触发一次 GC 再 dump,或者直接依赖 HeapDumpOnOutOfMemoryError。

坑三:MAT 默认内存不够。 几个 GB 的 dump,MAT 自己先 OOM。改 MemoryAnalyzer.ini 的 -Xmx,并且用 64 位版本。

坑四:只抓一个实例。 集群里每个实例的负载不同,泄漏可能只在某台机器上明显。多抓一两个对比。

坑五:把缓存增长当泄漏。 缓存本来就会涨到上限再稳定。看它是否收敛,而不是看它是否增长。

坑六:忽略直接内存。 Netty、NIO、gRPC 大量使用堆外内存,堆很健康,容器却被 OOMKill。堆外问题看 NMT 和 MaxDirectMemorySize,不要只 dump 堆。

排查步骤

  1. 看监控曲线:是锯齿形缓慢上升,还是台阶式突增,突增通常和发布、流量、定时任务相关。
  2. jstat -gcutil 观察多轮 Full GC 后的 Old 基线,确认是否真的回收不掉。
  3. 在低峰期触发 dump,用 MAT 的 Dominator Tree 找 retained size 最大的对象。
  4. 沿 GC Roots 看引用链,确认是业务缓存、静态集合还是框架持有。
  5. 堆内正常就查 NMT,区分元空间、线程栈、直接内存和代码缓存。
  6. 修复后做长时间压测,确认 Old 基线稳定,而不是只跑十分钟看没 OOM 就上线。

参考

评论

推荐阅读

订阅邮件

订阅更新,及时收到最新文章。

作者