对象泄漏原理:什么是对象泄漏、泄漏链、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 有这些:
- 栈帧中的局部变量(方法执行中)
- 静态字段(类变量)
- 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、对象、泄漏链、可达性分析画在了一起。你可以把它当作“泄漏排查的脑图”。
“先找根,再追链,强引用最危险。生命周期对齐好,静态持有要小心。”
6. 避坑指南与实用建议
我曾经在一个几千万用户的 App 上排查过 Session 泄漏。那时发现一个单例的缓存 Map 存放最近打开的 Activity 的弱引用,但因业务需求,有个回调在 Activity 销毁后仍然被外部持有,导致 Activity 无法回收。修复方法很简单:在 onDestroy 里把回调置空。就是这一行代码,解决了上千个 ANR 警。
这里我给出几个检查泄漏的思路:
- 在 onDestroy 中记录日志,确认是否被调用(可以用 LeakCanary 自动检测)。
- 如果发现某个 Activity 的实例数只增不减,dump 堆快照用 MAT 查最短 GC 路径。
- 关注匿名内部类、Handler、RxJava 订阅、单例等常见泄漏点。
总结一下:对象泄漏的原理并不复杂——就是一条不该存在的引用链。理解 GC Root 和可达性分析,你就能找到那条链,然后砍掉它。嗯,每次我遇到 ANR,第一反应都是打开 LeakCanary 的 leak trace,看看有没有熟悉的泄漏模式。熟悉了这些套路,调优就没那么玄学了。