JVM 垃圾回收算法有哪些?


面试考察点

  1. 算法基础掌握:面试官想知道你是否清楚四种基础 GC 算法的原理和特点,而不是笼统地说一句 "标记清除"。
  2. 优缺点对比能力:每个算法都有明显的短板,面试官考察你能否说出 "空间碎片"、"复制开销"、"Stop-The-World" 这些关键词,以及它们各自的适用场景。
  3. 分代收集思想:现代 JVM 不是只用一种算法,而是根据分代特点组合使用。能不能把这层关系讲清楚,是区分 "背八股文" 和 "真理解" 的分水岭。

核心答案

JVM 的垃圾回收算法有 4 种基础算法

算法核心思路优点缺点
标记-清除(Mark-Sweep)先标记可回收对象,再统一清除实现简单空间碎片多、分配效率低
标记-复制(Copying)将存活对象复制到另一块空间,原空间整体清空没有碎片、分配快(指针碰撞)内存利用率只有一半
标记-整理(Mark-Compact)将存活对象向一端移动,清理边界外空间没有碎片、内存利用率高移动对象开销大
分代收集(Generational)根据对象存活特点分代,每代选用最合适的算法兼顾效率和利用率实现复杂

现代 JVM(HotSpot)采用的是 分代收集策略,在不同代中组合使用上述基础算法。

深度解析

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

最基础的 GC 算法,分为两步:先标记出所有需要回收的对象,再统一清除。

标记-清除的过程很直观:遍历所有对象,把不可达的标出来(标记阶段),然后把这些对象的内存直接回收(清除阶段)。

  • 优点:实现简单,不需要移动对象,不需要额外空间。
  • 缺点一:空间碎片。清除后内存中布满了大大小小的空闲块。假设现在要分配一个 3 个单位的对象,虽然总空闲空间够,但没有任何一个连续的空闲块满足要求,分配就会失败。
  • 缺点二:分配效率低。碎片化之后,分配器不得不用 "空闲列表"(Free List)来管理可用内存,每次分配都要遍历列表找合适大小的块,比起 "指针碰撞"(Bump the Pointer)那种 O(1) 的分配方式,效率差了不少。

二、标记-复制算法(Copying)

为了解决碎片问题,标记-复制算法把内存一分为二,每次只用其中一半。

标记-复制的思路很巧妙:每次 GC 只扫描当前正在使用的半区(From 区),把存活对象 按顺序 复制到另一半(To 区),然后 From 区整个清空。复制完成后两个角色互换。

  • 优点:复制过去之后对象是紧凑排列的,没有碎片。分配新对象时只需要移动一个指针就行了(指针碰撞),O(1) 搞定,效率极高。
  • 缺点:太费内存了。可用空间直接打了个五折,对于堆内存寸土寸金的 JVM 来说,这个代价很大。另外如果存活对象很多,复制操作本身的开销也不小。

HotSpot 的新生代做了一个很聪明的优化——Appel 式回收。它不是严格 1:1 平分,而是把新生代分为一个较大的 Eden 区和两个较小的 Survivor 区(FromTo),默认比例 8:1:1。每次 GC 只用 Eden + 一个 Survivor,浪费的空间只有 10% 而不是 50%。这块在后面的分代收集中会详细说。

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

标记-复制太费空间了,那有没有既不碎片、又不浪费一半内存的方案?标记-整理就是答案。

标记-整理分三步:先标记存活对象(和标记-清除一样),然后把所有存活对象往内存的一端 "挪动",最后把边界以外的空间全部释放。

  • 优点:既没有碎片,又不浪费额外空间。对老年代这种对象存活率高、GC 不频繁的区域特别合适。
  • 缺点:要移动对象就意味着要更新所有指向这些对象的引用,这个开销不小。尤其是老年代对象多的时候,整理过程会比较慢。

所以这也是一个权衡:新生代存活少,用复制算法划算;老年代存活多,移动所有对象的成本太高,但碎片更不能接受,所以用标记-整理。

四、分代收集算法(Generational Collection)

重点来了。前面三种算法各有优劣,现代 JVM 的做法是——不选一个,全都要

分代收集的核心思想基于一个经验规律:绝大多数对象都是 "朝生夕死" 的。统计表明,约 98% 的对象在一次 Minor GC 中就会被回收。根据对象存活特点的不同,把堆内存分为不同的 "代",每代选用最适合的算法。

