内存泄漏原理:GC Roots 与可达性分析归纳总结
讲 ANR 分析,绕不开内存泄漏。而理解内存泄漏,得先搞懂 GC 是怎么判断一个对象「该不该死」的。说白了,就是 GC Roots 和可达性分析这套机制。
我刚开始做 Android 时,对 GC 的理解就是「引用计数」,后来发现完全不是那么回事。Java 用的不是引用计数,而是 可达性分析。这个模型简单、高效,但也埋了很多坑。
什么是 GC Roots?
GC Roots 是一组「根引用」的集合。JVM 把这些引用当作起点,顺着它们往下遍历。凡是能碰到的对象,就是「活的」;碰不到的,就是垃圾。
我举个例子。你在 Activity 里 new 了一个对象,Activity 的 this 引用就是 GC Root。只要 Activity 没被销毁,这个对象就一直可达。你想想看,如果 Activity 被 finish 了,但某个静态变量还持有它的引用……嗯,GC Roots 就永远能顺着静态变量找到它,泄漏就发生了。
- 栈帧中的局部变量表(当前正在执行的方法里的引用)
- 静态变量(类加载时就存在的引用)
- JNI 全局引用(Native 代码持有的 Java 对象引用)
- 活跃的线程对象(Thread 对象本身)
- 系统类加载器(BootClassLoader 等)
可达性分析的完整流程
GC 发生时,会从所有 GC Roots 出发,做一次广度优先遍历。遍历过程中,给每个对象做一个「标记」。标记完成后,没被标记的对象就是不可达的,会被回收。
这里有个关键点:软引用、弱引用、虚引用 的处理方式不同。弱引用在 GC 时会被直接清除,软引用则在内存不足时才清除。我在项目中遇到过因为错误使用 SoftReference 导致缓存失效的案例——后来才发现,Android 的 GC 策略比我们想象中激进。
- 根枚举:GC 暂停所有线程(STW),收集当前所有 GC Roots。
- 引用链构建:从 Roots 出发,沿着引用关系递归遍历。
- 对象标记:遍历到的对象标记为「可达」。
- 引用类型判断:对软/弱/虚引用做特殊处理。
- 回收决策:未被标记的对象即为垃圾,计入待回收集合。
引用类型与可达性级别
| 引用类型 | GC 行为 | 可达性级别 |
|---|---|---|
| 强引用 | 永不回收(除非不可达) | 强可达 |
| 软引用 | 内存不足时回收 | 软可达 |
| 弱引用 | 下一次 GC 时回收 | 弱可达 |
| 虚引用 | 任何时候都可能回收,仅用于跟踪 | 虚可达 |
实战:用 MAT 验证可达性
工具是死的,思路是活的。我个人习惯在怀疑泄漏时,先 dump 一份 heap,然后直接看 GC Roots。操作很简单:
// 用 Android Studio 的 Profiler 或 jmap
jmap -dump:format=b,file=leak.hprof <pid>
// 然后用 MAT 打开,点 "Analyze Leaks"
MAT 会自动列出所有「可能泄漏」的对象,并给出到 GC Roots 的引用链。你看哪条链最不正常,就是哪里忘了释放。
本章知识体系结构图
下面这张图梳理了 GC Roots 到对象回收的完整路径:
归纳总结
好了,我们把这章的核心提炼一下。说白了就是一句话:内存泄漏的本质是 GC Roots 意外持有本应回收的对象。
你在分析任何 ANR 或 OOM 时,如果把方向锁定在内存上,第一步就是找到那个「不该活着的强引用」。怎么找?从 GC Roots 往下推,看哪个对象的引用链异常长、异常多。
- 工具选型: LeakCanary 适合日常集成;MAT 适合深入分析;Android Studio Profiler 适合快速预览。
- 思维模型: 每个对象都有生命周期。考虑它「什么时候应该被回收」——如果这个时间点早于实际回收时间,就说明有泄漏。
- 编码习惯: 静态变量慎用、内部类尽量用静态 + 弱引用、及时反注册、清空回调。