Skip to content

垃圾收集算法:标记-清除、复制、标记-整理

GC 算法是 JVM 垃圾回收的核心。

不同的算法有不同的优缺点,适用于不同的场景。今天我们来深入了解三种经典的 GC 算法。


一、标记-清除算法(Mark-Sweep)

1.1 算法原理

标记-清除是最基础的 GC 算法,分为两个阶段:

  1. 标记阶段:从 GC Roots 出发,标记所有存活的对象
  2. 清除阶段:遍历整个堆,清除所有未标记的对象
标记-清除算法:
┌─────────────────────────────────────────────────────────────┐
│  标记阶段(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 算法原理

标记-整理是标记-清除的改进版,分三个阶段:

  1. 标记阶段:标记所有存活对象
  2. 整理阶段:移动存活对象,使其连续存放
  3. 清除阶段:清除边界外的内存
标记-整理算法:
┌─────────────────────────────────────────────────────────────┐
│                                                             │
│  标记阶段                                                   │
│  ┌─────────────────────────────────────────────────────┐  │
│  │  ○ ○ ● ○ ○  ● ○  ○                                  │  │
│  │  ○ ● ○ ○ ●  ○ ○  ○      ● = 存活(已标记)           │  │
│  │  ○ ○ ○ ● ○  ○ ●  ○      ○ = 死亡                    │  │
│  └─────────────────────────────────────────────────────┘  │
│                                                             │
│  整理阶段(移动存活对象)                                    │
│  ┌─────────────────────────────────────────────────────┐  │
│  │  ● ● ● ● ●  ● ●  ●                                  │  │
│  │                      ← 存活对象向一端移动             │  │
│  │                      ← 死亡对象在另一端               │  │
│  └─────────────────────────────────────────────────────┘  │
│                                                             │
│  清除阶段                                                   │
│  ┌─────────────────────────────────────────────────────┐  │
│  │  ● ● ● ● ●  ● ●  ●    ░░░░░░░░░░                    │  │
│  └─────────────────────────────────────────────────────┘  │
│                            ↑                               │
│                      清除边界外的内存                        │
│                                                             │
└─────────────────────────────────────────────────────────────┘

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 配置)是一个重要的调优点,需要根据实际场景调整。

下一节,我们来聊聊垃圾收集器的分类和组合关系。

基于 VitePress 构建