垃圾回收算法
问题
JVM 如何判断对象是否可以回收?有哪些垃圾回收算法?什么是分代收集?三色标记法是怎么回事?
答案
如何判断对象是否存活
1. 引用计数法(Reference Counting)
每个对象维护一个引用计数器,有引用指向它时加 1,引用失效时减 1,计数为 0 时回收。
// 引用计数法的致命缺陷:循环引用
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()执行不确定,可能永远不会被调用- 执行效率低,拖慢 GC
- JDK 9 已标记为
@Deprecated - 使用
try-with-resources或Cleaner(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% |
| 分配内存快(指针碰撞) | 对象存活率高时复制开销大 |
新生代中大部分对象朝生夕灭(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%) | 是 | 新生代(对象存活率低) |
| 标记-整理 | 无 | 高 | 是 | 老年代(对象存活率高) |
分代收集理论
基于两个假说:
- 弱分代假说:绝大多数对象都是朝生夕灭的(新生代对象存活率低)
- 强分代假说:熬过多次 GC 的对象越难消亡(老年代对象存活率高)
因此将堆分为新生代和老年代,采用不同的回收算法:
| 分代 | 回收算法 | GC 类型 | 频率 | 速度 |
|---|---|---|---|---|
| 新生代 | 标记-复制 | Minor GC / Young GC | 高 | 快 |
| 老年代 | 标记-清除/标记-整理 | Major GC / Old GC | 低 | 慢 |
| 整堆 | — | Full GC | 最低 | 最慢 |
对象的晋升流程:
触发 GC 的条件:
| GC 类型 | 触发条件 |
|---|---|
| Minor GC | Eden 区空间不足 |
| Full GC | 老年代空间不足 |
| Full GC | 方法区(元空间)空间不足 |
| Full GC | System.gc()(建议,不保证执行) |
| Full GC | Minor GC 后晋升到老年代的对象大于老年代剩余空间 |
| Full GC | CMS 并发模式失败(Concurrent Mode Failure) |
除了固定年龄阈值(-XX:MaxTenuringThreshold,默认 15),HotSpot 还有动态年龄判定:如果 Survivor 中某年龄段及以下的对象总大小超过 Survivor 空间的一半,则该年龄段及以上的对象直接晋升老年代。
三色标记法
现代并发垃圾收集器(CMS、G1、ZGC)使用三色标记法来实现并发标记:
| 颜色 | 含义 |
|---|---|
| 白色 | 未被访问的对象(GC 开始时所有对象都是白色) |
| 灰色 | 已被访问但其引用的对象还没有全部扫描完 |
| 黑色 | 已被访问且其引用的对象都已扫描完 |
标记过程:
- GC Roots 直接引用的对象标为灰色
- 从灰色对象出发,扫描其引用的对象,将子对象标为灰色,自身变为黑色
- 重复步骤 2,直到没有灰色对象
- 剩余的白色对象即为垃圾
并发标记的问题
并发标记期间,用户线程仍在运行,可能导致两种问题:
1. 浮动垃圾(Floating Garbage)
已经标记为黑色的对象,在标记过程中其引用被删除,但因为已经是黑色不会再被扫描,本轮 GC 不会被回收。无害,下次 GC 会回收。
2. 漏标(Missing Mark)— 严重问题
对象已经是白色,标记过程中有新的引用指向它(从黑色对象),但因为黑色对象不会再被扫描,导致这个存活对象被错误回收。
漏标的充要条件(同时满足才会发生):
- 赋值器插入了一条从黑色对象到白色对象的新引用
- 赋值器删除了所有从灰色对象到该白色对象的直接或间接引用
解决方案:
| 方案 | 破坏条件 | 实现 | 使用者 |
|---|---|---|---|
| 增量更新 | 破坏条件 1 | 写屏障记录新增引用,重新标记时以这些黑色对象为根再扫描 | CMS |
| 原始快照(SATB) | 破坏条件 2 | 写屏障记录被删除的引用(灰→白引用断开前记录),按标记开始时的快照完成标记 | G1、ZGC、Shenandoah |
// 增量更新写屏障(伪代码)
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: 对象如何从新生代晋升到老年代?
答案:
四种情况:
- 年龄达到阈值:对象在 Survivor 中每经历一次 Minor GC 年龄加 1,达到
-XX:MaxTenuringThreshold(默认 15)后晋升 - 大对象直接进入老年代:超过
-XX:PretenureSizeThreshold的对象直接在老年代分配 - 动态年龄判定:Survivor 中相同年龄的对象总和超过 Survivor 空间一半,该年龄及以上的对象直接晋升
- 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。