跳到主要内容

垃圾回收算法

问题

JVM 如何判断对象是否可以回收?有哪些垃圾回收算法?什么是分代收集?三色标记法是怎么回事?

答案

如何判断对象是否存活

1. 引用计数法(Reference Counting)

每个对象维护一个引用计数器,有引用指向它时加 1,引用失效时减 1,计数为 0 时回收。

ReferenceCountingProblem.java
// 引用计数法的致命缺陷:循环引用
public class CircularReference {
public Object ref = null;

public static void main(String[] args) {
CircularReference a = new CircularReference();
CircularReference b = new CircularReference();

// 互相引用
a.ref = b;
b.ref = a;

// 置空外部引用
a = null;
b = null;

// 此时 a 和 b 的引用计数都为 1(互相引用)
// 引用计数法无法回收!但 JVM 的可达性分析可以回收
System.gc();
}
}

JVM(HotSpot)不使用引用计数法,Python、Objective-C 使用。

2. 可达性分析(Reachability Analysis)

从一组称为 GC Roots 的根对象出发,沿着引用链向下搜索。如果一个对象到 GC Roots 没有任何引用链相连,则该对象是不可达的,可以被回收。

上图中 E 和 F 虽然互相引用,但从 GC Roots 不可达,会被判定为垃圾。

可以作为 GC Roots 的对象:

GC Root 类型说明
虚拟机栈中的引用方法中的局部变量、参数
方法区中的类静态属性static 引用的对象
方法区中的常量引用static final 引用的对象
本地方法栈中的 JNI 引用Native 方法引用的对象
JVM 内部引用基本类型的 Class 对象、系统 ClassLoader
synchronized 持有的对象被锁住的对象

finalize() 方法

对象被判定为不可达后,不会立即回收,还有最后一次"自救"机会:

不要使用 finalize()
  • finalize() 执行不确定,可能永远不会被调用
  • 执行效率低,拖慢 GC
  • JDK 9 已标记为 @Deprecated
  • 使用 try-with-resourcesCleaner(JDK 9+)代替

垃圾回收算法

1. 标记-清除(Mark-Sweep)

分为两个阶段:先标记所有需要回收的对象,然后清除被标记的对象。

标记前:  [A] [B] [C] [D] [E] [F] [G]
标记后: [A] [×] [C] [×] [E] [×] [G] ← B、D、F 被标记
清除后: [A] [_] [C] [_] [E] [_] [G] ← 出现内存碎片
优点缺点
实现简单内存碎片:大对象可能找不到连续空间
不需要移动对象效率不稳定:标记和清除效率随对象数量线性增长

2. 标记-复制(Mark-Copy)

将内存分为两块相等的区域,每次只使用一块。GC 时将存活对象复制到另一块,然后清空当前区域。

使用区:  [A] [×] [C] [×] [E]     空闲区: [_] [_] [_] [_] [_]
GC 后
空闲区: [_] [_] [_] [_] [_] 使用区: [A] [C] [E] [_] [_]
↑ 紧凑排列,无碎片
优点缺点
无内存碎片内存利用率只有 50%
分配内存快(指针碰撞)对象存活率高时复制开销大
新生代的优化版本:Appel 式回收

新生代中大部分对象朝生夕灭(98% 以上),不需要 1:1 划分。HotSpot 将新生代分为 Eden(80%)+ Survivor From(10%)+ Survivor To(10%),每次只浪费 10% 的空间。

当 Survivor 空间不足时,需要老年代进行分配担保(Handle Promotion)。

3. 标记-整理(Mark-Compact)

标记存活对象后,将所有存活对象向一端移动,然后直接清理掉边界以外的内存。

标记后:  [A] [×] [C] [×] [E] [×] [G]
整理后: [A] [C] [E] [G] [_] [_] [_]
↑ 边界,之后全部清空
优点缺点
无内存碎片需要移动对象,开销大
内存利用率高移动时需要 STW(Stop The World)

