一、从引用计数到可达性分析:GC 判定对象存活的两大流派
在讨论垃圾回收(GC)时,第一个要回答的问题是:如何判断一个对象是"垃圾"——即它不再被需要,可以被安全回收? 这个问题看似简单,但回答方式直接影响 GC 的正确性和效率。历史上出现过两种主流方案:引用计数法(Reference Counting)和可达性分析(Reachability Analysis)。
引用计数法:每个对象维护一个引用计数器,当有新的引用指向它时计数器加 1,当引用失效时计数器减 1。当计数器为 0 时,对象被认为是垃圾,可以回收。引用计数法实现简单、判定效率高,但有一个致命缺陷:无法解决循环引用问题。当两个对象互相引用形成环时,即使它们已经不再被外部使用,引用计数永远不会归零,导致内存泄漏。Python 的早期版本使用引用计数,后来不得不增加"标记-清除"来专门处理循环引用。
可达性分析:Java 从设计之初就选择了可达性分析作为 GC 判定对象存活的核心算法。它的基本思想是:从一组被称为 GC Roots 的根对象出发,沿着引用链(Reference Chain)向下搜索,搜索所走过的路径称为引用链。如果一个对象到任何 GC Roots 都没有任何引用链相连,那么这个对象就是不可达的,被判定为可回收对象。

二、GC Roots:哪些对象有资格成为"根"
GC Roots 是可达性分析的起点,是一组必须被 JVM 始终认为是"存活"的引用。JVM 不会回收 GC Roots 本身,也不会回收从 GC Roots 出发通过引用链可达的任何对象。那么,哪些对象可以成为 GC Roots?主要分为以下几类:
1. 虚拟机栈(栈帧中的局部变量表)中引用的对象
每个线程的虚拟机栈中,栈帧的局部变量表里存储的引用类型变量,都是 GC Roots。比如一个方法中写了 Object obj = new Object();,这个 obj 引用指向的对象就是通过 GC Roots 可达的。当方法执行完毕,栈帧弹出,obj 不再作为 GC Roots,它指向的对象就可能变成不可达。
2. 方法区中静态变量引用的对象
类的 static 变量引用的对象,作为 GC Roots 存在。这些引用在类加载后就一直存在,直到类被卸载。这也是为什么静态变量持有大对象引用容易导致内存泄漏——因为静态变量作为 GC Roots,只要类不被卸载,它引用的对象就永远不会被 GC。
3. 方法区中常量引用的对象
运行时常量池中的常量引用,如字符串常量池中的字符串引用,也是 GC Roots。
4. 本地方法栈中 JNI 引用的对象
Native 方法中引用的 Java 对象,通过 JNI(Java Native Interface)的 Local Reference 或 Global Reference 持有,也属于 GC Roots。
5. JVM 内部的引用
包括基本数据类型的 Class 对象(如 int.class、void.class)、常驻的异常对象(如 NullPointerException)、系统类加载器等。这些是 JVM 运行时必须始终存活的对象。
6. 所有被同步锁(synchronized)持有的对象
被 synchronized 锁持有的对象,即使没有其他引用指向它,在锁释放之前也不能被 GC 回收。
7. 反映 JVM 内部情况的 JMXBean、JVMTI 回调等
这些是 JVM 监控和管理接口持有的对象引用。

三、可达性分析的工作流程:STW 与枚举根节点
可达性分析的具体执行过程,涉及两个关键步骤:枚举根节点和并发标记。
枚举根节点(Root Scanning)
枚举根节点的过程需要在一个一致性的快照中进行——即在整个分析过程中,引用关系不能发生变化。如果一边分析一边有线程在修改引用关系,分析结果就会不准确。因此,枚举根节点通常需要暂停所有用户线程(Stop-The-World,简称 STW)。
HotSpot 使用 OopMap(Ordinary Object Pointer Map)数据结构来加速根节点枚举。OopMap 记录了栈帧中哪些位置是引用类型,这样 GC 在枚举根节点时不需要遍历整个栈帧,只需要根据 OopMap 的标记精确定位即可。这大大缩短了 STW 的时间。
并发标记(Concurrent Marking)
从 GC Roots 出发,沿着引用链遍历整个对象图,标记所有可达对象。这个过程在 CMS 和 G1 等现代 GC 中,大部分工作可以与用户线程并发执行(只有初始标记和最终标记需要 STW)。并发标记的核心挑战是:在标记过程中,用户线程可能正在修改引用关系,导致标记结果出现偏差。为了处理这个问题,GC 采用三色标记法(Tri-color Marking),将在后续章节详细展开。
最终判定:标记-清除
标记完成后,所有未被标记的对象就是不可达对象,可以被安全回收。但如果一个对象覆盖了 finalize() 方法且从未被调用过,它会在被回收前被放入一个低优先级的 Finalizer 线程队列中,等待 finalize() 执行。在 finalize() 中,对象可以"自救"——将自己重新赋值给某个 GC Roots 可达的引用,这样它就能逃脱本次 GC。但 finalize() 只会被调用一次,第二次 GC 时对象不会再有机会自救。finalize() 已被 JDK 9 标记为 @Deprecated,不推荐使用。

