堆内存泄漏

长生命周期的对象,持有短生命周期对象的引用,导致后者无法被 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 最贴心的功能。当你打开文件时,它会弹出一个 Wizard 向导。

  1. 选择 “Leak Suspects Report” (泄漏嫌疑报告)。
  2. 看饼图: MAT 会自动计算支配树,并给你画一个饼图。如果饼图中有一块巨大的切片占据了 80%~90% 的面积,恭喜你,问题基本找到了。
  3. 看描述: 点击饼图下方的 Details,MAT 会直接告诉你:“一个 java.util.ArrayList 实例占用了 90% 的内存,它是由 com.example.CacheManager 的静态变量引用的。”

注意: 简单的泄漏(如静态大集合)这一步就能解决了。如果报告没看出来,或者你需要更深度的分析,进入第三步。


第三步:手动深度分析 (专家模式)

如果没有明显的“嫌疑人”,或者你想知道细节,请按照以下顺序操作:

1. 宏观概览:Histogram (直方图)

2. 抓大鱼:Dominator Tree (支配树)

3. 寻根问底:Path to GC Roots (核心操作)

当你找到一个看着不顺眼的对象(比如一个不该存在的 UserContext)时,你需要知道**“是谁抓着它不放”**。

4. 查看具体数据:List Objects -> with outgoing references

如果你想知道这个泄漏的大 List 里面到底存了什么数据(比如存的是哪个用户的 ID):

  1. 右键点击该 List 对象。
  2. List Objects -> with outgoing references
  3. 打开 Inspector 窗口,你可以看到字段的具体值(String 的内容、int 的数值等)。

💡 一个真实的分析案例 (模拟)

假设你分析一个 Tomcat 服务 OOM 的 Dump:

  1. 打开 Histogram,按 Retained Heap 排序。
  2. 发现 char[] (字符数组) 占用极高。这通常是 String 太多,但 char[] 看不出业务含义。
  3. 右键 char[] -> List Objects -> with incoming references (查看谁引用了这些数组)。
  4. 发现全是 java.lang.String 引用了它们。
  5. 回到 Histogram,直接看 java.lang.String,再次查找 Incoming References。
  6. 发现这些 String 大部分被一个 java.util.HashMapNode 引用。
  7. 查找 Path to GC Roots:发现这个 HashMap 被一个名为 GlobalConfig 的静态变量引用。
  8. 破案: 开发者在 GlobalConfig 里做了一个无限增长的日志缓存。

高级必杀技:OQL (对象查询语言)

如果你知道你要找什么,MAT 支持类 SQL 的查询。

我可以为你做的是:

如果你现在手头有 Dump 文件,或者你正在看某个具体的视图卡住了,你可以把 MAT 里的那一行截图描述给我(比如“我看到了一个 ArrayList 占了 500MB,它的 Incoming Ref 是…”),我来帮你解读这代表什么代码逻辑。