内存泄漏原理: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 就永远能顺着静态变量找到它,泄漏就发生了。

GC Roots 的典型来源
  • 栈帧中的局部变量表(当前正在执行的方法里的引用)
  • 静态变量(类加载时就存在的引用)
  • JNI 全局引用(Native 代码持有的 Java 对象引用)
  • 活跃的线程对象(Thread 对象本身)
  • 系统类加载器(BootClassLoader 等)

可达性分析的完整流程

GC 发生时,会从所有 GC Roots 出发,做一次广度优先遍历。遍历过程中,给每个对象做一个「标记」。标记完成后,没被标记的对象就是不可达的,会被回收。

这里有个关键点:软引用、弱引用、虚引用 的处理方式不同。弱引用在 GC 时会被直接清除,软引用则在内存不足时才清除。我在项目中遇到过因为错误使用 SoftReference 导致缓存失效的案例——后来才发现,Android 的 GC 策略比我们想象中激进。

  1. 根枚举:GC 暂停所有线程(STW),收集当前所有 GC Roots。
  2. 引用链构建:从 Roots 出发,沿着引用关系递归遍历。
  3. 对象标记:遍历到的对象标记为「可达」。
  4. 引用类型判断:对软/弱/虚引用做特殊处理。
  5. 回收决策:未被标记的对象即为垃圾,计入待回收集合。

引用类型与可达性级别

引用类型 GC 行为 可达性级别
强引用 永不回收(除非不可达) 强可达
软引用 内存不足时回收 软可达
弱引用 下一次 GC 时回收 弱可达
虚引用 任何时候都可能回收,仅用于跟踪 虚可达
我曾经踩过的坑:一个 Activity 泄漏导致整个 View 树全部泄漏。原因就是内部类持有外部 Activity 的隐式引用。你以为只是丢了几个对象?实际上整个布局、Drawable、甚至 Bitmap 都跟着活了下来。用 MAT 或 LeakCanary 一看,GC Roots 链路长长的。

实战:用 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 链上全是 static 字段,那八成是单例或 Application 级别的泄漏。如果链上有 Activity 的 this,说明 Activity 没被销毁。

本章知识体系结构图

下面这张图梳理了 GC Roots 到对象回收的完整路径:

GC Roots 可达性分析流程图 GC Roots 集合 栈帧局部变量 静态字段 JNI 全局引用 活跃线程 引用链遍历 (广度优先) 可达对象 (标记为存活) 不可达对象 (被回收) 关键点 • 软/弱引用在标记过程中会特殊处理 • 强引用只要可达就不会被回收 • 泄漏本质上就是 GC Roots 意外持有 了本应销毁的对象引用 • 常见的泄漏模式:静态持有 Activity、 内部类/匿名类、注册未反注册等 泄漏典型案例 1. 静态变量持有 Activity: sRef = activity; 2. 匿名内部类隐式引用: new Runnable() { run(){ ... }} 内部持有外部 Activity 引用 3. Handler/Looper 泄漏: Message 未处理完成,持有 Activity

归纳总结

好了,我们把这章的核心提炼一下。说白了就是一句话:内存泄漏的本质是 GC Roots 意外持有本应回收的对象。

你在分析任何 ANR 或 OOM 时,如果把方向锁定在内存上,第一步就是找到那个「不该活着的强引用」。怎么找?从 GC Roots 往下推,看哪个对象的引用链异常长、异常多。

  • 工具选型: LeakCanary 适合日常集成;MAT 适合深入分析;Android Studio Profiler 适合快速预览。
  • 思维模型: 每个对象都有生命周期。考虑它「什么时候应该被回收」——如果这个时间点早于实际回收时间,就说明有泄漏。
  • 编码习惯: 静态变量慎用、内部类尽量用静态 + 弱引用、及时反注册、清空回调。
避坑指南(我个人的血泪教训): 不要迷信弱引用。弱引用虽然能帮你打破强引用链,但它本身也有生命周期,如果弱引用对象被意外恢复(比如被其他强引用再引用),GC 依然不会回收。最好的办法是从设计上避免长生命周期对象持有短生命周期对象的引用。