Skip to content

标记-清除/复制/标记-压缩(对比)

三大基础算法的深度对比

前面我们已经了解了三种基础 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 调优的钥匙。

下一节,我们来看 分代/增量/分区收集算法

基于 VitePress 构建