四、引用链与对象存活判定
"引用链"是可达性分析的核心概念。从 GC Roots 出发,沿着引用关系逐级遍历,形成一条或多条引用路径。只要一个对象在任意一条从 GC Roots 出发的引用路径上,它就是可达的、存活的。
但这里有一个重要的细节:GC Roots 的选取和引用链的遍历,都是在 GC 发生时进行的,而不是实时维护的。这意味着,一个对象在 GC 发生前可能已经"不可达"了,但直到下一次 GC 发生时,JVM 才会发现它并回收它。这也是为什么内存使用量在 GC 前会持续增长——大量不可达对象在等待 GC 来清理。
此外,引用链的分析还涉及跨代引用的问题。在分代 GC 中,新生代 GC(Minor GC)只扫描新生代,但新生代中的对象可能被老年代中的对象引用——这些老年代到新生代的引用就是跨代引用。如果 Minor GC 每次都要扫描整个老年代来查找跨代引用,效率会非常低。HotSpot 的解决方案是记忆集(Remembered Set):在新生代 GC 时,只需要扫描老年代中那些在 Remembered Set 中记录了的"脏"区域,而不是扫描整个老年代。
五、面试追问链
从"GC 如何判断一个对象是否可以被回收"这个基础问题出发,逐层追问:引用计数法和可达性分析的区别是什么,为什么 Java 选择可达性分析而不是引用计数法;GC Roots 包含哪些类型,各自在什么场景下成为根节点;可达性分析的工作流程是什么,枚举根节点为什么需要 STW;OopMap 是什么,它如何加速根节点枚举;三色标记法的基本思想是什么,为什么并发标记中需要它;跨代引用如何影响分代 GC 的效率,Remembered Set 是什么,如何解决这个问题;对象在 finalize 中自救的机制是什么,为什么 finalize 已被废弃。

