GC 基础原理:垃圾回收的概念与根可达性分析

说起垃圾回收,很多同学第一反应就是「自动释放内存,不用管」。嗯,这话没错,但做性能优化的人,不能只停留在会用层面。你得知道 JVM/ART 到底怎么判定一个对象该不该回收。我刚开始接触 GC 调优时,也踩过不少坑,今天就带你从头捋一遍。

什么是垃圾回收?

说白了,垃圾回收就是自动帮我们回收那些「再也用不到的对象」占用的内存。

手动管理内存,比如 C/C++ 里的 malloc/free,一旦忘记 free 就内存泄漏了。Java/Android 的 GC 解决了这个痛点,但它自己也有代价——停顿、CPU 开销。所以你要理解它的工作原理,才能做到「抑制 GC 对启动耗时的干扰」。

GC 的核心问题就两个:

  • 哪些对象是垃圾?
  • 怎么回收垃圾?

这一章我们重点解决第一个问题。

核心要点: 垃圾回收 ≠ 内存优化。它只是工具,用好它才能让 App 启动更快。

根可达性分析:判断对象生死的唯一标准

早年有些老方案是用引用计数,循环引用就死锁。现代主流 GC(包括 HotSpot、ART)都用根可达性分析(Reachability Analysis)来判断对象是否存活。

它的逻辑特别简单:

  1. 从一组固定的「GC Roots」出发。
  2. 沿着引用链(Reference Chain)遍历所有对象。
  3. 能到达的对象就是「活的」,不能到达的就是「垃圾」。

我举个例子你就明白了。想象一个社交网络,GC Roots 是几个大佬,从他们出发能联系到的人都是活跃用户;那些完全孤立、没有任何联系的人……嗯,就可以删号了。

注意: 这里说的「可达」不光是强引用,软引用、弱引用、虚引用也要考虑,不过我们后续章节再细聊。简单场景下,只看强引用就够了。

GC Roots 到底包含哪些?

我记得第一次看 JVM 规范时,被一堆术语搞晕。后来自己总结成一张表,好记多了:

GC Roots 类型 说明 常见场景
虚拟机栈(栈帧中的本地变量表) 方法中的局部变量、参数引用的对象 Activity 的 onCreate 里 new 的对象
静态变量(方法区/元空间) 类静态属性引用的对象 Singleton 持有的全局对象
常量池引用(字符串常量等) 编译期常量或运行时常量池中的引用 "Hello" 字符串常量池中的对象
JNI 引用(Native 方法) Java 的 native 方法中创建的全局/局部引用 JNI 中 NewGlobalRef 持有的对象
活跃线程(Thread 对象) 所有正在运行的线程本身 主线程、子线程

为什么搞这么复杂?说白了,GC 必须确保「这些根是不能被回收的」,否则整个引用链都塌了。我在项目里就遇到过:一个静态变量不小心持有了 Activity 的引用,导致 Activity 退出了也无法被回收——典型的内存泄漏。根源就是那个 Activity 被 GC Roots 中的静态变量直连了,永远可达。

我曾经踩过的坑: 一个全局的 EventBus 注册监听器没有及时注销,静态内部类隐式持有外部 Activity 引用。结果 Activity 退出了,但 EventBus 的静态集合里还留着这个监听器,导致 Activity 对象一直可达,GC 根本回收不了。查了几个小时的 heap dump 才找到元凶。

可达性分析到底怎么跑?

你想想看,整个过程就像一次图遍历。GC 线程从所有 GC Roots 出发,沿着引用逐步做 BFS(广度优先)或 DFS(深度优先)。每个被访问到的对象标记为存活。遍历结束后,没被打标记的对象就是垃圾。

伪代码大致是这样的:

// 伪代码示意,非真实实现
Set aliveSet = new HashSet<>();
for (Object root : gcRoots) {
    markReachable(root, aliveSet);
}
// aliveSet 中包含的对象保留
// 剩下的对象就是垃圾,可以回收

void markReachable(Object obj, Set alive) {
    if (obj == null || alive.contains(obj)) return;
    alive.add(obj);
    for (Object ref : getAllReferences(obj)) {
        markReachable(ref, alive);
    }
}

当然,真实 JVM/ART 的实现更复杂,有并发标记、写屏障、彩色指针等优化。但核心思想就是这个——以根为起点,扩散标记。

一张图看懂根可达性

我画了张 SVG 图,把整个过程串起来。你可以直观看到哪些对象会被标记为存活,哪些会成为垃圾。

根可达性分析示意图 GC Roots 栈帧局部变量 静态变量 JNI 引用 对象 A(存活) 对象 B(存活) 对象 C(存活) 对象 D(存活) 对象 E(存活) 对象 X(垃圾) 对象 Y(垃圾) 无引用链→ 无引用链→ 💡 标记流程 从 GC Roots 出发,遍历所有引用链 能到达的标记为存活,未到达的为垃圾 ⚠️ 关键点 即使对象 X 被对象 C 一度引用过 只要 C 不再引用 X,X 就不可达

从图上能看出来,对象 D、E 是从存活对象 A、B 延伸出去的,也属于存活。而对象 X、Y 没有任何 GC Roots 能到达,它们就是待回收的垃圾。

小技巧: 在分析 Android 启动耗时时,你可以在关键启动路径上手动触发一次 GC,然后观察堆中是否有意料之外的大对象残留。我常用 Android Studio 的 Memory Profiler + 手动 dump 堆转储,配合根可达性分析找出那些「本应死亡却被误留」的对象。

为什么启动耗时和根可达性有关?

你想想看,启动阶段需要快速加载各种类、初始化单例、创建 View 对象。如果 GC 在启动期间频繁做可达性分析,遍历大量对象,一定会造成 STW(Stop-The-World)停顿,影响用户感知。

有些对象其实只在启动初期用一下,之后就没用了。但如果你不小心让它们被 GC Roots「挂住」,GC 就认为它们是活的,不会回收。结果堆里堆积了大量垃圾,触发更频繁的 GC……恶性循环。

所以,优化启动耗时的一个重要手段就是:缩短 GC Roots 集合的大小,减少可达性分析的遍历范围。具体怎么做,后面章节会细讲。

小结

这一章我们搞明白了 GC 最底层的判断标准——根可达性分析。记住几个要点:

  • GC Roots 是分析的起点,包括栈帧变量、静态变量、JNI 引用等。
  • 从根出发能到达的对象才存活,否则就是垃圾。
  • 理解这个机制,你才能看懂后续的 GC 日志和堆转储。
  • 启动优化中,减少不必要的根引用是抑制 GC 的关键。