面试知识库
极高 进阶

垃圾判定与GC-Roots#

一句话答案#

判定对象是否可回收有两种思路:引用计数法(无法解决循环引用,Java 不用)和可达性分析(Java 采用)——从 GC Roots 出发遍历引用链,不可达的对象才可回收,且要经过两次标记(含 finalize 自救机会)才真正判死。

核心要点

1. 引用计数法(Java 未采用)

  • 每个对象维护一个引用计数器,引用加一、失效减一,计数为 0 即可回收。
  • 优点:实现简单、回收及时。
  • 致命缺陷:无法解决循环引用(A 引 B、B 引 A,互相计数不为 0,但已无外部引用,造成内存泄漏)。

2. 可达性分析(Java/主流 JVM 采用)

  • 以一系列 GC Roots 为起点向下搜索引用链,走过的路径称为引用链(Reference Chain)。
  • 对象到 GC Roots 没有任何引用链相连(不可达)时,判定为可回收。
  • 天然解决循环引用:互引的两个对象若都不可达,整体回收。

3. 哪些可以作为 GC Roots?

  • 虚拟机栈(栈帧本地变量表)中引用的对象——方法参数、局部变量
  • 方法区中静态属性引用的对象
  • 方法区中常量引用的对象(如字符串常量池引用)
  • 本地方法栈中 **JNI(Native 方法)**引用的对象
  • synchronized 持有的锁对象
  • JVM 内部引用(基本类型 Class 对象、常驻异常对象、系统类加载器等)

4. 两次标记与 finalize 自救

  1. 可达性分析后,不可达对象被第一次标记
  2. 判断该对象是否有必要执行 finalize():没重写或已执行过 → 直接回收。
  3. 有必要则放入 F-Queue,由低优先级 Finalizer 线程执行 finalize()
  4. 若对象在 finalize 中重新与引用链上任一对象建立关联(如把 this 赋给静态变量)→ 第二次标记时移出回收集合,完成自救;否则回收。
  • 注意:finalize 只能自救一次(每个对象的 finalize 最多被自动调用一次),且不推荐使用,已被 @Deprecated

5. 引用强度影响判定

  • 强引用(永不回收)、软引用(内存不足才回收)、弱引用(下次 GC 必回收)、虚引用(仅用于回收通知)——详见 引用类型(强软弱虚)

6. OopMap:根枚举从”遍历栈”变”查表”

  • 问题:枚举 GC Roots 要找出栈和寄存器中所有的对象引用。若逐个扫描每个栈帧的每个 slot判断是不是引用,几千个线程时开销巨大,且无法区分”这个 int 看起来像地址”。
  • OopMap(Ordinary Object Pointer Map):HotSpot 在类加载完成时、JIT 编译时,就把栈上/寄存器里哪些位置存放的是对象引用记录成一张表(oop = 对象指针)。
  • 根枚举时直接查 OopMap 表即可定位所有引用,从”遍历扫描”变成”按表索引”,大幅加速且准确。

7. 安全点(Safepoint):OopMap 只在特定位置才准确

  • 为每条指令都生成 OopMap 太占空间,HotSpot 只在特定位置记录 OopMap,这些位置就是安全点。线程只有停在安全点上,OopMap 才精确、根枚举才安全。
  • 安全点选址特征——会长时间执行的指令:方法调用、循环回边(尤其循环跳回)、异常跳转。这些位置之间间隔短,不会让线程长时间无法进入安全点。
  • 如何让线程跑到安全点停下——主动式中断(Voluntary Suspension)
    • JVM 不强行中断线程,而是设置一个安全点标志(轮询页)
    • 各用户线程在执行到安全点时主动轮询这个标志,发现要 GC 就自己跑到最近的安全点挂起
    • HotSpot 用”内存保护页 + test 指令”实现轮询:把轮询页设为不可读,线程执行轮询的 test 指令时触发自陷异常而挂起,比显式检查更省开销。

