从零学 JVM(六):垃圾回收算法详解——标记清除、标记复制与标记整理

一、为什么需要垃圾回收算法?

JVM 通过可达性分析,从 GC Roots 出发判断对象是否可达。无法从 GC Roots 到达的对象,就具备了被回收的条件。

那么接下来的问题是:

JVM 找到垃圾对象之后,具体如何释放它们占用的内存?

这就涉及垃圾回收算法。

经典的垃圾回收算法主要有三种:

  1. 标记-清除算法(Mark-Sweep)
  2. 标记-复制算法(Mark-Copy)
  3. 标记-整理算法(Mark-Compact)

它们的目标都是回收垃圾对象,但在内存处理方式、空间利用率和执行开销方面存在明显差异。

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

标记-清除算法是比较基础的一种垃圾回收算法。

它分为两个阶段:

  1. 标记阶段:从 GC Roots 出发,标记所有可达的存活对象。
  2. 清除阶段:遍历堆内存,将没有被标记的垃圾对象占用的空间回收。

可以简单理解为:

text 复制代码
标记存活对象
      |
      v
遍历堆内存
      |
      v
清除未被标记的对象

1. 标记-清除算法的优点

  • 实现相对简单。
  • 不需要移动存活对象。
  • 不需要额外准备一块与原空间相同大小的复制空间。

2. 标记-清除算法的缺点

最大的问题是:容易产生内存碎片。

例如,堆内存中原本有多个对象:

text 复制代码
对象 A | 对象 B | 对象 C | 对象 D | 对象 E

假设 B、D 已经成为垃圾:

text 复制代码
对象 A | 垃圾 B | 对象 C | 垃圾 D | 对象 E

清除之后:

text 复制代码
对象 A | 空闲 | 对象 C | 空闲 | 对象 E

虽然总的空闲空间增加了,但是这些空间可能分散在堆内存的不同位置。

如果后续需要分配一个比较大的连续对象空间,即使堆中空闲空间总量足够,也可能因为没有足够大的连续空间而无法直接分配。

这就是内存碎片问题。

3. 适用场景

标记-清除算法不需要移动对象,因此可以避免对象移动带来的额外开销。

但如果内存分配需要大量连续空间,碎片问题就会比较明显。

三、标记-复制算法(Mark-Copy)

标记-复制算法的核心思想是:

将内存划分为两块区域,每次只使用其中一块。当发生垃圾回收时,把存活对象复制到另一块空闲区域,然后清空原来的区域。

它的基本过程:

  1. 在当前使用的空间中标记存活对象。
  2. 将存活对象复制到另一块空间。
  3. 更新对象引用,使其指向复制后的对象。
  4. 清空原来的空间。
  5. 两块空间交换角色,下一次 GC 再反向执行。

1. 为什么复制算法没有内存碎片?

因为复制时,存活对象会被集中放到目标空间的一端,通常可以连续排列。

例如原来是:

text 复制代码
A | 垃圾 | B | 垃圾 | C

复制后:

text 复制代码
A | B | C | 空闲 | 空闲

这样,剩余空间就是连续的,不会因为垃圾对象分散而产生外部碎片。

2. 标记-复制算法的优点

  • 不会产生外部内存碎片。
  • 回收后存活对象连续排列。
  • 后续分配空间比较简单,通常可以通过移动分配指针完成。

3. 标记-复制算法的缺点

  • 需要准备目标空间。
  • 复制存活对象会产生额外的内存读写开销。
  • 如果存活对象很多,需要复制的数据量就会比较大。

如果采用最简单的等大小双空间方案,每次只有一半空间用于对象分配,空间利用率不高。

不过,实际 JVM 新生代并不是简单地永久浪费一半空间。经典分代收集方案会将新生代划分为 Eden 和两个 Survivor 区,通过更合理的空间比例降低复制空间的浪费。

四、标记-整理算法(Mark-Compact)

标记-整理算法可以理解为标记-清除算法和复制算法思路上的结合。

它主要分为三个阶段:

  1. 标记:标记所有存活对象。
  2. 整理:将存活对象向内存的一端移动。
  3. 清理:清理边界之外的内存空间。

