2750 字
约 9 分钟
21
S05-X18 · 内存泄漏的排查路径:从堆增长到引用链
2024-12-31
2026-11-08

老年代占用一路爬到接近上限,重启就回落,这基本已经是泄漏了。但更多时候曲线只是“看着高”:一个把堆塞满缓存的系统,和真正泄漏的系统,在监控图上长得几乎一样。两者的分界只有一条——Full GC 之后,占用能不能回到原来的基线。

把“泄漏”和“不够用”拆开,再谈取证顺序。


1. 先分清:泄漏不是“内存不够”

判据 内存泄漏 容量不足
曲线形状 阶梯式单向上行,重启归零 高位平台,随负载起伏
Full GC 后残留 基线逐次上移 每次回到同一水平
OOM 出现时机 运行越久越容易 高峰流量时集中出现
java.lang.OutOfMemoryError: Java heap space             # 堆真的放不下
java.lang.OutOfMemoryError: GC overhead limit exceeded  # 回收吃掉大部分时间却收不回多少
java.lang.OutOfMemoryError: Metaspace                   # 类元数据区,不在堆里

看到 GC overhead limit exceeded 别当成“GC 参数没调好”,它等价于“存活对象太多,回收已无利可图”。三类 OOM 指向三个不同的内存区域。


2. 第一步是看趋势,不是抓堆

堆转储是重型证据:会产生 STW、文件与堆同量级、分析要几十分钟。在用重型工具之前,先用 jstat 花五分钟确认“这是不是泄漏”。

jstat -gcutil 列 含义 泄漏视角下的信号
O 老年代使用率 单调上行且 FGC 后不回落
E Eden 使用率 长期贴 100% → 分配速率过高
M Metaspace 使用率 只涨不落 → 动态类过多
YGC / YGCT Young GC 次数 / 累计耗时 次数陡增说明分配压力大
FGC / FGCT Full GC 次数 / 累计耗时 FGC 在涨而 O 不降 = 强泄漏信号
GCT 全部 GC 累计耗时 与业务 RT 一起看才有意义
$ jstat -gcutil -t 5123 5s 12          # 12 次采样、间隔 5 秒,带时间戳
$ jstat -gcutil 5123 1000 | tee /tmp/gcutil.log

# 关键对照实验:手动触发一次 Full GC,看 O 能不能降回基线
$ jcmd 5123 GC.run
$ jstat -gcutil 5123 1000 3

判据就一句话:主动 Full GC 之后 O 回不到历史低点,且每次回落的最低点都比上一次高。曲线的“高度”不说明问题,回落后的“地板”才说明问题。


3. 堆转储的对比法

单张 dump 只能告诉你“现在谁占得多”,两张 dump 才能告诉你“谁在变多”。后者才是泄漏的定义。

# 两次类直方图,间隔 10–30 分钟,业务量保持稳定
$ jcmd 5123 GC.class_histogram > /tmp/h1.txt
$ jcmd 5123 GC.class_histogram > /tmp/h2.txt
$ diff <(sort -k2 -n /tmp/h1.txt) <(sort -k2 -n /tmp/h2.txt) | head -n 40

# 两次完整 dump,供 MAT 对比
$ jcmd 5123 GC.heap_dump /tmp/d1.hprof
$ jcmd 5123 GC.heap_dump /tmp/d2.hprof
抓取时机 优点 代价
OOM 时自动落盘 现场最真实 进程已异常,可能没写成文件
间隔两次对比 能算增量,天然排除静态数据 两次 STW,两次磁盘占用
摘流量后抓 停顿影响可控 现场已被改变,可能掩盖并发相关泄漏

GC.class_histogram 只触发一次短暂 STW,比完整 dump 轻得多。先看直方图增量,锁定可疑类再抓 dump。


4. MAT 里找泄漏的三种入口

入口 做法 适合回答
Leak Suspects 打开 dump 后读自动报告 起手方向,给出可疑对象与占比
支配树 Dominator Tree 按 retained size 降序 “删掉哪个对象能释放最多内存”
Histogram 按实例数 降序看类,配合 classloader 过滤 “哪个类在异常增多”
$ ./ParseHeapDump.sh /tmp/d2.hprof org.eclipse.mat.api:suspects \
    org.eclipse.mat.api:overview org.eclipse.mat.api:top_components -vmargs -Xmx6g
-- OQL 控制台:按条件直接捞对象,比翻树快
SELECT * FROM java.util.HashMap$Node

三种入口是先粗后细:Leak Suspects 给方向,支配树给持有量排序,Histogram 给增量排序。真正定位到字段,必须走引用链。

retained size 与 shallow size 的区别要记牢:shallow 是对象自身字节,retained 是“回收它能连带释放多少”。泄漏排查看的是 retained——一个 HashMap 自身几十字节,retained 可能是几个 G。


5. 引用链的正确读法

引用类型 回收时机 在泄漏判断中的角色
强引用 不可达时 唯一能让对象活着的引用
软引用 SoftReference 内存紧张时清 缓存可用,但不能当“不会 OOM”的保证
弱引用 WeakReference 下次 GC 必清 常见于 key 侧,通常不是元凶
虚引用 PhantomReference 仅用于回收通知 与泄漏基本无关

读法三步:从对象向上走到 GC Root;排除软/弱引用(Path to GC Roots → exclude weak/soft references);对剩下的强引用路径逐跳追问“这个字段属于谁、持有者本该活多久”。

第三问最关键。OrderService → static Map → OrderContext 这条路径里,问题不在 OrderContext,而在 static Map 没有淘汰策略;ThreadPoolExecutor$Worker → threadLocals → value 这条里,问题在线程池复用线程且业务没调 remove()。


