对象泄漏原理:什么是对象泄漏、泄漏链、GC Root与可达性分析

调了这么多年 Android 性能,我发现一个现象:很多 ANR 的根因根本不是业务逻辑复杂,而是对象泄漏。说白了,就是本该被回收的对象,因为某个隐形的引用链一直活着,活到最后把堆撑爆、把主线程卡死。你想想看,一个 Activity 退出了,它里面的图片、View、甚至一个匿名线程,如果被某个静态集合拽着,GC 就拿它没办法。

我记得有一次,用户反馈某个页面退出后再次进入越来越卡。我 dump 了堆快照,发现这个 Activity 的实例居然有几十个躺着。嗯,这就是典型的对象泄漏。

1. 对象泄漏:人话和黑话

官方定义:对象泄漏是指不再被程序使用的对象,因为仍然被 GC Root 直接或间接引用,导致垃圾回收器无法回收。

用人话说:一件垃圾本该进垃圾桶,却被一根看不见的线拴在你手上,永远丢不掉。

我把它分为两类:

类型特征典型场景
短期泄漏生命周期短,随用户操作产生,积累后崩溃Activity 泄漏、Fragment 泄漏
长期泄漏从 App 启动就开始存在,持续增长静态单例持有 Context、线程池未清理

在项目中我遇到过最坑的泄漏:某个自定义 View 注册了 SensorEventListener,忘了在 onDetachedFromWindow 里注销。结果 SensorManager 持有这个 View,View 又持有 Activity,整个 Activity 变成了“僵尸”。

2. 泄漏链:那条看不见的线

对象泄漏从来不是孤立的,它一定有一条从 GC Root 到泄漏对象的引用路径。这条路径就是泄漏链。

举个例子:

public class MyActivity extends AppCompatActivity {
    private static List<Listener> sListeners = new ArrayList<>();

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        sListeners.add(new Listener() { 
            // 匿名内部类持有外部 Activity 的引用
        });
    }
}

你看,sListeners 是静态的(GC Root),它引用匿名类,匿名类又隐式持有 MyActivity.this。这条链就是:GC Root (静态变量) → ArrayList → 匿名Listener → MyActivity。

我排查泄漏时,最爱用的工具就是 MAT 的「Merge Shortest Paths to GC Roots」。选到活动对象,右键一看,整条链清清楚楚。你想想看,如果没有链的概念,你根本不知道泄漏源头在哪。

3. GC Root:垃圾回收的起点

GC 在判断对象是否存活时,不是漫无目的地扫。它从一组根对象(GC Root)出发,沿着引用链走,能走到的就是活着的,走不到的就是可回收的。

常见的 GC Root 有这些:

GC Root 类型(Java/Android)
  • 栈帧中的局部变量(方法执行中)
  • 静态字段(类变量)
  • JNI 引用(native 持有的 Java 对象)
  • 活跃线程(Thread 对象本身以及它持有的对象)
  • 系统类加载器加载的类

我印象很深的一次:同事在 Service 里启动了一个子线程死循环,线程对象没有被清除,于是整个 Service 对象链都成为 GC Root 可达的。Service 退出了?不好意思,线程还在跑,Service 就回收不了。嗯,这其实就是泄漏的一种形式——生命周期不对齐。

4. 可达性分析:GC 的算法

说完了根,再聊聊怎么算“可达”。JVM 和 ART 都用可达性分析算法:从 GC Root 出发,做一次图遍历,所有能到的对象标记为“存活”,其余全是“垃圾”。

这里面有个关键点:引用类型。强引用、软引用、弱引用、虚引用,它们的处理方式不同。

引用类型GC 回收时机泄漏风险
强引用绝不回收(除非不可达)最高
软引用内存不足时回收低
弱引用下一次 GC 就回收极低
虚引用仅用于跟踪回收无

我曾经在优化大图片缓存时,用软引用存 Bitmap,结果发现软引用对象被 GC 频繁回收,反而导致图片加载抖动。后来改成 LRU 缓存 + 弱引用回调,才稳住。这里要提醒:别迷信软引用,它解决不了设计上的死活题。

5. 把概念串起来:一张图搞定

下面这张 SVG 把 GC Root、对象、泄漏链、可达性分析画在了一起。你可以把它当作“泄漏排查的脑图”。

对象泄漏原理:GC Root → 可达性 → 泄漏链 GC Root 静态变量 活跃线程 JNI 引用 局部变量 系统类 存活对象 A 存活对象 B 存活对象 C 泄漏链示例 静态 List 匿名内部类 泄漏的 Activity 可达? 可达性分析判断 从 GC Root 出发,能走到的对象 → 存活(无法回收) | 走不到的 → 垃圾 泄漏链 = 从 GC Root 到泄漏对象的最短路径 强引用切断 = 泄漏修复
我自己的排查口诀:
“先找根,再追链,强引用最危险。生命周期对齐好,静态持有要小心。”

6. 避坑指南与实用建议

我曾经在一个几千万用户的 App 上排查过 Session 泄漏。那时发现一个单例的缓存 Map 存放最近打开的 Activity 的弱引用,但因业务需求,有个回调在 Activity 销毁后仍然被外部持有,导致 Activity 无法回收。修复方法很简单:在 onDestroy 里把回调置空。就是这一行代码,解决了上千个 ANR 警。

注意: 不要以为用了弱引用就万事大吉。弱引用在 GC 执行时会被立即清理,但如果 GC 执行不够频繁,对象可能仍然存活一段时间。真正的解决方案永远是设计上的生命周期绑定——例如使用 Lifecycle Observer 或 Disposable 容器。

这里我给出几个检查泄漏的思路:

  1. 在 onDestroy 中记录日志,确认是否被调用(可以用 LeakCanary 自动检测)。
  2. 如果发现某个 Activity 的实例数只增不减,dump 堆快照用 MAT 查最短 GC 路径。
  3. 关注匿名内部类、Handler、RxJava 订阅、单例等常见泄漏点。

总结一下:对象泄漏的原理并不复杂——就是一条不该存在的引用链。理解 GC Root 和可达性分析,你就能找到那条链,然后砍掉它。嗯,每次我遇到 ANR,第一反应都是打开 LeakCanary 的 leak trace,看看有没有熟悉的泄漏模式。熟悉了这些套路,调优就没那么玄学了。