例如,原来的内存布局:

text 复制代码
对象 A | 垃圾 | 对象 B | 垃圾 | 对象 C | 对象 D

整理之后:

text 复制代码
对象 A | 对象 B | 对象 C | 对象 D | 空闲空间

存活对象被集中到一端,另一端形成连续的空闲空间。

1. 标记-整理算法的优点

  • 不需要像经典复制算法那样长期保留一块等大的备用空间。
  • 回收后存活对象连续排列。
  • 不会产生外部内存碎片。
  • 后续分配对象时,可以使用连续空间进行分配。

2. 标记-整理算法的缺点

它最大的代价是:需要移动存活对象,并且更新对象引用。

对象移动不是简单地把字节复制到新地址就结束了。

因为其他对象可能持有指向它的引用,移动之后,需要确保这些引用能够正确指向对象的新位置。

因此,标记-整理算法可能产生较大的对象移动和引用更新开销。

五、三种垃圾回收算法对比

前面介绍了三种算法,我们把它们放在一起比较:

对比维度 标记-清除 标记-复制 标记-整理
基本过程 标记后清除垃圾 复制存活对象 标记后移动存活对象
是否移动对象 否 是 是
是否产生外部碎片 可能产生 不产生 不产生
是否需要备用空间 通常不需要等大备用区 需要目标空间 通常不需要等大备用区
存活对象较多时 清除成本与堆扫描有关 复制成本较高 移动成本可能较高
典型特点 实现简单 适合存活对象较少的场景 适合希望整理连续空间的场景

三种算法怎么选择?

没有一种算法能够在所有场景下都取得最好的效果。

选择时主要考虑:

  • 对象存活率。
  • 可用内存空间。
  • 对象移动成本。
  • 内存碎片是否会影响后续分配。
  • 垃圾回收暂停时间和吞吐量要求。

因此,JVM 并不是所有内存区域都使用同一种垃圾回收算法,而是会根据不同区域的对象特点采用不同的回收策略。

六、为什么 JVM 要采用分代收集思想?

前面三种算法各有优缺点,那么 JVM 有没有办法结合它们的特点?

这就引出了**分代收集(Generational Collection)**思想。

分代收集不是一种独立的垃圾回收算法,而是一种根据对象生命周期特点组织内存、选择回收策略的思路。

它基于一个重要的经验规律:

大多数对象朝生夕灭,只有少数对象会长期存活。

例如:

java 复制代码
public void process() {
    User user = new User();
    Order order = new Order();

    // 执行业务逻辑
}

方法执行结束后,如果这些对象不再被其他地方引用,它们很快就可能成为垃圾。

但也有一些对象会长期存活,例如:

  • 应用级缓存。
  • 单例对象。
  • 长期运行的业务状态对象。

因此,可以根据对象存活时间,将堆内存划分为不同区域。

1. 新生代(Young Generation)

新创建的对象通常优先在新生代分配(具体分配位置取决于对象大小、JVM 参数和收集器实现)。

新生代对象数量变化快,而且通常有大量对象很快死亡。

因此,新生代适合使用复制算法的思路:

  • 将存活对象复制到 Survivor 区。
  • 回收死亡对象占用的空间。
  • 多次 GC 后仍然存活的对象,可能晋升到老年代。

2. 老年代(Old Generation)

如果对象经过多次垃圾回收仍然存活,就可能进入老年代。

老年代中的对象通常生命周期较长,整体存活率往往比新生代高。

如果对大量存活对象进行复制,成本可能比较高。

因此,经典分代收集器会根据老年代特点,采用标记-清除或标记-整理等策略。

需要注意:这只是经典分代收集思想的示意。现代收集器的内存组织和算法组合可能更加复杂,不能把所有收集器简单归纳为"新生代复制、老年代整理"。

七、Eden 和 Survivor 区是怎么配合工作的?

在经典分代收集设计中,新生代通常会进一步划分为:

  • Eden 区。
  • Survivor 0 区。
  • Survivor 1 区。

对象通常首先在 Eden 区分配。