6. 六种常见泄漏形态

形态 代码特征 取证信号
静态集合只增不删 static Map/List 做注册表 集合 size 与业务量同向增长
监听器未注销 addListener 无对应 remove 同一回调对象的实例数持续增加
ThreadLocal 未 remove 线程池 + 大对象写入 ThreadLocal 线程数 × value 大小 ≈ 老年代增量
缓存无上限 HashMap 当缓存,无 TTL/容量 类实例数随请求数线性涨
连接/流未关闭 缺 try-with-resources 堆内有大量 XxxStream/Connection
内部类持有外部 非静态内部类被长生命周期对象引用 外部类的实例数异常,且无其他引用
// 典型:非静态内部类隐式持有外部 this
public class Outer {
    private final byte[] payload = new byte[1024 * 1024];
    class Inner { }                    // Inner 持有 Outer.this
    static Inner REGISTRY;             // 一旦注册,Outer 也活到进程结束
}

7. ThreadLocal 为什么必须 remove()

ThreadLocal 的泄漏机制不在 key,在 value。

public class Ctx {
    private static final ThreadLocal<byte[]> HOLDER = new ThreadLocal<>();

    void handle(Request req) {
        HOLDER.set(new byte[1024 * 1024]);   // value 挂在当前线程的 ThreadLocalMap 上
        try {
            // 业务
        } finally {
            HOLDER.remove();                 // 少了这行,value 跟线程同寿
        }
    }
}

ThreadLocalMap.Entry 继承 WeakReference<ThreadLocal<?>>:key 是弱引用,value 是强引用。当 ThreadLocal 实例被回收,Entry 的 key 变成 null,但 value 仍被 Entry 强引用,只有 map 在后续 get/set/remove 顺带清理 stale entry 时才会释放。在固定大小的线程池里,线程不会退出,ThreadLocalMap 也就不会消失,于是每个线程各留下一份“孤儿 value”。

所以规则不是“用完清一下”,而是:凡是往 ThreadLocal 里放了大对象或请求级对象,必须在 finally 里 remove();框架层面(RequestContextHolder、MDC)也要确认它替你清了。


8. 缓存的正确做法

维度 该怎么做 反面做法
容量 有界上限(maximumSize / size limit) new HashMap 无限装
过期 TTL + TTI 双策略 只写不失效
引用强度 弱键按对象身份回收;软引用只作压力缓冲 拿软引用当“不会 OOM”的挡箭牌
可观测 命中率、size、eviction 计数 缓存变黑盒,出问题无从判断
Cache<String, User> cache = Caffeine.newBuilder()
    .maximumSize(100_000)                       // 有界,先保证不失控
    .expireAfterWrite(Duration.ofMinutes(10))   // 过期,防止陈旧数据堆积
    .recordStats()                              // 命中率/淘汰数可观测
    .build();

软引用/弱引用的取舍要写清楚:软引用在内存紧张时被清,看起来“安全”,但清理时机由 GC 决定、不可预测,业务上表现为“命中率突然跌到 0 + 一次长 GC”。弱引用更激进,下一轮 GC 就没了,只适合重算很便宜的键。两者都替代不了容量上限。


9. 排错:判断失误的五种典型

现象 真实原因 处理
只调大 -Xmx,几天后又 OOM 没找泄漏点,只是推迟了爆发 回到趋势与增量对比
用 -Xmx 掩盖增长 曲线形状被更大的堆“压平”了 看 Full GC 后基线,不看绝对值
抓 dump 导致接口大面积超时 dump 触发 STW,堆越大停越久 摘流量、避开高峰,或只抓直方图
把正常缓存增长当泄漏 有界缓存会先涨到上限再平稳 看是否收敛到平台,而非单调上升
调 GC 参数后仍频繁 Full GC 存活对象多,任何收集器都收不动 转回对象生命周期分析

最后一条常被忽略:G1/ZGC 改善停顿分布,但收不掉强引用可达的对象,泄漏面前收集器选型不是变量。


10. 边界:堆 dump 看不见的泄漏

泄漏位置 为什么 hprof 看不到 该用什么
直接内存(DirectByteBuffer) 只有一小块 Java 对象做句柄,真实内存在堆外 NMT、pmap、-XX:MaxDirectMemorySize
Metaspace / 类元数据 不在 Java 堆 jcmd VM.metaspace、jcmd GC.class_histogram 看类数
线程栈 每个线程的栈在堆外 jcmd Thread.print、线程总数趋势
JNI / native 库分配 由本地代码 malloc pmap、malloc 追踪、NMT
容器内存超限被 kill JVM 认为还有余量,cgroup 已到顶 核对 MaxRAMPercentage 与容器 limit

收口:泄漏的证据链是趋势 → 增量 → 支配 → 引用链 → 代码字段,跳过任何一步都是猜。


11. 自测

  1. 老年代占用很高但每次 Full GC 后都回到同一水平,这更像泄漏还是容量问题?
  2. jstat -gcutil 里哪两列的组合同向变化时,最值得怀疑泄漏?
  3. 为什么建议先抓 GC.class_histogram 再抓完整 dump?
  4. 在 MAT 里看引用链,排除软引用与弱引用是为了排除什么干扰?
  5. ThreadLocal 的 key 是弱引用,为什么 value 还会泄漏?
  6. 给缓存加软引用,为什么仍不能替代容量上限?
S05-X18 · 内存泄漏的排查路径:从堆增长到引用链
https://www.clxhxhhr.top/posts/5912/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。