8. 安全区域(Safe Region):解决 sleep/blocked 的线程

  • 问题:处于 sleep、blocked、wait 状态的线程不在 CPU 上执行,没机会轮询安全点标志、跑不到安全点,GC 就得一直等它。
  • 安全区域:一段引用关系不会变化的代码区域,在区域内任意位置开始 GC 都是安全的(相当于”扩大的安全点”)。
  • 机制:线程进入安全区域时标记自己已在安全区域;GC 发起根枚举时直接跳过这些线程。线程离开安全区域时,要检查 GC 是否已完成根枚举——若没完成则等待,完成后才离开继续执行。
面试回答(2分钟版)

判断一个对象能不能被回收有两种算法。第一种是引用计数法,给每个对象维护一个引用计数器,被引用就加一、失效就减一,为零就回收,优点是简单及时,但有个致命问题解决不了循环引用——比如 A 引用 B、B 引用 A,它们的计数都不为零,但其实外部已经没人用了,会造成内存泄漏,所以 Java 没有采用。Java 用的是可达性分析:以一组 GC Roots 作为起点向下遍历引用链,如果一个对象到 GC Roots 没有任何引用链相连,也就是不可达,就判定为可回收,这样循环引用的两个对象只要都不可达就能一起被回收。GC Roots 主要包括:虚拟机栈里引用的对象比如局部变量和方法参数、方法区里的静态变量和常量引用的对象、本地方法栈里 JNI 引用的对象、还有被 synchronized 锁住的对象等。另外对象被判死不是一次性的,要经过两次标记:第一次标记不可达后,如果对象重写了 finalize 并且还没执行过,会被放到 F-Queue 里由 Finalizer 线程执行 finalize,对象可以在 finalize 里把自己重新挂到引用链上完成自救,逃过第二次标记,但这个自救机会每个对象只有一次,而且 finalize 已经被废弃不推荐用。

追问与易错

追问方向:

  • “为什么 Java 不用引用计数法?”→ 无法解决循环引用导致内存泄漏;且每次引用变更都要维护计数有性能开销
  • “GC Roots 具体有哪些?”→ 栈中局部变量引用、静态变量、常量引用、JNI 引用、synchronized 锁对象、JVM 内部引用
  • “对象判死要几次标记?”→ 两次。第一次标记不可达,第二次确认是否在 finalize 中自救
  • “finalize 能用来释放资源吗?”→ 不能依赖。执行时机不确定、可能不执行、只调一次,已 @Deprecated;资源释放应用 try-with-resources/Cleaner
  • “可达性分析在 GC 时要 Stop The World 吗?”→ 枚举 GC Roots 阶段需要 STW 保证一致性快照,CMS/G1 用 OopMap、安全点、三色标记+增量更新/SATB 来减少停顿
  • “枚举 GC Roots 要逐个扫栈吗?”→ 不用。HotSpot 用 OopMap 表记录栈上/寄存器里哪些位置是对象引用,根枚举从遍历扫描变为查表
  • “OopMap 为什么不是每条指令都记?”→ 太占空间,只在安全点(方法调用、循环回边、异常)记录,线程必须停在安全点 OopMap 才准确
  • “怎么让正在跑的线程停到安全点?”→ 主动式中断:JVM 设置轮询标志,线程执行到安全点主动轮询、自己跑到最近安全点挂起(HotSpot 用内存保护页 + test 自陷实现)
  • “sleep/blocked 的线程跑不到安全点怎么办?”→ 安全区域:线程进入引用不变的区域时打标记,GC 直接跳过它;线程离开时检查 GC 是否完成根枚举,没完成就等

易错点:

  • ❌ “Java 用引用计数判定垃圾”——用的是可达性分析
  • ❌ “不可达对象立即回收”——还需两次标记,且有 finalize 自救机会
  • ❌ “循环引用会内存泄漏”——可达性分析下,互引但整体不可达照样回收