4、内存泄漏检测原理:什么是内存泄漏、内存泄漏的常见原因、引用计数与可达性分析、GC Roots概念

聊到内存优化,有个绕不开的话题——内存泄漏

说实话,我早年刚入行时,对内存泄漏的理解就停留在「内存没释放」这个层面。直到有一次线上服务频繁 Full GC,查了两天才定位到问题,才发现自己之前太天真了。今天咱们就把这块掰开揉碎讲清楚。

4.1 什么是内存泄漏?

先给个最直白的定义:内存泄漏,就是本该被回收的对象,因为被错误地引用着,导致 GC 无法回收。

你想想看,Java 有自动垃圾回收,按理说不用我们操心。但问题是,GC 只回收「不可达」的对象。如果一个对象已经没用了,但还有引用链连着它,GC 就认为它「还活着」,于是它就一直占着内存不走。

我习惯把内存泄漏比作「占着茅坑不拉屎」——对象已经没用了,却赖在堆里不走。日积月累,堆空间越来越小,GC 越来越频繁,最终 OOM。

核心要点:内存泄漏 ≠ 内存溢出。泄漏是原因,溢出是结果。泄漏多了,迟早溢出。

4.2 内存泄漏的常见原因

我在项目中遇到过不少内存泄漏的案例,总结下来,最常见的就这几类:

4.2.1 静态集合类持有对象

这是最经典的一种。比如你有一个 static List,往里面塞了一堆对象,用完了没清掉。静态变量的生命周期和 JVM 一样长,里面的对象永远可达。

public class CacheManager {
    private static List<byte[]> cache = new ArrayList<>();
    
    public void addData(byte[] data) {
        cache.add(data);  // 用完了没人 remove
    }
}

嗯,这种代码我见过太多次了。说白了就是「只加不减」。

4.2.2 未关闭的资源

数据库连接、文件流、Socket、Cursor……这些资源用完后必须 close。如果不关,它们内部往往会持有一些对象引用,导致内存泄漏。

我曾经排查过一个线上问题,发现数据库连接池的连接数一直在涨,最后发现是某段代码里 try 完之后忘了在 finally 里 close。

避坑指南:我建议所有资源操作都用 try-with-resources 语法,Java 7 以后就支持了,能省不少心。

4.2.3 内部类持有外部类引用

非静态内部类会隐式持有外部类的引用。如果你在 Activity 里定义了一个非静态内部类,然后把这个内部类的实例传给了某个静态变量或长期存活的对象,那整个 Activity 就泄漏了。

public class MainActivity extends AppCompatActivity {
    private static Handler sHandler;
    
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        // 非静态匿名内部类,持有外部 Activity 引用
        sHandler = new Handler() {
            @Override
            public void handleMessage(Message msg) {
                // ...
            }
        };
    }
}

这个例子我印象特别深。当时一个同事写的代码,Activity 退出后一直没释放,就是因为 Handler 被静态变量引着。

4.2.4 注册没反注册

广播接收器、EventBus、观察者模式……注册了就要反注册。否则对象虽然不再需要了,但事件总线还持有它的引用。

4.2.5 缓存设计不合理

很多人喜欢用 HashMap 做缓存,但从不考虑淘汰策略。缓存越积越多,最终变成内存泄漏。我建议用 WeakHashMap 或者 LRU 缓存。

4.3 引用计数与可达性分析

聊到内存泄漏检测,得先搞清楚 GC 是怎么判断对象「该不该回收」的。主流有两种算法:

4.3.1 引用计数法

思路很简单:每个对象维护一个计数器,被引用一次就 +1,引用失效就 -1。计数器为 0 时,说明没人用了,可以回收。

听起来很完美对吧?但有个致命问题——循环引用

class Node {
    Node next;
}

// 两个对象互相引用
Node a = new Node();
Node b = new Node();
a.next = b;
b.next = a;
// 此时 a 和 b 的引用计数都是 1,即使外部没人用了,它们也永远不会被回收

这就是为什么主流 JVM 不用引用计数法。Python 早期用过,但为了解决循环引用,还得额外搞个标记清除。

4.3.2 可达性分析

Java 用的就是这种。思路是:从一组「根对象」出发,沿着引用链往下走,能走到的对象就是「活的」,走不到的就是「死的」。

说白了,就是画一张对象引用图,然后从根节点做一次遍历。没被遍历到的节点,就是垃圾。

关键区别:引用计数是「数数」,可达性分析是「画图」。画图的方式天然不怕循环引用——两个互相引用的对象,如果从根节点出发找不到它们,那它们就是垃圾。

4.4 GC Roots 概念

GC Roots 就是可达性分析的「起点」。JVM 里哪些对象可以作为 GC Roots 呢?

GC Roots 类型 说明 举例
虚拟机栈引用 栈帧中的局部变量表 方法里的局部对象
静态变量 方法区的静态属性 static 字段引用的对象
常量引用 方法区中的常量池 字符串常量池中的引用
JNI 引用 Native 方法栈中的引用 JNI 全局引用
活跃线程 所有正在运行的线程 Thread 对象本身

我个人的理解是:GC Roots 就是「程序当前正在使用」的那些对象。 只要这些对象还活着,它们引用的对象就不能回收。

举个例子:

public void test() {
    User user = new User();  // user 是局部变量,属于 GC Root
    user.setName("张三");
    // 方法执行完,user 出栈,不再作为 GC Root
    // 如果此时没有其他引用指向这个 User 对象,它就会被回收
}

这里要注意一点:GC Roots 不是固定不变的。 每次 GC 时,JVM 都会重新确定当前的 GC Roots 集合。比如方法执行完了,局部变量出栈了,它就不再是 GC Root 了。

小技巧:分析内存泄漏时,用 MAT 或 JProfiler 查看 GC Roots 路径,就能知道某个对象为什么没被回收。我每次排查泄漏问题,第一件事就是看「GC Root 到目标对象的最短路径」。

4.5 总结一下

内存泄漏的本质,就是不该存活的对象,因为被 GC Roots 链引用着,导致无法回收

常见的泄漏原因就那么几种:静态集合、未关闭资源、内部类、未反注册、缓存设计不当。

而检测泄漏的核心,就是理解可达性分析和 GC Roots。你只要搞清楚「哪些对象是根」,就能顺着引用链找到泄漏点。

嗯,这一章的内容就到这儿。下一章咱们聊聊具体的检测工具和实战案例,到时候我会拿几个我亲手修过的泄漏问题来拆解。