刚开始准备JVM面试的人,几乎都会被这个组合拳打懵:一边是“垃圾回收的时候怎么确定谁是垃圾”,一边是“你知道什么是GC Roots吗”。这两个问题看着像两件事,实际上考的是同一个底层模型。我在模拟面试里问过很多候选人,八成能说出“引用计数法”“可达性分析”这两个词,但只要往深问一句“那为什么不用引用计数”“GC Roots到底有哪些”,就开始卡壳了。
这篇文章就把这条线彻底理顺。我会从判定垃圾的两种方案讲起,把GC Roots的五类经典来源拆开,再结合面试里真实的高频追问、手写代码场景推演,以及不同回收器之间的处理差异,给你一套可以直接复述的答题思路。无论你是准备校招、社招,还是在工作中想把GC日志看明白,这条链路都值得一次性啃透。
1. 先换个角度看垃圾回收:它找的不是“垃圾”,而是“活人”
很多人理解JVM垃圾回收,第一反应是“JVM扫描堆,看看谁没人用了,就把它清掉”。这个直觉对了一半,但恰恰是这“一半”让后面的理解全歪了。真正的垃圾回收器,绝大多数情况下不直接去找垃圾,而是先找到“所有还活着的对象”,剩下的、没被找到的,才被定义为垃圾。这就好比学校查寝:辅导员不会挨个宿舍问“谁是旷宿的”,而是先核对“谁在宿舍里”,不在名单上的人,直接记违纪。
这个思路上的反转很重要,因为它直接影响GC的算法设计。如果是“扫垃圾”,回收器就必须对堆里每个对象做有效性判断,成本极高,而且“有效”的定义还会随着业务代码变化;如果是“找活对象”,只需要从一组固定起点出发,沿引用关系走一圈,走得到的就是活的,走不到的就是可回收的。判定规则简单、确定,并且能通过增量更新、染色标记等手段优化。
在JVM里,这套“找活对象”的算法就是可达性分析(Reachability Analysis)。与之相对的、面试里一定会被拎出来对比的,是引用计数法(Reference Counting)。
1.1 引用计数法:想到它的人很多,但没人敢在JVM里用它
引用计数法的逻辑非常直观:每个对象内部维护一个计数器,每当有一个新引用指向它,计数器加一;当某个引用失效,计数器减一。计数器归零,说明没有任何引用指向它,对象就可以被回收。乍一看,这简直是“完美方案”——回收即时、不用停顿扫描,Python的早期内存管理就是基于这个思路做的。
但它有两个硬伤,直接导致了JVM最终放弃这条路线。
第一个硬伤是循环引用。两个对象内部各持有对方的引用,外部对它们的引用全部断开之后,两个对象的引用计数依然是1,这个“引用环”就永远无法归零,成了堆里的顽固垃圾。经典的代码如下:
class Node { Node next; } public void createCycle() { Node a = new Node(); Node b = new Node(); a.next = b; b.next = a; }方法结束后,局部变量a、b出栈,外部不再有任何人引用这两个Node对象。如果JVM用引用计数法,它们的计数器互指,都不会归零,于是这两个对象永远回收不掉。而在可达性分析里,由于没有从GC Roots出发的引用链能到达它们,它们会被判死并回收。
第二个硬伤是计数更新的代价。Java代码里引用赋值非常频繁:obj.attr = other、列表add、函数传参……每次赋值都伴随计数器的加减。更麻烦的是,多线程环境下这个计数器必须用原子操作,CAS加锁级别的开销压在每一次普通赋值上,性能损失是所有应用层都背不起的。这也是为什么现代JVM全部采用可达性分析,把“引用变化”的记账成本转移到GC停顿阶段去做。
1.2 可达性分析的基础模型:从根出发,走引用链
可达性分析的基本过程是:选择一组称为“GC Roots”的根节点,从这组根节点出发,沿着对象之间的引用关系向下遍历,凡是能遍历到的对象,都标记为“存活”。遍历结束后,堆里剩下的、没被标记的对象,就是垃圾。
下面这个文本结构图可以直观表示引用链:
GC Roots ├──> 对象A ──> 对象B ──> 对象C └──> 对象D对象A、B、C、D都在从根出发的引用链上,属于存活对象。对象E和F没有箭头指向它们,没有任何引用链从根出发能到达,它们就是待回收垃圾。注意,E和F不一定是“没被别人引用”,它们可能被其他垃圾对象引用,但只要这个引用链的源头不是GC Roots,链条就断了,照样回收。
这里必须强调一个细节:不可达 ≠ 立即回收。对象被判为不可达之后,还会进入一个“缓刑期”,涉及finalize机制和两次标记,这个我在第3节单独展开。很多候选人知道可达性分析,却说“不可达的下一帧就会被清掉”,这个表述不严谨,遇到面试官追问就露馅了。
2. GC Roots到底有哪些:经典五类加现代补充,一条条说清
虚拟机的规范文档里,GC Roots的定义可以概括为“堆外指向堆内的引用”。也就是说,所有能作为根的引用,它的存放位置都不在Java堆里,而是位于栈、方法区、本地方法栈这些堆外区域。下面这张表是我觉得比较完整的整理,面试按这个顺序背不会乱:
| 根类型 | 存放位置 | 典型例子 | 存活特征 |
|---|---|---|---|
| 虚拟机栈中引用的对象 | 线程栈帧的局部变量表 | 正在执行方法里的局部变量、方法参数 | 线程未结束、栈帧未弹出时就存在 |
| 方法区中静态属性引用的对象 | 类的静态字段(JDK7以后移入堆,但逻辑上仍是根) | private static Config config = new Config(); | 类未被卸载、静态字段未被置空就存在 |
| 方法区中常量引用的对象 | 运行时常量池 | private static final String S = "hello";、字符串常量池引用 | 常量未被替换就存在 |
| 本地方法栈中JNI引用的对象 | JNI本地方法栈 | Native方法中的局部引用、全局引用 | Native方法栈帧存活期间存在 |
| 活跃线程对象 | 线程自身 | 正在运行的Thread对象 | 线程存活期间存在 |
| JVM内部引用 | JVM内部数据结构 | 类加载器、基本类型包装类的Class对象、常驻异常对象、系统类加载器 | JVM运行期间存在 |
| 被synchronized持有的对象 | Monitor锁内部 | 所有被同步锁持有的对象引用 | 锁未释放期间存在 |
| JMXBean、本地缓存等管理对象 | JVM管理子系统 | 平台MBean、JIT代码缓存中的引用 | 注册期间存在 |
记忆口诀其实就一句话:程序正在用的、类还挂着的、锁还没释放的、底层系统要留着的,都是根。
2.1 栈上的引用和静态引用:一对总被放在一起考的数据
面试官喜欢把“虚拟机栈中的引用”和“方法区静态属性引用”放在一起问,因为它们正好展示了“临时活”和“长期活”的差别。
栈上的引用,比如方法里的局部变量,生命周期严格限制在栈帧存续期间。方法一调用,局部变量表里的引用就是一根根;方法一返回,这些引用全部失效,原本被引用的对象如果除此之外没有别的引用链,下一次GC就会被收掉。这解释了为什么一个方法内部创建的、size很大的临时对象,方法结束后就没了:不是它“该被回收”,而是它不再有任何根可达。
静态引用则不一样。static字段被类持有,类的生命周期通常和JVM一样长。只要这个静态字段没有被显式置空,它指向的对象就始终有一条从根出发的引用链,无论对象本身看起来多么“没人用”,它都不会被回收。常见的内存泄漏场景——长生命周期对象持有短生命周期对象的引用、静态集合不断add数据,本质都是把本应“从根不可达”的对象,硬生生拴在了根上。
2.2 常量和JNI引用的冷门细节:容易被忽略但确实考过
常量引用的典型例子是static final的字符串或大对象。例如private static final User DEFAULT_USER = new User();,这个User对象被常量引用持有,是根可达的。这里的理解难点在于“final”只是不允许重新赋值,但对象内部状态可以变,引用链本身不会断。
JNI引用分成局部引用和全局引用两种。局部引用在Native方法返回后自动释放,所以它作为GC Roots的时间窗口很短;全局引用需要显式调用DeleteGlobalRef释放,不释放就会出现堆里的对象一直被Native层拴住、Java层却看不到任何引用的“离奇泄漏”。排查这种泄漏,光看Java堆栈没用,得结合Native内存分析。
面试里还有一种进阶问法:“根对象自己会不会被回收?”答案是不存在“回收根”这个说法。GC Roots是JVM判定时使用的起点集合,不是一个被管理的对象实例。你可以把一个曾经是根的对象从静态字段移除,它就不再是根了,但“根”本身是一个位置和引用关系,不是堆里的实体。
3. 面试里最容易答错的六个追问:每个都可能让你当场卡壳
很多候选人会背GC Roots的定义,背得滚瓜烂熟,但面试官一追问就懵。这些追问不见得有多难,难在它们考察的是“你到底理解了这个机制,还是只记住了结论”。
3.1 追问一:可达性分析是每次GC都扫描整个堆吗
不是。这是理解GC Roots的关键。可达性分析本质上是对引用链的遍历,不是“全堆无差别扫描”。它从GC Roots出发,顺着引用字段访问对象,访问过的对象不会重复扫。因此,真正的工作量取决于“存活对象的数量及引用关系复杂度”,而不是堆的总大小。堆里有1万个垃圾对象、100个存活对象,可达性分析的成本近似于遍历那100个活对象。
但要注意,主流的JVM实现会有一些优化。比如用OopMap记录每个安全点下哪些位置存放着对象引用,避免每次GC时去逐个扫描栈帧里的每一个槽位判断“这是不是引用”。所以面试时能主动说出OopMap、安全点(SafePoint)这两个词,通常会是加分项。
3.2 追问二:GC Roots是怎么被“快速找出来”的
这里要说到OopMap和SafePoint的配合。
JVM里,每个线程的栈帧里哪些位置是引用、哪些是原始类型,不是运行时临时分析出来的。JIT编译时会在特定的位置生成OopMap数据,记录当前栈帧和寄存器中哪些槽位是引用。GC发生时,JVM只需要让所有线程跑到最近的安全点停下来,然后读各自栈帧的OopMap,就能快速枚举出栈上的GC Roots,而不需要逐个偏移扫描内存。
安全点就是所有线程允许被暂停的位置,通常选在方法调用、循环回跳、异常跳转等指令上。合理设置安全点,是为了兼顾“GC要尽快让线程停下”和“线程不能每执行一条指令都检查一次是否需要GC”。这个机制在解释执行阶段和JIT编译阶段有差异,但整体链路是一致的。
3.3 追问三:不可达对象会立刻被回收吗
不会,至少对普通对象来说,还有一个两次标记的过程。
第一次标记:对象在可达性分析中不可达,被标记为“可回收候选”。此时JVM会检查它是否覆盖了finalize()方法,且尚未调用过。如果满足条件,对象会被放入一个F-Queue队列,由Finalizer线程异步执行其finalize方法。执行完finalize之后,JVM会对队列中的对象进行第二次标记:如果对象在finalize里重新和GC Roots建立了引用链,比如把一个静态变量指向自己,它就能“死里逃生”,被移出回收名单。
但我要直说:这种自救写法只是面试考点,生产环境千万别用。finalize机制本身就被官方标记为不推荐,执行时机不可控、性能开销大、还容易带来诡异时序问题。JDK9开始已经把相关类标记为Deprecated。面试时你要能讲清楚这个机制,但一定要补一句“实际工程中不建议依赖它”。
3.4 追问四:哪些对象算GC Roots,是不是越少越好
GC Roots越少,可达性分析的遍历起点就越少,理论上能减少重复遍历的代价。但GC Roots集合是客观存在的,你没法为了性能故意“藏起来”某些根。它由当前存活线程的栈帧、已加载类的静态字段、JNI引用等共同决定,JVM只能优化遍历方式,不能削减根本身。
这里有一个很有意思的延伸:频繁创建线程会导致GC Roots数量上升,因为每个线程的栈帧都会贡献一堆局部变量引用。高并发应用里,线程池线程数动辄几百上千,GC Roots遍历成本确实会变大。这也是为什么有的高并发服务会控制线程数量、避免线程滥用——它影响的不仅是调度开销,还有GC开销。
3.5 追问五:方法区(元空间)里的类对象算不算垃圾
算,但条件比堆对象苛刻得多。类需要同时满足三个条件才可能被卸载:类所有的实例都被回收;加载该类的ClassLoader已经被回收;类对应的java.lang.Class对象没有任何地方被引用(包括反射、动态代理等间接引用)。这三个条件本身就很严格,所以在很多实战场景里,类的卸载并非常态,只有大量使用自定义类加载器的框架(比如某些热部署容器)才会对此敏感。
3.6 追问六:并发标记时GC Roots会不会变
如果GC线程在标记,应用线程还在改引用,那根集合和引用链当然会变。早期的实现靠Stop-The-World硬扛,全停顿解决一切。现代以G1、ZGC为代表的回收器追求低延迟,引入了并发标记,这就带来了“标记过程中引用被改了怎么算”的问题。处理思路包括三色标记(白灰黑三色状态)和写屏障配合增量更新、原始快照(SATB)等。
面试中这个点是很好的加分深水区:能讲到三色标记、模块引用、SATB,说明你对垃圾回收的理解已经超过“背概念”阶段了。
4. 三个代码场景,把判定过程完整推演一遍
光讲理论容易飘,我用三个Java代码场景把GC Roots的可达性分析走一遍。这里不要求你把每个场景运行出来(虽然环境允许的话强烈建议加-XX:+PrintGCDetails实测),重点是推演逻辑。
4.1 场景一:循环引用对象在可达性分析下会被回收吗
回到第1节的Node循环引用例子。方法createCycle执行期间,局部变量a、b都在栈帧上,a和b都是GC Roots可达的,所以它们活着。方法返回后,栈帧弹出,局部变量表销毁,根消失。虽然a和b互相引用,但因为没有根可达它们,它们在可达性分析下就是垃圾。下一次Minor GC时,这两个对象会正常回收。
这个例子最能说明“引用计数法”和“可达性分析”的关键差异。你可以主动在面试里举这个例:“循环引用是引用计数法的死穴,但在可达性分析下只是普通垃圾。”一句话就把两种算法对比讲透了。
4.2 场景二:static引用如何“保命”
设想一个类:
public class CacheHolder { public static List<byte[]> CACHE = new ArrayList<>(); } public class Demo { public static void main(String[] args) { List<byte[]> local = new ArrayList<>(); local.add(new byte[10 * 1024 * 1024]); CacheHolder.CACHE.addAll(local); local.clear(); } }main方法的局部变量local在clear之后,按理说不再引用任何对象,但那些10MB的byte数组已经被放进了CacheHolder.CACHE,这是一个静态字段持有的引用。只要CacheHolder类没被卸载、CACHE没有被置空,这些byte数组就始终根可达,不会被回收。这个场景在旧代码里很常见,也是“静态集合导致内存泄漏”的最直接案例。
由此可以推导一个问题:如果你想释放这类对象,必须显式调用CacheHolder.CACHE.clear()或把CACHE = null,仅靠局部变量失效是没有用的。很多人觉得“对象没用完了自然会回收”,其实真正决定回收的是“从根是否可达”,不是“你有没有再用它”。
4.3 场景三:局部变量的“伪可达”和编译器优化
有一种边界情况值得了解:局部变量在源码作用域结束后,会不会继续作为GC Roots?看下面的代码:
public static void test() { { byte[] big = new byte[10 * 1024 * 1024]; // 大对象 System.out.println(big.length); } // 这里的big已经超出作用域,但栈帧里big槽位还存在吗? System.gc(); }在严格按字节码实现时,局部变量槽位可以复用,但JIT经过优化后,槽位可能会提前失效。一个常见的现象是:你以为出了作用域就会释放,但如果不触发GC,这块内存可能在很长一段时间内占用着。测试GC时,我建议用下面这种更“干净”的写法:
public static void test() { byte[] big = new byte[10 * 1024 * 1024]; System.out.println(big.length); big = null; // 主动切断根可达 System.gc(); }主动将big置为null,可以确保栈帧中的引用槽位不再指向对象。这在实际编程中可以作为临界释放手段,虽然现代JVM大多数时候不需要你这么做,但在读GC日志、做内存测试时,这种细节能让结果判断更准确。
5. 从GC Roots延伸到回收器设计:跨代引用和并发标记
了解GC Roots不能只停留在“哪些是根”的层面,再往前走两步,面试和实战的体感会完全不同。这两步是“跨代引用怎么处理”和“不同回收器对根的标记策略”。
5.1 跨代引用问题:老年代对象引用年轻代对象,根怎么算
分代垃圾回收器(如G1、CMS)把堆分成年轻代和老年代,回收年轻代时,理论上GC Roots不仅来自堆外,还可能来自老年代。因为一个老年代对象可能引用着年轻代对象,如果漏掉这条引用,就会把仍然被老年代引用的年轻代对象误判为垃圾。
如果每次Minor GC都去扫描整个老年代来找“老年代到年轻代的引用”,那Minor GC的成本就变得和老年代一样大,分代优势就没了。于是有了记忆集(Remembered Set)和卡表(Card Table)这类结构:把老年代按固定大小的“卡页”划分,只要某个卡页内有引用指向年轻代,就把这个卡页标记为“脏卡”。Minor GC时,只需要扫描脏卡里的老年代对象,把这些对象作为额外的根,就能快速处理跨代引用,而不用全量扫描老年代。
这个机制是面试里连接GC Roots和具体回收器的关键桥梁。能讲清楚记忆集,说明你对分代回收的理解不是停留在PPT上。
5.2 不同收集器对GC Roots的处理差异
Parallel比较传统,它的GC Roots枚举是在Stop-The-World阶段完成的,所有业务线程暂停,GC线程统一枚举根并标记。全程暂停意味着实现简单、吞吐量高,但不适合对延迟敏感的场景。
CMS引入了并发标记,把标记过程分成初始标记(需要STW但只标记根直接引用的对象)、并发标记(业务线程和GC线程同时跑,通过写屏障记录引用变化)、重新标记(STW,修正并发阶段的变化)几个阶段。初始标记时GC Roots的枚举还是STW的,但后续遍历不再完全停顿。这个分段设计能显著降低停顿时间,代价是整体CPU占用更高、内存碎片问题突出。
G1在此基础上做了两件大事:分区化的堆布局让回收以Region为单位;并发标记阶段使用原始快照(SATB)保证并发期间引用变化的正确性。ZGC更进一步,把几乎整个标记过程都做成了并发,连初始标记也大幅缩短,配合读屏障和染色指针实现了极短且可预测的停顿。
面试时如果能按“谁负责枚举根、谁负责遍历引用、谁负责处理并发变化”这个维度对比回收器,会显得非常有条理。这也是我建议在答完GC Roots定义后主动展开的方向——主动展示,而不是等面试官逼问。
5.3 记忆集与并发标记的交汇:记住“根集合是动态的”
把第3节的三色标记和第5节的记忆集合在一起看,会发现一个共性问题:根集合本身是动态的。线程不停创建和销毁,栈帧不断压栈弹栈,静态字段可能被赋值也可能被置空,JNI引用的诞生和释放都在随时发生。GC要保证某个时间点上的快照是准的,就得依赖安全点停顿、写屏障、读屏障这些机制共同兜底。
因此,我建议你把GC Roots理解成“一组带时间戳的可达引用锚点”,而不是“永远存在的对象列表”。面试官听到这种表述,通常能准确判断你是真理解还是背概念。
6. 一套可以直接套用的答题结构,以及一次模拟面试复盘
讲了这么多原理,最后落到“面试到底怎么答”。我见过太多候选人知识点零散、每点都对但组织不起来,这里给一个我自己总结的四步答题结构,照着练基本不会翻车。
6.1 推荐的答题节奏
第一步,一句话点题:“JVM判断垃圾主要用可达性分析,先从GC Roots出发遍历引用链,不可达的对象就是垃圾;引用计数法因循环引用和更新开销太大,没有在主流JVM中使用。”
第二步,展开GC Roots定义,列出五类经典来源。这里用“堆外指向堆内的引用”作为引子,然后逐个说:虚拟机栈中的引用、静态属性引用、常量引用、JNI引用、活跃线程等。如果面试官没打断,可以补充synchronized监视线程和JVM内部引用。
第三步,主动结合一个例子。把Node循环引用例子讲到一半,面试官一般就让你停了,因为他已经确认你理解到位。注意控制时间,这一步不超过40秒。
第四步,向并发方向扩展。“如果面试官想继续深挖,可以聊三色标记、安全点、OopMap、记忆集。”这句话不是让你背出来,而是在答完基础问题后,用一种“我还能再往下讲”的状态,把话语权交给对方。很多面试官会因为这句话确定你的上限很高。
6.2 一次模拟面试复盘:从支支吾吾到答案成形
我在做模拟面试时遇到过一位候选人,他的初始回答是这样的:“GC Roots就是一些根对象,JVM从它们出发找可达对象,不可达的回收。具体哪些根我记得有栈上的引用、静态变量、常量、JNI……”说到这他就停了,明显是背过但理解不深。
我追问了一句:“为什么栈上的引用可以作为根?”他想了想说:“因为栈是线程私有的,局部变量是当前正在用的。”我又问:“那静态变量不是当前正在用的,为什么也能当根?”他卡住了。
这里其实就牵出一个判断标准:栈是当前执行流,当然要保住;静态变量属于类,类的生命周期更长,在类活着期间,被类静态字段引用的对象就是“整个类系统仍然需要”的,所以必须保住。这和“正在用”不完全是一回事,更准确的说法是“它所属的容器仍然是活的”。
我建议他把回答改成:“根的选择标准,是这些位置的引用生命周期足够明确,且它们持有对象的时机和系统当前存活状态一致。栈帧弹出引用即消失,所以它能反映‘当前执行流正在用的对象’;静态属性随着类存活,能反映‘类体系还在依赖的对象’。”这个回答把“是什么”升级成了“为什么”,效果立刻不一样。
此后这位候选人模拟面试的口径基本成型:先答主机制,再用对比说明引用计数的不足,最后主动补一句“如果想了解并发标记时的根变化,我可以用三色标记讲一下”。同一次模拟面试里,这个问题的评价从“基础概念掌握一般”提到了“有系统理解”。
我自己在准备这类题目时还有个土办法,也挺管用:平时写完一段代码,如果怀疑内存有问题,先画一下“哪些引用是从static字段、栈、本地方法传过来的”,再对照GC日志。画多了,GC Roots就不再是抽象概念,而是一张张实际的引用关系网。准备面试本身也一样,别死记定义,每次都想一遍“为什么它有资格当根”,理解自然就深了。