算法对比

算法碎片空间利用率移动对象适用场景
标记-清除CMS 老年代
标记-复制低(50%)新生代(对象存活率低)
标记-整理老年代(对象存活率高)

分代收集理论

基于两个假说:

  1. 弱分代假说:绝大多数对象都是朝生夕灭的(新生代对象存活率低)
  2. 强分代假说:熬过多次 GC 的对象越难消亡(老年代对象存活率高)

因此将堆分为新生代和老年代,采用不同的回收算法:

分代回收算法GC 类型频率速度
新生代标记-复制Minor GC / Young GC
老年代标记-清除/标记-整理Major GC / Old GC
整堆Full GC最低最慢

对象的晋升流程:

触发 GC 的条件:

GC 类型触发条件
Minor GCEden 区空间不足
Full GC老年代空间不足
Full GC方法区(元空间)空间不足
Full GCSystem.gc()(建议,不保证执行)
Full GCMinor GC 后晋升到老年代的对象大于老年代剩余空间
Full GCCMS 并发模式失败(Concurrent Mode Failure)
动态年龄判定

除了固定年龄阈值(-XX:MaxTenuringThreshold,默认 15),HotSpot 还有动态年龄判定:如果 Survivor 中某年龄段及以下的对象总大小超过 Survivor 空间的一半,则该年龄段及以上的对象直接晋升老年代。

三色标记法

现代并发垃圾收集器(CMS、G1、ZGC)使用三色标记法来实现并发标记:

颜色含义
白色未被访问的对象(GC 开始时所有对象都是白色)
灰色已被访问但其引用的对象还没有全部扫描完
黑色已被访问且其引用的对象都已扫描完

标记过程:

  1. GC Roots 直接引用的对象标为灰色
  2. 从灰色对象出发,扫描其引用的对象,将子对象标为灰色,自身变为黑色
  3. 重复步骤 2,直到没有灰色对象
  4. 剩余的白色对象即为垃圾

并发标记的问题

并发标记期间,用户线程仍在运行,可能导致两种问题:

1. 浮动垃圾(Floating Garbage)

已经标记为黑色的对象,在标记过程中其引用被删除,但因为已经是黑色不会再被扫描,本轮 GC 不会被回收。无害,下次 GC 会回收。

2. 漏标(Missing Mark)— 严重问题

对象已经是白色,标记过程中有新的引用指向它(从黑色对象),但因为黑色对象不会再被扫描,导致这个存活对象被错误回收。

漏标的充要条件(同时满足才会发生):

  1. 赋值器插入了一条从黑色对象到白色对象的新引用
  2. 赋值器删除了所有从灰色对象到该白色对象的直接或间接引用

解决方案:

方案破坏条件实现使用者
增量更新破坏条件 1写屏障记录新增引用,重新标记时以这些黑色对象为根再扫描CMS
原始快照(SATB)破坏条件 2写屏障记录被删除的引用(灰→白引用断开前记录),按标记开始时的快照完成标记G1、ZGC、Shenandoah
WriteBarrierPseudocode.java
// 增量更新写屏障(伪代码)
void writeBarrier_IncrementalUpdate(Object src, Object* field, Object newRef) {
if (src.isBlack() && newRef.isWhite()) {
// 记录这条新增引用,重新标记时扫描
recordNewReference(src, newRef);
}
*field = newRef;
}

// SATB 写屏障(伪代码)
void writeBarrier_SATB(Object src, Object* field, Object newRef) {
Object oldRef = *field;
if (oldRef != null && oldRef.isWhite()) {
// 记录被删除的旧引用,确保旧引用指向的对象不会被漏标
recordOldReference(oldRef);
}
*field = newRef;
}

安全点与安全区域

GC 需要在特定位置暂停线程(STW),这些位置称为安全点(Safepoint)

概念说明
安全点线程可以安全暂停的位置,此时对象引用关系确定。通常在方法调用、循环回跳、异常跳转处
安全区域引用关系不会变化的代码区间。处于 Sleep/Blocked 状态的线程无法主动走到安全点,通过安全区域解决

