GC 基础原理:垃圾回收的概念与根可达性分析
说起垃圾回收,很多同学第一反应就是「自动释放内存,不用管」。嗯,这话没错,但做性能优化的人,不能只停留在会用层面。你得知道 JVM/ART 到底怎么判定一个对象该不该回收。我刚开始接触 GC 调优时,也踩过不少坑,今天就带你从头捋一遍。
什么是垃圾回收?
说白了,垃圾回收就是自动帮我们回收那些「再也用不到的对象」占用的内存。
手动管理内存,比如 C/C++ 里的 malloc/free,一旦忘记 free 就内存泄漏了。Java/Android 的 GC 解决了这个痛点,但它自己也有代价——停顿、CPU 开销。所以你要理解它的工作原理,才能做到「抑制 GC 对启动耗时的干扰」。
GC 的核心问题就两个:
- 哪些对象是垃圾?
- 怎么回收垃圾?
这一章我们重点解决第一个问题。
根可达性分析:判断对象生死的唯一标准
早年有些老方案是用引用计数,循环引用就死锁。现代主流 GC(包括 HotSpot、ART)都用根可达性分析(Reachability Analysis)来判断对象是否存活。
它的逻辑特别简单:
- 从一组固定的「GC Roots」出发。
- 沿着引用链(Reference Chain)遍历所有对象。
- 能到达的对象就是「活的」,不能到达的就是「垃圾」。
我举个例子你就明白了。想象一个社交网络,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 中的静态变量直连了,永远可达。
可达性分析到底怎么跑?
你想想看,整个过程就像一次图遍历。GC 线程从所有 GC Roots 出发,沿着引用逐步做 BFS(广度优先)或 DFS(深度优先)。每个被访问到的对象标记为存活。遍历结束后,没被打标记的对象就是垃圾。
伪代码大致是这样的:
// 伪代码示意,非真实实现
Set
当然,真实 JVM/ART 的实现更复杂,有并发标记、写屏障、彩色指针等优化。但核心思想就是这个——以根为起点,扩散标记。
一张图看懂根可达性
我画了张 SVG 图,把整个过程串起来。你可以直观看到哪些对象会被标记为存活,哪些会成为垃圾。
从图上能看出来,对象 D、E 是从存活对象 A、B 延伸出去的,也属于存活。而对象 X、Y 没有任何 GC Roots 能到达,它们就是待回收的垃圾。
为什么启动耗时和根可达性有关?
你想想看,启动阶段需要快速加载各种类、初始化单例、创建 View 对象。如果 GC 在启动期间频繁做可达性分析,遍历大量对象,一定会造成 STW(Stop-The-World)停顿,影响用户感知。
有些对象其实只在启动初期用一下,之后就没用了。但如果你不小心让它们被 GC Roots「挂住」,GC 就认为它们是活的,不会回收。结果堆里堆积了大量垃圾,触发更频繁的 GC……恶性循环。
所以,优化启动耗时的一个重要手段就是:缩短 GC Roots 集合的大小,减少可达性分析的遍历范围。具体怎么做,后面章节会细讲。
小结
这一章我们搞明白了 GC 最底层的判断标准——根可达性分析。记住几个要点:
- GC Roots 是分析的起点,包括栈帧变量、静态变量、JNI 引用等。
- 从根出发能到达的对象才存活,否则就是垃圾。
- 理解这个机制,你才能看懂后续的 GC 日志和堆转储。
- 启动优化中,减少不必要的根引用是抑制 GC 的关键。