标记-清除/复制/标记-压缩(对比)
三大基础算法的深度对比
前面我们已经了解了三种基础 GC 算法的基本原理,这一节从更深的维度来对比它们。
标记-清除算法(Mark-Sweep)
详细执行过程
步骤一:标记(Mark)
┌────────────────────────────────────────────────────┐
│ GC Roots ──→ [A] ──→ [B] ──→ [C] │
│ ↑ ↑ ↑ │
│ │ │ │ │
│ 标记为存活 标记 标记 │
└────────────────────────────────────────────────────┘
步骤二:清除(Sweep)
┌────────────────────────────────────────────────────┐
│ [A] [___] [B] [C] [___] [___] │
│ 存活 删除 存活 存活 删除 │
└────────────────────────────────────────────────────┘标记-清除的内存布局问题
清除后,内存中出现「洞」:
┌────┬──────────┬────┬────┬──────────┬────┐
│ 存活│ 洞 │ 存活│ 洞 │ 洞 │ 存活│
└────┴──────────┴────┴────┴──────────┴────┘
↑ ↑ ↑
碎片化 碎片化 碎片化
后果:
1. 需要连续大内存时,无法分配
2. 即使总空闲空间足够,也分配失败实现细节
java
// 标记-清除的简化实现
public class MarkSweep {
// 标记阶段:从 GC Roots 出发,标记所有可达对象
public void mark() {
// 使用 O(n) 时间遍历堆
// n = 存活对象的数量
}
// 清除阶段:遍历堆,回收未标记的对象
public void sweep() {
// O(n) 时间遍历堆
// n = 堆中所有对象的数量
// 总复杂度:O(n + m),其中 n 是存活对象,m 是总对象
}
}复制算法(Copying)
详细执行过程
初始状态:
┌─────────────────────────┐ ┌─────────────────────────┐
│ From │ │ To │
│ [A] [B] [C] [D] │ │ │
│ 存活对象:50% │ │ 全部空闲 │
└─────────────────────────┘ └─────────────────────────┘
复制后:
┌─────────────────────────┐ ┌─────────────────────────┐
│ From │ │ To │
│ [全部清空] │ │ [A] [B] [C] [D] │
│ (准备下次 From) │ │ 按顺序紧凑排列 │
└─────────────────────────┘ └─────────────────────────┘复制算法的内存利用率问题
复制算法的致命问题:只能用一半内存
┌─────────────────────────┐ ┌─────────────────────────┐
│ From │ │ To │
│ │ │ │
│ 存活对象很少时 │ │ 大量空间被浪费 │
│ 复制开销也小 │ │ │
│ (这是优点) │ │ │
└─────────────────────────┘ └─────────────────────────┘
如果存活对象很多(如老年代),复制开销会很大
→ 所以复制算法只适合存活对象少的年轻代HotSpot 的实际做法
年轻代使用 Eden + Survivor 策略,把 10% 的 Survivor 区作为「To」空间:
┌─────────────────────────────────────────────────┐
│ 年轻代 │
│ │
│ ┌──────────────┐ ┌─────┐ ┌─────┐ │
│ │ Eden │ │ S0 │ │ S1 │ │
│ │ (8/10) │ │(1/10)│ │(1/10)│ │
│ └──────────────┘ └─────┘ └─────┘ │
│ │
│ 实际可用空间 = Eden + 1 个 Survivor = 90% │
│ Survivor 作为 To 区轮流使用 │
└─────────────────────────────────────────────────┘标记-压缩算法(Mark-Compact)
详细执行过程
压缩前(标记-清除后):
┌──────────────────────────────────────────┐
│ [A] [___] [B] [C] [___] [D] │
│ 存活 洞 存活 存活 洞 存活 │
└──────────────────────────────────────────┘
步骤一:计算新的地址
A ──→ 新地址 #1
B ──→ 新地址 #2
C ──→ 新地址 #3
D ──→ 新地址 #4
步骤二:更新引用
所有引用 A/B/C/D 的地方都要更新为新地址
步骤三:移动对象
┌──────────────────────────────────────────┐
│ [A] [B] [C] [D] [___] [___] │
│ 紧凑排列 空洞(合并) │
└──────────────────────────────────────────┘标记-压缩的三个子阶段
| 阶段 | 说明 | 开销 |
|---|---|---|
| 标记 | 标记所有存活对象 | O(n) |
| 计算新地址 | 计算每个存活对象的新位置 | O(n) |
| 更新引用 | 更新所有对象中的引用 | O(R),R 为引用数量 |
| 移动对象 | 把对象移动到新位置 | O(n) |
标记-压缩 vs 标记-清除
标记-清除(有碎片):
┌────────────────────────────┐
│ [A] [洞] [B] [C] [洞洞洞] │
│ 新对象需要连续空间,即使总 │
│ 空间够也可能分配失败 │
└────────────────────────────┘
标记-压缩(无碎片):
┌────────────────────────────┐
│ [A] [B] [C] [___] [___] │
│ 新对象可直接分配,效率高 │
└────────────────────────────┘三种算法的深度对比
| 维度 | 标记-清除 | 复制算法 | 标记-压缩 |
|---|---|---|---|
| 时间复杂度 | O(n+m)(存活+总对象) | O(n)(只扫描存活对象) | O(n+R)(+引用更新) |
| 空间复杂度 | 无额外开销 | 50% 空间浪费 | 无额外开销 |
| 内存碎片 | ❌ 有碎片 | ✅ 无碎片 | ✅ 无碎片 |
| 对象移动 | ❌ 不移动 | ✅ 全部移动 | ✅ 部分/全部移动 |
| 吞吐量 | 中等 | 高(扫描快) | 较低(需要移动) |
| 局部性 | 较差(碎片多) | 好(移动后紧凑) | 好(移动后紧凑) |
| 适用区域 | 老年代 | 年轻代 | 老年代 |
| 分配速度 | 慢(需要找连续空间) | 快(指针碰撞) | 快(指针碰撞) |
算法与 GC 收集器的对应
Serial GC
年轻代 ──→ Serial ──→ 复制算法
老年代 ──→ Serial Old ──→ 标记-压缩
Parallel GC
年轻代 ──→ Parallel Scavenge ──→ 复制算法
老年代 ──→ Parallel Old ──→ 标记-压缩
CMS GC
年轻代 ──→ ParNew ──→ 复制算法
老年代 ──→ CMS ──→ 标记-清除(并发)
(最终退化为 Serial Old ──→ 标记-压缩)
G1 GC
年轻代 ──→ 复制算法(在 Region 间复制)
老年代 ──→ 混合收集 ──→ 标记-清除 + 复制
(通过 RSet 记录跨 Region 引用,避免全堆扫描)本节小结
三种基础算法的对比总结:
| 算法 | 核心特点 | 致命缺陷 | 适用场景 |
|---|---|---|---|
| 标记-清除 | 最基础,实现简单 | 内存碎片 | 老年代(CMS 并发) |
| 复制 | 速度最快,无碎片 | 50% 空间浪费 | 年轻代(Eden → Survivor) |
| 标记-压缩 | 无碎片,空间利用率高 | 移动开销大,速度最慢 | 老年代(Serial/Parallel) |
现代 GC 收集器不是「选一个算法」,而是在不同区域使用不同算法——年轻代用复制(高效),老年代用标记-清除/压缩(高利用率)。
理解三种算法的权衡,是理解 GC 调优的钥匙。
下一节,我们来看 分代/增量/分区收集算法。