当 Eden 区空间不足并触发 Young GC 时,垃圾收集器会处理 Eden 和当前使用的 Survivor 区:

  1. 找出其中的存活对象。
  2. 将存活对象复制到另一个 Survivor 区。
  3. 对存活对象的年龄进行更新。
  4. 清空原来的 Eden 和 Survivor 区。
  5. 交换两个 Survivor 区的角色。

例如:

text 复制代码
GC 前:

Eden       : A B C D
Survivor 0 : E F
Survivor 1 : 空

假设 A、C、E 存活,其他对象死亡。

GC 后:

Eden       : 空
Survivor 0 : 空
Survivor 1 : A C E

下一次 Young GC 时,Survivor 1 就会作为存活对象来源,Survivor 0 作为目标空间。

这就是两个 Survivor 区交替使用的基本思想。

对象什么时候进入老年代?

常见情况包括:

  1. 对象年龄达到晋升阈值。
  2. Survivor 空间不足,部分对象可能通过担保机制直接晋升。
  3. 某些收集器或 JVM 参数会根据运行时情况调整晋升策略。

注意:对象年龄并不意味着每次 GC 都严格加 1 后达到固定阈值才晋升。实际晋升还会受到收集器实现、年龄分布和空间情况影响。

八、三种算法与常见垃圾收集器的关系

理解经典算法后,再看垃圾收集器就容易很多。

垃圾收集器 相关算法或思路
Serial 新生代采用复制算法,老年代 Serial Old 采用标记-整理
ParNew 新生代采用复制算法
Parallel Scavenge 新生代采用复制算法
Parallel Old 老年代采用标记-整理
CMS 主要采用标记-清除思路
G1 基于 Region,将存活对象疏散到其他 Region,兼顾整理效果

这里有几个需要注意的地方:

  • Serial 和 Parallel Scavenge 等经典分代收集器会结合新生代复制、老年代整理的思路。
  • CMS 的主要回收流程采用标记-清除,因此可能产生内存碎片;CMS 在特定条件下还可能退化为 Serial Old 等收集方式。
  • G1 并不是简单地对整个老年代执行一次传统标记-整理,而是以 Region 为单位进行回收,通过复制存活对象实现空间整理效果。

这些内容会在后续介绍具体垃圾收集器时展开。

九、本篇总结

  1. 标记-清除算法先标记存活对象,再清除未被标记的对象,优点是不需要移动存活对象,缺点是可能产生内存碎片。
  2. 标记-复制算法将存活对象复制到另一块空间,回收后对象连续排列,但需要目标空间并承担复制成本。
  3. 标记-整理算法将存活对象移动到内存一端,能够减少碎片,但需要移动对象并更新引用。
  4. 三种算法各有优缺点,不存在适用于所有场景的最优算法。
  5. 分代收集是一种根据对象生命周期特点组织内存和选择回收策略的思想,不是独立的 GC 算法。
  6. 新生代对象通常存活率较低,适合采用复制算法的思路。
  7. 老年代对象通常存活率较高,需要结合具体收集器权衡回收和对象移动成本。
  8. 经典新生代会划分为 Eden、Survivor 0 和 Survivor 1,两个 Survivor 区交替作为复制的目标空间。
  9. 对象经过多次回收仍然存活,可能晋升到老年代;晋升条件不只取决于年龄。
  10. Serial、Parallel、CMS、G1 等收集器会根据自身设计组合不同算法或回收策略。

十、本篇面试题与参考答案

1. JVM 常见的垃圾回收算法有哪些?

参考答案:

经典的垃圾回收算法主要有三种:标记-清除、标记-复制和标记-整理。

标记-清除先标记存活对象,再清除垃圾对象;标记-复制将存活对象复制到另一块空间;标记-整理则将存活对象移动到内存一端,再清理边界之外的空间。

它们主要在对象是否移动、空间利用率、内存碎片和执行开销方面存在差异。

2. 标记-清除算法有什么缺点?

参考答案:

标记-清除算法最大的缺点是容易产生内存碎片。

