内存涨了,不一定是泄漏
服务跑几天内存涨一点,是正常的:缓存预热、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 打开,按这个顺序看:
- Leak Suspects:MAT 自动给出的可疑点,适合快速定位,但不保证准确;
- Dominator Tree:按 retained size 排序,找到真正占内存的对象树,比 shallow size 有用得多;
- Path to GC Roots:排除弱引用、软引用后,看是谁强引用着这个对象;
- 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 堆。
排查步骤
- 看监控曲线:是锯齿形缓慢上升,还是台阶式突增,突增通常和发布、流量、定时任务相关。
jstat -gcutil观察多轮 Full GC 后的 Old 基线,确认是否真的回收不掉。- 在低峰期触发 dump,用 MAT 的 Dominator Tree 找 retained size 最大的对象。
- 沿 GC Roots 看引用链,确认是业务缓存、静态集合还是框架持有。
- 堆内正常就查 NMT,区分元空间、线程栈、直接内存和代码缓存。
- 修复后做长时间压测,确认 Old 基线稳定,而不是只跑十分钟看没 OOM 就上线。
评论