垃圾收集算法:标记-清除、复制、标记-整理
GC 算法是 JVM 垃圾回收的核心。
不同的算法有不同的优缺点,适用于不同的场景。今天我们来深入了解三种经典的 GC 算法。
一、标记-清除算法(Mark-Sweep)
1.1 算法原理
标记-清除是最基础的 GC 算法,分为两个阶段:
- 标记阶段:从 GC Roots 出发,标记所有存活的对象
- 清除阶段:遍历整个堆,清除所有未标记的对象
标记-清除算法:
┌─────────────────────────────────────────────────────────────┐
│ 标记阶段(Mark) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ ○ ○ ● ○ ○ ● ○ ○ │ │
│ │ ○ ● ○ ○ ● ○ ○ ○ ● = 存活对象(已标记) │ │
│ │ ○ ○ ○ ● ○ ○ ● ○ ○ = 死亡对象(未标记) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 清除阶段(Sweep) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ ○ ○ ○ ○ ○ ○ ○ ○ │ │
│ │ ○ ● ○ ○ ○ ○ ○ ○ 清除未标记的对象 │ │
│ │ ○ ○ ○ ○ ○ ○ ○ ○ │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘1.2 优点
- 简单:只需要标记和遍历两次操作
- 不需要移动对象:对象位置不变
1.3 缺点
内存碎片化:清除后会产生大量不连续的内存空间。
内存碎片问题:
┌─────────────────────────────────────────────────────────────┐
│ 清除前 │
│ ┌──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┐ │
│ │██│ │██│██│ │ │██│ │██│██│██│ │ │██│ │██│ │
│ └──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┘ │
│ ██ = 存活对象 (空格) = 死亡对象 │
│ │
│ 清除后 │
│ ┌────────────────────────────────────────────────────┐ │
│ │████│ │ │ │ │ │ │ │ │ │ │
│ └────────────────────────────────────────────────────┘ │
│ 可用空间:很多小碎片,无法分配大对象 │
│ │
└─────────────────────────────────────────────────────────────┘如果需要分配一个大对象,即使总可用空间足够,也可能因为没有足够大的连续空间而失败。
1.4 应用场景
标记-清除是 CMS 收集器"并发标记"阶段使用的算法。CMS 在初始标记和重新标记阶段使用 STW 标记,清除阶段与用户线程并发执行。
二、复制算法(Copying)
2.1 算法原理
复制算法把内存划分为两半,每次只使用一半。当这一半满了,就把存活对象复制到另一半,然后一次性清除这一半。
复制算法:
┌─────────────────────────────────────────────────────────────┐
│ │
│ 初始状态 复制后 │
│ ┌───────────┐ ┌───────────┐ │
│ │ 区域 A │ 存活对象 │ 区域 B │ │
│ │ ○ ○ ● ● ○│ ───→ 复制 ──→ │ ● ● │ │
│ │ ○ ● ○ ○ ●│ │ │ │
│ └───────────┘ └───────────┘ │
│ ↓ 清除 ↑ │
│ ┌───────────┐ ┌───────────┐ │
│ │ 全部清空 │ │ 继续使用 │ │
│ └───────────┘ └───────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘2.2 优点
- 无内存碎片:存活对象连续存放,分配时只需要移动指针
- 实现简单:标记 + 复制 + 清除
2.3 缺点
- 可用内存减半:永远只有一半空间可用
- 如果存活对象多,复制成本高
2.4 新生代的实际应用
新生代使用复制算法,但做了一个优化:Eden + Survivor。
新生代复制算法:
┌─────────────────────────────────────────────────────────────┐
│ │
│ Eden: 80% Survivor From: 10% Survivor To: 10% │
│ ┌────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ 新对象分配 │ │
│ │ │ │
│ │ Minor GC │ │
│ │ ↓ │ │
│ │ 存活对象复制到 Survivor To │ │
│ │ Eden + Survivor From 全部清空 │ │
│ │ ↓ │ │
│ │ Survivor To 和 Survivor From 交换角色 │ │
│ │ │ │
│ └────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘为什么 Survivor 区只需要 10%?
根据弱分代假说,新生代中大部分对象都是朝生夕灭的。Minor GC 后,存活对象只占很小一部分,Survivor 区只需要容纳这些存活对象即可。
2.5 缺点的解决方案
复制算法的缺点是如果存活对象很多,复制开销大。
解决方案:
- 分代收集:把"容易死亡"的对象放在新生代,用复制算法;把"难以死亡"的对象放在老年代,用其他算法
- Survivor 区比例:通过
-XX:SurvivorRatio调整,默认 8:1(Eden:Survivor = 8:1)
三、标记-整理算法(Mark-Compact)
3.1 算法原理
标记-整理是标记-清除的改进版,分三个阶段:
- 标记阶段:标记所有存活对象
- 整理阶段:移动存活对象,使其连续存放
- 清除阶段:清除边界外的内存
标记-整理算法:
┌─────────────────────────────────────────────────────────────┐
│ │
│ 标记阶段 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ ○ ○ ● ○ ○ ● ○ ○ │ │
│ │ ○ ● ○ ○ ● ○ ○ ○ ● = 存活(已标记) │ │
│ │ ○ ○ ○ ● ○ ○ ● ○ ○ = 死亡 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 整理阶段(移动存活对象) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ ● ● ● ● ● ● ● ● │ │
│ │ ← 存活对象向一端移动 │ │
│ │ ← 死亡对象在另一端 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 清除阶段 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ ● ● ● ● ● ● ● ● ░░░░░░░░░░ │ │
│ └─────────────────────────────────────────────────────┘ │
│ ↑ │
│ 清除边界外的内存 │
│ │
└─────────────────────────────────────────────────────────────┘3.2 优点
- 无内存碎片:存活对象连续存放
- 空间利用率高:不需要牺牲一半空间
3.3 缺点
- 移动成本高:需要移动所有存活对象
- Stop The World 时间长:整理阶段需要暂停所有线程
3.4 应用场景
标记-整理是老年代收集器(如 Serial Old、Parallel Old)的首选算法。
老年代对象存活率高,复制算法的复制成本太大。标记-整理虽然需要移动对象,但避免了内存碎片,而且老年代 GC 频率相对较低。
四、三种算法对比
| 特性 | 标记-清除 | 复制 | 标记-整理 |
|---|---|---|---|
| 时间复杂度 | 标记 + 清除 | 标记 + 复制 | 标记 + 整理 |
| 空间开销 | 无额外空间(但有碎片) | 一半空间 | 无额外空间 |
| 是否移动对象 | 否 | 是 | 是 |
| 碎片问题 | 有 | 无 | 无 |
| STW 时间 | 短 | 短 | 长 |
| 适用场景 | 老年代(CMS 并发清除) | 新生代 | 老年代 |
各算法的应用位置:
┌─────────────────────────────────────────────────────────────┐
│ JVM 堆 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 老年代(Old Generation) │
│ ┌────────────────────────────────────────────────────┐ │
│ │ 标记-整理算法(Serial Old / Parallel Old) │ │
│ │ 标记-清除算法(CMS 并发清除) │ │
│ └────────────────────────────────────────────────────┘ │
│ │
│ 新生代(Young Generation) │
│ ┌────────────────────────────────────────────────────┐ │
│ │ 复制算法(Serial / ParNew / Parallel Scavenge) │ │
│ │ - Eden → Survivor → Old │ │
│ └────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘五、面试高频问题
问题 1:为什么新生代用复制算法,老年代用标记-整理?
新生代对象存活率低,复制少量存活对象的成本很低,而且复制算法没有碎片。
老年代对象存活率高,复制所有存活对象成本太大。标记-整理虽然需要移动对象,但避免了碎片,且老年代 GC 频率低,可以承受整理的开销。
问题 2:为什么需要 Survivor 区?
如果没有 Survivor 区,对象只有 Eden 和 Old 两个去向:
- 要么创建后立即晋升到老年代(增加老年代压力)
- 要么Minor GC后全部回收(但有些对象可能只是临时死掉太快被误判)
Survivor 区提供了一个"缓冲区",让那些"还没死透"的对象多活一会儿再决定去留。
问题 3:标记-整理为什么要移动对象?
因为标记-清除会产生内存碎片。如果不移动对象,对象之间会有很多空隙,分配大对象时可能找不到足够的连续空间。
问题 4:CMS 为什么用标记-清除而不是标记-整理?
CMS 的目标是低停顿。标记-整理需要移动大量对象,STW 时间长。CMS 牺牲了空间(产生碎片)来换取时间(并发清除不 STW)。
留给你的问题
三种算法各有优缺点,JVM 根据不同区域的特性选择不同的算法。
你有没有想过:如果 Survivor 区太小,存活对象放不下,会发生什么?
答案是:存活对象会直接进入老年代。这会增加老年代的负担,可能导致 Full GC 频繁。
反过来,如果 Survivor 区太大,虽然减少了晋升到老年代的对象,但浪费了宝贵的堆空间。
所以 Survivor 区的比例(通过 -XX:SurvivorRatio 配置)是一个重要的调优点,需要根据实际场景调整。
下一节,我们来聊聊垃圾收集器的分类和组合关系。