如何让线程到达安全点:

  • 抢先式中断:GC 直接中断所有线程,不在安全点的再恢复运行(几乎不用)
  • 主动式中断:设置标志位,线程执行到安全点时检查标志,主动挂起(HotSpot 使用)

常见面试问题

Q1: JVM 是如何判断对象可以被回收的?

答案

HotSpot 使用可达性分析算法:从 GC Roots 出发,沿引用链搜索,不可达的对象就是垃圾。

GC Roots 包括:虚拟机栈中的局部变量、方法区的静态变量和常量、JNI 引用、synchronized 锁对象等。

不使用引用计数法的原因是它无法解决循环引用问题。

Q2: 三种垃圾回收算法各有什么优缺点?

答案

  • 标记-清除:简单但产生碎片,适合老年代(CMS)
  • 标记-复制:无碎片但浪费一半空间,适合新生代(对象存活率低,实际只浪费 10%)
  • 标记-整理:无碎片且空间利用率高,但需要移动对象导致 STW,适合老年代

Q3: 什么是 Minor GC、Major GC、Full GC?

答案

GC 类型范围说明
Minor GC(Young GC)新生代Eden 满时触发,速度快
Major GC(Old GC)老年代仅部分收集器区分(如 CMS)
Full GC整个堆 + 方法区最慢,应尽量避免

实际开发中主要关注 Full GC 的频率和耗时,频繁 Full GC 是性能问题的信号。

Q4: 对象如何从新生代晋升到老年代?

答案

四种情况:

  1. 年龄达到阈值:对象在 Survivor 中每经历一次 Minor GC 年龄加 1,达到 -XX:MaxTenuringThreshold(默认 15)后晋升
  2. 大对象直接进入老年代:超过 -XX:PretenureSizeThreshold 的对象直接在老年代分配
  3. 动态年龄判定:Survivor 中相同年龄的对象总和超过 Survivor 空间一半,该年龄及以上的对象直接晋升
  4. Survivor 空间不足:Minor GC 后存活对象放不下 Survivor,通过分配担保进入老年代

Q5: 什么是三色标记法?如何解决并发标记中的漏标问题?

答案

三色标记将对象分为白色(未访问)、灰色(访问中)、黑色(已完成)。从 GC Roots 开始逐步将白→灰→黑,最终白色对象为垃圾。

并发标记中的漏标:黑色对象新增了对白色对象的引用,同时所有灰色到该白色的路径被断开,导致存活对象被误回收。

解决方案:

  • 增量更新(CMS):记录黑→白的新引用,重新标记时重新扫描
  • SATB 原始快照(G1/ZGC):记录灰→白引用断开前的旧值,按快照完成标记

Q6: 什么是 STW(Stop The World)?为什么需要它?

答案

STW 指 GC 时暂停所有用户线程。需要 STW 的原因是:如果用户线程一边运行一边修改引用关系,GC 无法准确追踪对象的存活状态。

不同 GC 的目标就是缩短 STW 时间

  • Serial:整个 GC 过程 STW
  • CMS/G1:只有初始标记和重新标记需要 STW,并发标记阶段用户线程照常运行
  • ZGC/Shenandoah:STW 时间控制在 10ms 以内

Q7: 什么是分配担保机制?

答案

Minor GC 前,JVM 会检查老年代最大可用连续空间是否大于新生代所有对象总空间:

  • 如果大于:Minor GC 安全,直接执行
  • 如果小于:检查 -XX:HandlePromotionFailure 是否允许担保
    • 允许且历史晋升平均值 < 老年代剩余空间:冒险执行 Minor GC
    • 否则:改为 Full GC

JDK 6 Update 24 后不再使用 HandlePromotionFailure 参数,只要老年代剩余空间大于历史平均晋升大小或大于新生代总大小,就执行 Minor GC。

相关链接