延伸阅读
- 《深入理解 Java 虚拟机》(周志明)第 3.2 节,详细介绍了可达性分析和 GC Roots
- HotSpot 源码
src/hotspot/share/gc/shared目录下的 GC Roots 实现 - 「04 JVM 原理与调优」系列下一篇(058)将深入垃圾回收算法(标记-清除、标记-整理、复制)
问答(不计入阅读时长)
Q1:引用计数法为什么无法解决循环引用问题?Java 有没有任何地方使用了引用计数?
A:循环引用问题:对象 A 引用对象 B,对象 B 引用对象 A,形成环。假设环外没有任何引用指向 A 或 B,此时 A 和 B 的引用计数都是 1(它们互相引用),永远不会归零,因此永远不会被 GC 回收,即使它们实际上已经不可达。Java 的 GC 整体上不使用引用计数,但有一个例外:堆外内存(Direct Memory) 的管理中,java.nio.Bits 类内部使用了一个 AtomicLong 的引用计数器来跟踪 DirectByteBuffer 分配的堆外内存总量,当计数器超过 -XX:MaxDirectMemorySize 时会抛出 OOM。但这个引用计数是用来限制堆外内存总量的,不是用来判定对象是否可回收的——堆外内存的回收仍然依赖虚引用(Cleaner)机制。
Q2:为什么枚举根节点需要 STW(Stop-The-World)?如果不停机,会发生什么?
A:枚举根节点需要在一个"一致性快照"中进行——即在整个分析过程中,引用关系不能发生变化。如果不停机,用户线程可能正在修改引用关系:比如一个线程正在执行 obj = null,另一个线程正在执行 newObj.field = oldObj,如果在枚举根节点时引用关系处于变化中,GC 可能会漏标某些可达对象(导致它们被错误回收,造成程序崩溃),或者误标某些不可达对象(导致它们无法被回收,造成内存泄漏)。OopMap 虽然能加速定位引用位置,但无法解决引用关系的变化问题。因此,枚举根节点(初始标记)在所有 GC 算法中都需要 STW,只是现代 GC 通过优化(如 G1 的初始标记和 Minor GC 一起做)让 STW 的时间尽可能短。
Q3:GC Roots 中,"被同步锁持有的对象"为什么也属于 GC Roots?锁释放后,这些对象会被回收吗?
A:synchronized 锁持有的对象必须作为 GC Roots,是因为锁机制依赖对象头中的 Mark Word 来存储锁状态(偏向锁、轻量级锁、重量级锁)。如果锁持有的对象被 GC 回收了,正在等待这个锁的线程就会失去锁的载体,导致 JVM 状态不一致。锁释放后,这个对象就不再是 GC Roots 了——如果此时没有其他 GC Roots 引用它,它就会在下一次 GC 时被正常回收。一个常见的误解是:锁对象本身占内存很小,但锁对象关联的同步资源(如 ObjectMonitor)可能占用较多内存,这些资源在锁释放后可以被回收。
Q4:如果两个对象互相引用,但均不可达,它们会被 GC 回收吗?这和引用计数法的区别在哪里?
A:会被回收。可达性分析从 GC Roots 出发,沿着引用链向下搜索。如果两个对象互相引用形成环,但环上没有任何对象能通过引用链连接到 GC Roots,那么整个环上的所有对象都是不可达的,都会被 GC 回收。这就是可达性分析相对于引用计数法的核心优势:它不关心对象之间的互相引用,只关心"从 GC Roots 出发能不能到达"。引用计数法关心的是"每个对象被多少个引用指向",这两个视角完全不同。面试中常问"Java 有没有循环引用问题",正确答案是:Java 的 GC 不存在循环引用问题,因为可达性分析天然能处理循环引用。
Q5:OopMap 具体是如何加速 GC Roots 枚举的?如果没有 OopMap,GC 会怎么做?
A:OopMap 记录了每个栈帧中哪些位置(偏移量)是引用类型。没有 OopMap 时,GC 在枚举根节点时需要遍历整个栈帧,逐个检查每个栈槽位——判断它是基本类型还是引用类型。这个判断很困难:因为栈帧中存储的是原始字节,从字节本身无法区分一个 4 字节的值是 int 还是对象的引用。OopMap 在 JIT 编译时生成,在"安全点"(Safepoint)记录下当前栈帧的引用类型位置信息,GC 在安全点触发时直接读取 OopMap,就能精确定位所有引用位置,不需要遍历和猜测。这大大缩短了根节点枚举的 STW 时间,对降低 GC 停顿至关重要。
Q6:三色标记法中,为什么需要"写屏障"(Write Barrier)?如果没有写屏障,并发标记会出现什么问题?
A:三色标记法将对象分为白(未访问)、灰(已访问但子节点未扫描完)、黑(已访问且子节点全部扫描完)三种颜色。并发标记时,用户线程可能在修改引用关系,导致两种错误:漏标(一个本应被标记为存活的对象被遗漏)和多标(一个本应被回收的对象被标记为存活)。漏标会导致程序崩溃,不可接受。写屏障(Write Barrier)是一种在引用赋值时插入的额外代码,用于记录引用关系的变化,防止漏标。具体来说,写屏障会在赋值操作前或后执行一段逻辑,将变化通知给 GC。CMS 和 G1 使用写屏障 + 增量更新(Incremental Update)或 SATB(Snapshot At The Beginning)技术来保证并发标记的正确性。如果没有写屏障,并发标记的结果不可靠,GC 就只能全程 STW 来保证正确性。
Q7:跨代引用问题中,Remembered Set 和 Card Table 是什么关系?它们是如何工作的?
A:Card Table 是 Remembered Set 的一种实现方式。Card Table 将整个堆内存划分为固定大小的卡片(Card,通常 512 字节),每个卡片对应一个标志位。当老年代中某个卡片内的对象引用关系发生变化(如老年代对象的字段被赋值为指向新生代对象的引用),写屏障会将这个卡片标记为"脏"(Dirty)。在 Minor GC 时,GC 只需要扫描 Card Table 中被标记为脏的卡片对应的老年代区域,而不需要扫描整个老年代,从而大大减少了跨代引用扫描的开销。Remembered Set 是逻辑概念,Card Table 是物理实现。G1 中的 Remembered Set 更进一步,每个 Region 维护一个 Remembered Set,记录"哪些 Region 中的对象引用了当前 Region 中的对象",精度更高。
Q8:**finalize()**方法在可达性分析中的"自救"机制是什么?为什么它已被 JDK 9 标记为**@Deprecated**?
A:当一个对象被判定为不可达时,如果它覆盖了 finalize() 方法且从未被调用过,GC 不会立即回收它,而是将它放入一个低优先级的 Finalizer 线程队列中。在 finalize() 方法中,对象可以重新将自己赋值给某个 GC Roots 可达的引用(如静态变量),从而"自救"逃脱本次 GC。但 finalize() 只会被调用一次,第二次 GC 时对象不会再有机会自救。finalize() 被废弃的原因:(1)不确定性——无法保证 finalize() 何时被调用甚至是否被调用;(2)性能问题——finalize() 会延迟对象的回收,导致 GC 效率降低;(3)Finalizer 线程没有超时机制,如果 finalize() 方法中发生了死锁或长时间阻塞,会导致所有待回收对象堆积,引发 OOM。推荐替代方案:try-with-resources(AutoCloseable)、Cleaner(基于虚引用)或 java.lang.ref 包。finalize() 在 JDK 18 中已被标记为 @Deprecated(forRemoval=true),计划在未来版本中彻底移除。