垃圾判定与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 自救
- 可达性分析后,不可达对象被第一次标记。
- 判断该对象是否有必要执行
finalize():没重写或已执行过 → 直接回收。 - 有必要则放入 F-Queue,由低优先级 Finalizer 线程执行
finalize()。 - 若对象在 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 自救机会
- ❌ “循环引用会内存泄漏”——可达性分析下,互引但整体不可达照样回收