老年代占用一路爬到接近上限,重启就回落,这基本已经是泄漏了。但更多时候曲线只是“看着高”:一个把堆塞满缓存的系统,和真正泄漏的系统,在监控图上长得几乎一样。两者的分界只有一条——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. 自测
- 老年代占用很高但每次 Full GC 后都回到同一水平,这更像泄漏还是容量问题?
jstat -gcutil里哪两列的组合同向变化时,最值得怀疑泄漏?- 为什么建议先抓
GC.class_histogram再抓完整 dump? - 在 MAT 里看引用链,排除软引用与弱引用是为了排除什么干扰?
ThreadLocal的 key 是弱引用,为什么 value 还会泄漏?- 给缓存加软引用,为什么仍不能替代容量上限?