堆内存泄漏
长生命周期的对象,持有短生命周期对象的引用,导致后者无法被 GC 回收。
我们先来简单造一个典型的错误案例。我们有一个内存cache, 然后key是object类型
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.TimeUnit;
/**
* 模拟由“可变哈希值”导致的内存泄漏
* 场景:对象存入 HashSet/HashMap 后,修改了参与 hashCode 计算的字段,导致无法被删除。
*/
public class MutableKeyLeakDemo {
// 1. 静态容器,模拟全局缓存
private static final Set<KeyObject> LEAK_SET = new HashSet<>();
/**
* 定义一个“不守规矩”的 Key 对象
*/
static class KeyObject {
private int id;
// 为了让内存占用明显,每个对象携带 50KB 数据
private byte[] payload = new byte[50 * 1024];
public KeyObject(int id) {
this.id = id;
}
// 【致命操作】提供了一个 Setter 修改参与 Hash 计算的字段
public void setId(int id) {
this.id = id;
}
// hashCode 依赖于 id
@Override
public int hashCode() {
return id;
}
// equals 也依赖于 id
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
KeyObject keyObject = (KeyObject) o;
return id == keyObject.id;
}
}
public static void main(String[] args) throws InterruptedException {
System.out.println("=== 应用程序启动 ===");
System.out.println("请打开 VisualVM 连接... (等待 10 秒)");
TimeUnit.SECONDS.sleep(10);
System.out.println("=== 开始测试:存入 -> 修改 -> 尝试删除 ===");
for (int i = 0; i < 10000; i++) {
// A. 创建对象,此时 id = i (比如 0)
KeyObject key = new KeyObject(i);
// B. 放入 Set
// Set 根据 hashCode(0) 把它放到了 "桶A" 里
LEAK_SET.add(key);
// C. 【关键泄漏点】修改 id
// 此时对象的 id 变成了 99999+i,hashCode 也变了!
key.setId(i + 99999);
// D. 尝试删除这个对象
// Set 调用 key.hashCode(),算出的是新 ID 对应的 "桶B"。
// Set 去 "桶B" 里找,当然找不到(因为对象其实还在 "桶A" 里躺着)。
boolean success = LEAK_SET.remove(key);
// 验证是否删除失败
if (success) {
System.out.println("删除成功 (这行代码永远不会执行)");
}
// 每 500 个打印一次日志,避免刷屏
if (i % 500 == 0) {
System.out.println("尝试删除第 " + i + " 个对象 | 删除结果: " + (success ? "成功" : "失败")
+ " | 当前 Set 大小: " + LEAK_SET.size());
TimeUnit.MILLISECONDS.sleep(20); // 稍微停顿,观察内存曲线
}
}
System.out.println("=== 循环结束 ===");
// 理论上我们执行了 10000 次 add 和 10000 次 remove,Set 应该是空的。
// 但实际上...
System.out.println("预期 Set 大小: 0");
System.out.println("实际 Set 大小: " + LEAK_SET.size());
System.out.println("内存未释放,因为这些对象在 Set 中迷失了。");
// 保持运行以便 Dump
synchronized (MutableKeyLeakDemo.class) {
MutableKeyLeakDemo.class.wait();
}
}
}
如何避免这种泄漏? 铁律: 放入 HashMap/HashSet 的 Key 类,其参与 hashCode 和 equals 计算的字段必须是不可变(Immutable)的(即用 final 修饰)。
最佳实践: 尽量使用 String、Integer 这种天然不可变的类作为 Key。如果必须用自定义对象,请确保不要提供 setXxx 方法修改关键字段。
使用 Eclipse MAT (Memory Analyzer Tool) 分析内存泄漏,通常遵循一套标准的**“侦探破案”流程**。
MAT 不像 VisualVM 那样直观(全是图表),它是基于数据和引用关系的。只要掌握了核心套路,任何泄漏都无所遁形。
这是我为你整理的MAT 标准分析三部曲:
第一步:准备工作(避坑指南)
在打开 .hprof 文件之前,有一个新手最容易遇到的“坑”需要避开:
- 调整 MAT 自身的内存:
MAT 解析 Dump 文件时,需要把文件索引加载到自己的内存中。如果你的 Dump 文件是 8GB,而 MAT 默认配置只有 1GB,它自己会先崩掉。
- 操作: 找到 MAT 安装目录下的
MemoryAnalyzer.ini。 - 修改: 把
-Xmx1024m改大,通常建议设置为 Dump 文件大小的 1.2 倍左右(例如-Xmx10g)。
- 操作: 找到 MAT 安装目录下的
第二步:一键生成“嫌疑人报告” (小白/快速模式)
这是 MAT 最贴心的功能。当你打开文件时,它会弹出一个 Wizard 向导。
- 选择 “Leak Suspects Report” (泄漏嫌疑报告)。
- 看饼图: MAT 会自动计算支配树,并给你画一个饼图。如果饼图中有一块巨大的切片占据了 80%~90% 的面积,恭喜你,问题基本找到了。
- 看描述: 点击饼图下方的
Details,MAT 会直接告诉你:“一个java.util.ArrayList实例占用了 90% 的内存,它是由com.example.CacheManager的静态变量引用的。”
注意: 简单的泄漏(如静态大集合)这一步就能解决了。如果报告没看出来,或者你需要更深度的分析,进入第三步。
第三步:手动深度分析 (专家模式)
如果没有明显的“嫌疑人”,或者你想知道细节,请按照以下顺序操作:
1. 宏观概览:Histogram (直方图)
- 入口: 点击工具栏上的柱状图图标。
- 作用: 查看哪种类型的对象数量最多,或者占用内存最大。
- 关键概念:
- Shallow Heap (浅堆): 对象本身占用的大小(不含它引用的对象)。
- Retained Heap (深堆): 对象被回收后,能释放的总内存大小(即它“支配”的所有对象)。找内存泄漏主要看这个指标!
- 操作技巧:
- 在第一行的 Filter 框里输入你的包名(例如
com.mycompany),过滤掉 JDK 自带的类,只看自己的业务对象。
- 在第一行的 Filter 框里输入你的包名(例如
2. 抓大鱼:Dominator Tree (支配树)
- 入口: 点击工具栏上的皇冠图标(或树状图图标)。
- 作用: 这是 MAT 最强大的视图。它直接把对象按 Retained Heap 降序排列。
- 怎么看:
- 排在第一位的对象就是内存里的“霸主”。
- 展开这个对象,你可以看到它内部持有了哪些大对象。
- 例子: 你可能会看到一个
StandardSession对象排第一,展开发现里面塞满了User对象。
3. 寻根问底:Path to GC Roots (核心操作)
当你找到一个看着不顺眼的对象(比如一个不该存在的 UserContext)时,你需要知道**“是谁抓着它不放”**。
- 操作步骤:
- 在 Histogram 或 Dominator Tree 中,右键点击该对象。
- 选择
Path to GC Roots->exclude all phantom/weak/soft etc. references。 - 为什么要排除? 因为软/弱/虚引用不会阻止 GC 回收,排除它们后,剩下的就是导致泄漏的强引用链。
- 分析结果:
- MAT 会展示一条从 GC Root(如
Thread或System Class)直到你选中对象的引用链。 - 例子:
Thread-1->ThreadLocalMap->Entry->YourLeakingObject。 - 结论: 这就是典型的 ThreadLocal 没清理导致的泄漏。
- MAT 会展示一条从 GC Root(如
4. 查看具体数据:List Objects -> with outgoing references
如果你想知道这个泄漏的大 List 里面到底存了什么数据(比如存的是哪个用户的 ID):
- 右键点击该 List 对象。
List Objects->with outgoing references。- 打开 Inspector 窗口,你可以看到字段的具体值(String 的内容、int 的数值等)。
💡 一个真实的分析案例 (模拟)
假设你分析一个 Tomcat 服务 OOM 的 Dump:
- 打开 Histogram,按 Retained Heap 排序。
- 发现
char[](字符数组) 占用极高。这通常是 String 太多,但char[]看不出业务含义。 - 右键
char[]->List Objects->with incoming references(查看谁引用了这些数组)。 - 发现全是
java.lang.String引用了它们。 - 回到 Histogram,直接看
java.lang.String,再次查找 Incoming References。 - 发现这些 String 大部分被一个
java.util.HashMap的Node引用。 - 查找 Path to GC Roots:发现这个 HashMap 被一个名为
GlobalConfig的静态变量引用。 - 破案: 开发者在
GlobalConfig里做了一个无限增长的日志缓存。
高级必杀技:OQL (对象查询语言)
如果你知道你要找什么,MAT 支持类 SQL 的查询。
- 入口: 工具栏上的
OQL按钮。 - 场景: “帮我找出所有 email 包含 ’test’ 的 User 对象”。
- 语句示例:
SELECT * FROM com.example.User u WHERE toString(u.email) LIKE ".*test.*"
我可以为你做的是:
如果你现在手头有 Dump 文件,或者你正在看某个具体的视图卡住了,你可以把 MAT 里的那一行截图描述给我(比如“我看到了一个 ArrayList 占了 500MB,它的 Incoming Ref 是…”),我来帮你解读这代表什么代码逻辑。