因为它只清除垃圾对象,并不移动存活对象,所以回收后空闲空间可能分散在堆内存的不同位置。即使总空闲空间足够,也可能找不到足够大的连续空间来分配新对象。

3. 标记-复制算法为什么适合新生代?

参考答案:

新生代对象具有朝生夕灭的特点,通常只有少部分对象能够存活下来。

复制算法只需要将存活对象复制到目标空间,死亡对象不需要复制,整体复制成本相对较低。同时,复制完成后存活对象连续排列,不容易产生内存碎片。

经典新生代会通过 Eden 和两个 Survivor 区配合使用复制算法,降低等大小双空间方案带来的空间浪费。

4. 标记-复制算法有什么缺点?

参考答案:

复制算法需要准备目标空间,并且要把存活对象复制过去。如果对象存活率较高,就需要复制大量对象,产生较大的内存读写开销。

如果使用最简单的等大小双空间方案,每次只有一半空间能够用于对象分配,空间利用率也不高。

5. 标记-整理和标记-清除有什么区别?

参考答案:

两者都会先标记存活对象,但后续处理方式不同。

标记-清除直接回收未标记对象占用的空间,不移动存活对象,因此可能产生内存碎片。

标记-整理会将存活对象移动到内存的一端,再清理边界之外的空间,可以形成连续空闲区域,但需要承担对象移动和引用更新的成本。

6. 什么是分代收集?为什么要分代?

参考答案:

分代收集是一种根据对象生命周期特点组织内存、选择回收策略的思想。

它基于大多数对象朝生夕灭、少数对象长期存活的经验规律,将堆划分为新生代和老年代。

新生代对象存活率通常较低,适合采用复制算法;老年代对象存活率通常较高,需要根据具体收集器选择合适的回收策略。

7. Eden、Survivor 0 和 Survivor 1 是如何配合工作的?

参考答案:

新对象通常优先在 Eden 区分配。发生 Young GC 时,垃圾收集器会从 Eden 和当前使用的 Survivor 区中找出存活对象,将它们复制到另一个 Survivor 区,然后清空原来的空间。

下一次 Young GC 时,两个 Survivor 区交换角色,一个作为存活对象来源,一个作为目标空间。通过这种方式,存活对象可以在两个 Survivor 区之间交替复制。

8. 对象什么时候会进入老年代?

参考答案:

常见情况包括对象年龄达到晋升阈值,或者 Survivor 空间不足导致部分对象提前晋升。此外,具体收集器还可能根据对象年龄分布、空间情况和运行时策略调整晋升行为。

因此,不能简单地认为对象一定要经历固定次数的 Young GC 才能进入老年代。

9. CMS 和 G1 分别使用什么垃圾回收思路?

参考答案:

CMS 主要采用标记-清除算法,特点是并发收集,能够降低部分垃圾回收阶段的停顿时间,但可能产生内存碎片。

G1 将堆划分为多个 Region,通过选择收益较高的 Region 进行回收,并将存活对象疏散到其他 Region,实现空间整理效果。它不是简单地对整个老年代执行传统的标记-整理算法。

相关推荐
严肃小白兔2 小时前
第 06/24 期:神经网络的反向传播算法:从链式法则到梯度计算
人工智能·神经网络·算法
落羽的落羽2 小时前
【C++】C++17的主要特性
linux·服务器·开发语言·c++·算法·机器学习
【心态好不摆烂】3 小时前
数据结构基础精讲3
数据结构·算法
漂流瓶jz4 小时前
UVA-1613 K度图的着色 题解答案代码 算法竞赛入门经典第二版
算法·深度优先·图论·贪心·aoapc·算法竞赛入门经典·uva
金銀銅鐵4 小时前
带您看懂字节码指令(三):一行加法里有什么
java·jvm
Pniubi5 小时前
动态规划入门 ( - 力扣509斐波那契数)
算法·动态规划
吴长建先生重名了6 小时前
GitHub Actions部署报表系统踩坑记:幂等性与限
算法
贝壳6906 小时前
一个2026年2月才上传的库,凭什么敢碰2009年就有人写烂的题目
算法