分代收集的整体布局如上图所示,Java 堆被划分为新生代和老年代两大区域:

  • 新生代(Young Generation):分为 Eden 区和两个 Survivor 区(S0S1),默认比例 8:1:1。新对象优先在 Eden 区分配。每次 Minor GC 时,将 Eden 和当前使用的 Survivor 区中的存活对象复制到另一个 Survivor 区,然后清空前两个区域。使用标记-复制算法,因为新生代对象存活率极低,只需要复制少量存活对象,效率很高。而且由于有 Survivor 区做缓冲,实际浪费的空间只有 10%(一个 Survivor 区),而不是 50%。
  • 老年代(Old Generation):存放长期存活的对象和大对象。由于老年代对象存活率高,如果用复制算法,需要复制大量对象,效率太低。所以老年代使用 标记-清除标记-整理 算法。具体用哪种取决于选择的垃圾收集器:CMS 使用标记-清除(追求低延迟),Serial OldParallel Old 使用标记-整理(追求吞吐量)。

五、对象如何晋升到老年代?

聊到分代收集,就绕不开对象从新生代晋升到老年代的规则,这同样是高频考点:

晋升条件说明
年龄达到阈值对象在 Survivor 区每熬过一次 Minor GC,年龄 +1,默认达到 15 岁-XX:MaxTenuringThreshold)晋升到老年代
大对象直接进入老年代超过 -XX:PretenureSizeThreshold 设定值的对象,直接在老年代分配,避免在新生代来回复制
动态年龄判断Survivor 区中相同年龄的所有对象大小总和超过 Survivor 空间的一半,则年龄 >= 该年龄的对象直接晋升
空间分配担保Minor GC 后 Survivor 区放不下所有存活对象,通过担保机制直接进入老年代

六、三种基础算法全面对比

一张表总结清楚:

维度标记-清除标记-复制标记-整理
空间碎片❌ 有碎片✅ 无碎片✅ 无碎片
内存利用率✅ 高❌ 最高浪费 50%✅ 高
移动对象❌ 不移动✅ 复制存活对象✅ 移动存活对象
引用更新❌ 不需要✅ 需要更新引用✅ 需要更新引用
分配方式空闲列表指针碰撞指针碰撞
适用场景存活对象多存活对象少存活对象多
典型应用CMS 的老年代新生代(Eden + Survivor)Serial Old、Parallel Old

一句话总结这个取舍关系:空间碎片和内存利用率不可兼得,要么牺牲空间换无碎片(复制),要么牺牲时间换无碎片(整理),要么省时间但忍受碎片(清除)。

面试高频追问

  1. 为什么新生代用复制算法,老年代用标记-整理?

    • 核心原因就一个字:活的对象少不多。新生代 98% 的对象熬不过一次 GC,用复制算法只需要复制那 2% 的存活对象,非常高效。老年代对象存活率高,复制意味着要拷贝大部分对象,还不如直接整理。
  2. 为什么 Survivor 区要设计两个?

    • 为了避免复制算法的 "全量复制" 问题。两个 Survivor 交替使用,一个做来源,一个做目标,保证每次 GC 后存活对象都有地方放。只有一个的话,复制算法就没法做了。
  3. CMS 为什么用标记-清除而不是标记-整理?

    • 因为 CMS 的设计目标是 低延迟。标记-整理需要移动对象并更新引用,这个过程中用户线程必须暂停(STW),而 CMS 为了减少停顿,选择了不移动对象的标记-清除。代价就是会有碎片,碎片太多时会退化为 Serial Old 做一次全量整理。
  4. JDK 11 引入的 ZGC 和 Shenandoah 用的什么算法?

    • 它们用的是 染色指针 + 读屏障 实现的并发整理算法,可以在移动对象的同时让用户线程继续运行,理论上做到了整理操作不产生 STW 停顿。这是 GC 技术的前沿方向。

常见面试变体

  • "说一说 JVM 的垃圾回收算法,各自的优缺点是什么?"
  • "为什么新生代用复制算法?老年代为什么不用?"
  • "JVM 的分代收集是怎么做的?为什么要分代?"
  • "标记-清除和标记-整理有什么区别?"

记忆口诀

四种算法:标记清除有碎片,标记复制费空间,标记整理两全美但移动慢,分代收集组合拳。 分代对应:新生代配复制(活得少、复制快),老年代配清除或整理(活得多、移动慢)。 Eden Survivor 比例:8:1:1(八成新建,一成来,一回去)。

总结

JVM 有四种基础 GC 算法:标记-清除、标记-复制、标记-整理、分代收集。现代 HotSpot 采用分代策略——新生代用复制算法(存活少、效率高),老年代用标记-清除或标记-整理(存活多、不能浪费空间)。面试时把三种基础算法的优缺点和分代收集的组合思路讲清楚,基本就稳了。