九个 JVM 垃圾回收器,一篇从头捋到尾

📅 2026/7/7 18:10:50 👁️ 阅读次数 📝 编程学习
九个 JVM 垃圾回收器,一篇从头捋到尾

面试里聊到 JVM,垃圾回收器几乎是必问的。Serial、Parallel、CMS、G1、ZGC 这些名字你大概都听过,但每个出现在什么版本、解决什么问题、最后怎么退出的,串起来讲清楚的人不多。这篇文章把九个收集器从头捋一遍,顺便分析各阶段的 STW 停顿。不包括算法原理,那些够再写一篇。

垃圾回收器详解

先说说 G1 出来之前那些"分代"的收集器。它们按新生代和老年代分开工作,互相配对。配对的模式通常是这样:新生代一个收集器,老年代一个收集器,JDK 8 之前没 G1 的时候大家都这么搭着用。

Serial 收集器

最古老的一个。复制算法,新生代单线程:标记、清理、复制都在一个线程里跑完。优点是实现简单,没有线程切换的开销,在单核环境或者客户端模式下效率反而高。现在的服务器基本都是多核,Serial 用得少了,但如果你在 Docker 里只分配了一个 CPU,Serial 反而是最稳的选择。启动参数-XX:+UseSerialGC

跟其他收集器配对的组合是 Serial + Serial Old,或者 Serial + CMS(CMS 当老年代,Serial 管新生代,前提是 JDK 版本还支持 CMS)。不过在单核机器的限定场景下,Serial 的停顿反而可能比并行收集器更短——因为少了两三倍的线程上下文切换。

ParNew 收集器

复制算法,新生代并行。本质上是 Serial 的多线程版本,但它存在的价值几乎完全系在 CMS 身上:CMS 只能配 ParNew 做新生代回收。JDK 9(JEP 291)标了废弃,JDK 14(JEP 363)随 CMS 一起从 HotSpot 正式移除。JDK 14 以后 ParNew 就不存在了,面试里知道它跟 CMS 的绑定关系就行。

Parallel Scavenge 收集器

复制算法,新生代并行。跟 ParNew 的区别是关注点不同:ParNew 活着是为了配合 CMS,Parallel Scavenge 活着的目的是把吞吐量做到最高。

吞吐量的公式:用户线程时间 /(用户线程时间 + GC 线程时间)。它追求的不是让单次停顿变短,而是让单位时间内完成的总任务量更多。适合后台计算、批处理这类场景。

调优上,-XX:MaxGCPauseMillis设期望的最大停顿时间,-XX:GCTimeRatio控制吞吐量目标(默认 99,意思 GC 时间不超过总时间的 1%)。这两个参数本质上互相妥协:你能接受越长的单次停顿,吞吐量的上限就越高。还有一个-XX:UseAdaptiveSizePolicy,默认开启,让 JVM 自己动态调整新生代大小、Eden 和 Survivor 比例来逼近参数目标,不需要手动设-Xmn

实际场景里,Parallel 是最省心的收集器之一:扔给后台任务,开个自适应策略,基本不用管。JDK 8 的默认 GC 就是它,直到今天很多批处理服务还在用。

Serial Old 收集器

标记-整理算法,老年代单线程。Serial 的老年代搭档,同时也是 CMS 的备用方案:CMS 出现 Concurrent Mode Failure 的时候会降级到它,做一次 STW 全量回收。现在基本只在面试题里见到了。

Parallel Old 收集器

标记-整理算法,老年代并行。JDK 6 才引入,在这之前 Parallel Scavenge 只能配 Serial Old,老年代回收是串行的,成了吞吐量的瓶颈。Parallel Old 补齐了这块,吞吐量优先这条线才算完整。

CMS 收集器

标记-清除算法,老年代并发,目标是让停顿时间尽量短。它的生命周期非常典型:出现的时候解决了大问题,后来因为自身设计缺陷被更好的方案替代。

CMS 的工作分四个阶段:

  • 初始标记(STW):从 GC Roots 出发标记直接可达的对象。GC Roots 数量有限,耗时很短。
  • 并发标记(不 STW):从 GC Roots 做可达性分析。耗时最长但跟应用线程并行。
  • 重新标记(STW):修正并发阶段发生变化的对象引用。
  • 并发清除(不 STW):清理标记好的垃圾对象。

几个硬伤导致它最终被移除。

首先是碎片问题。标记-清除算法不整理内存,老年代跑久了碎片越来越多。大对象分配时如果没有连续空间,就会触发 Concurrent Mode Failure,降级到 Serial Old 做一次全量 STW 回收,停顿时间直接爆炸,这跟 CMS 的设计初衷完全相反。

其次是 CPU 占用。CMS 默认的 GC 线程数是(CPU 数 + 3)/ 4,如果服务器只有两核,GC 线程能占到一半以上。

还有一个问题是浮动垃圾(Floating Garbage)。CMS 的并发标记和清除阶段,用户线程同时在跑,会不断产生新垃圾。这些垃圾只能在下次 GC 才能清理,所以 CMS 需要预留内存空间给浮动垃圾。如果预留不够,提前触发 Concurrent Mode Failure 的概率就很大。-XX:CMSInitiatingOccupancyFraction就是调这个阈值的,默认 68%,意思老年代用了 68% 就开始回收,留出空间给浮动垃圾。

另外 CMS 的重新标记阶段默认不做一次新生代 GC,如果年轻代的对象大量引用了老年代,重新标记时扫描的量就很大。可以配合-XX:+CMSScavengeBeforeRemark,在重新标记前先触发一次 Young GC,减少需要扫描的对象数量。但这又是一个两难:新生代 GC 本身也 STW。

还有就是实现太复杂,维护成本高。JDK 9 开始推废弃,JDK 14 正式移除。现在 JDK 14 以上已经用不了 CMS 了。如果面试聊到 CMS,能说清楚它为什么被淘汰,比背它的四个阶段更有意义。

G1 收集器

整体基于标记-整理,局部采用复制。JDK 7 引入,JDK 9 起成为服务端默认 GC。

G1 最大的变化是把堆切成很多小块,每个块叫 Region。Region 大小从 1MB 到 32MB,总数大概 2048 个。它不再硬性划分新生代和老年代的连续空间,而是给每个 Region 动态分配角色:这块 Region 这次是 Eden,下次可能就是 Survivor 或者 Old。

Region 分几种类型:Eden Region(新对象分配区)、Survivor Region(存活对象区)、Old Region(老年代对象区)。还有一种 Humongous Region,存放大小超过 Region 一半的大对象。Humongous Region 不会被移动,直接整块回收,所以大对象在 G1 里分配和回收的开销都比普通对象高。

回收策略也不一样了:不是全堆一起扫,而是挑一个 Collection Set,里面是回收收益最高的 Region。收益用"释放的空间 / 回收耗时"来算。这就是 G1 名字的由来:Garbage First,先做最值的。

Young GC 是 G1 最基本的回收。Eden Region 满了就触发,把存活对象复制到 Survivor Region,Eden 全部清空。这个过程 STW,但因为 Region 小,停顿可控。

Mixed GC 是 G1 的特色。当老年代的堆占用比例超过-XX:InitiatingHeapOccupancyPercent(默认 45%),G1 会启动并发标记周期。标记完成后不是一次性全收,而是分几次 Mixed GC,每次选一部分老年代 Region 加进 Collection Set,跟新生代一起回收。这样可以打散停顿时间。

并发标记周期本身也有几个阶段:初始标记(STW,很短,随 Young GC 一起做)、并发标记(不 STW)、再标记(STW,用 SATB 算法处理并发期间变化的对象)、清理(STW,统计每个 Region 的存活对象和回收收益)。这里用到 SATB(Snapshot-At-The-Beginning)算法:在初始标记时拍一张堆快照,并发标记期间只记录快照之后的变化,不重复扫描已经标记过的对象。

G1 还有一个重要的数据结构叫 Remembered Set(RSet)。每个 Region 维护一个 RSet,记录哪些外部 Region 引用了自己。这样在回收一个 Region 时不需要全堆扫描,只看 RSet 就知道引用了当前 Region 的外部指针在哪。RSet 的粒度是 Card(卡页),每张卡 512 字节。G1 通过写屏障(Write Barrier)来维护 RSet 的更新。

G1 用了一个停顿预测模型来逼近-XX:MaxGCPauseMillis的目标(默认 200ms)。它根据历史数据预测:这次选多少个 Region 能控制在目标停顿时间内。因为预测依赖历史,流量波动大的应用在起始阶段停顿会不稳定,跑一段时间就稳了。

调优上,-XX:MaxGCPauseMillis设目标停顿时间。这个值设得越小,每次选的 Region 就越少,GC 频率会上升,吞吐量下降。设 100ms 和 200ms 的区别就是这个。如果堆很大(几十 G)而且存活对象多,转移阶段的 STW 还是会拉长,这时候该考虑 ZGC 或者调大停顿目标。

G1 从 JDK 9 到 JDK 21 做了大量改进。JDK 10 引入了并行 Full GC(原来 G1 的 Full GC 是单线程的)。JDK 12 改进了 Promptness(更及时地回收大对象)。JDK 16 优化了 RSet 的内存占用。到了 JDK 21,G1 已经非常成熟,默认配置下大多数应用不需要额外调优。

ZGC 收集器

JDK 11 实验特性引入,JDK 15 正式可用,JDK 21 引入分代 ZGC(JEP 439)。

核心设计是染色指针和读屏障。在 64 位指针里留出几位做标记位,对象的状态可以不经过对象头就能判断。具体来说,在 Linux 的 64 位地址空间中,实际能用的是低 48 位,ZGC 从里面借了高几位来做标记:Finalizable、Remapped、Marked1、Marked0,剩下 42 位可以做 4TB 的堆。这样对象当前是标记中还是已转移,看一眼指针就清楚了。

并发整理是 ZGC 的另一个核心能力。它把重映射(Remapping)合并到下一次 GC 的标记阶段中做,不需要单独的转移后处理。具体过程是:并发标记时通过读屏障检测指针是不是旧地址,如果是就转发到新地址。这样大多数 STW 工作都被推到了并发阶段。

停顿时间稳定在 1ms 以内,跟堆大小无关。4G 的堆还是 400G 的堆,停顿时间差别不大。适合超大堆(TB 级)和低延迟要求高的服务,比如金融交易、广告投放。

JDK 21 的分代 ZGC 把新生代和老年代分开回收。不分代的 ZGC 每次回收都扫全堆,分代之后新生代回收更频繁但更快(只扫新生代 Region),老年代回收次数减少。整体吞吐量提升明显。启动参数-XX:+UseZGC -XX:+ZGenerational

ZGC 有一个实际限制:染色指针依赖 Linux 对多映射内存(Multimap Memory)的支持。在 macOS 和 Windows 上,ZGC 无法利用染色指针的硬件特性,退而使用软件模拟,效果不如 Linux。这是选型时需要考虑的。

Shenandoah 收集器

Red Hat 开发,JDK 12 引入,JDK 15 正式可用。跟 ZGC 一样追求低停顿,实现方式不同:用 Brooks Pointer(对象头里加一个转发指针)做并发整理,不依赖染色指针。

Brooks Pointer 的工作原理是:在每个对象的对象头里加一个指针(默认指向自己),当对象被移动后,这个指针更新为新地址。所有访问对象的操作都通过这个指针做一次间接跳转。这样在并发整理时,访问到旧对象的线程会被自动转发到新地址。

跟 ZGC 比,Shenandoah 的优势是不需要操作系统层面的特殊支持:ZGC 的染色指针在 Linux 上依赖大页支持,Shenandoah 没有这个限制,跨平台兼容性更好。缺点是 Barriere 的开销比 ZGC 的 Load Barrier 高一些(因为涉及间接寻址),社区跟进慢一些,使用面不如 ZGC 广。两者在停顿时间上已经拉得很近了。

Shenandoah 也有一套完备的分阶段流程:初始标记(STW)、并发标记(不 STW)、最终标记(STW)、并发清理(不 STW)、并发回收(不 STW,这就是核心——用 Brooks Pointer 实现并发转移)、初始引用更新(STW)、并发引用更新(不 STW)、最终引用更新(STW)。可以看到 Shenandoah 把原本需要 STW 的转移和引用更新都做成并发了,只在关键边界点做短暂的 STW。

各收集器一句话

把这九个收集器按关注点分一下:

  • Serial:简单直接,小内存或单核的稳妥选择。
  • Parallel:吞吐量优先,适合后台计算和批处理。如果 JDK 8 没改过 GC 配置,默认就是它。
  • CMS:低延迟老年代,但碎片和 CPU 硬伤,JDK 14 已移除。
  • G1:分区回收,在吞吐量和延迟之间找平衡,JDK 9 以后默认选项。
  • ZGC、Shenandoah:微秒级停顿,适合大堆低延迟。ZGC 在 Linux 上更成熟,Shenandoah 跨平台更好。

STW 阶段分析

不同收集器的 STW 对比

Serial 和 Parallel 的整个过程都是 STW:标记、复制、整理,没有并发阶段。好处是实现简单,没有并发一致性问题。坏处是停顿时间和堆大小成正比,堆越大停得越久。

CMS 的 STW 集中在初始标记和重新标记两个阶段,并发阶段不 STW,整体停顿比 Serial/Parallel 短,但要承受碎片和 CPU 开销的代价。

G1 的 STW 覆盖初始标记(随 Young GC 做)、再标记、清理、以及转移阶段。和 CMS 的不同是,G1 的转移阶段是 STW 的而 CMS 是并发清除。G1 选择把复制锁成 STW 来换取确定性:一次性复制完 Region 里的全部对象,而不是让应用线程跟 GC 线程同时修改指针,这样可以规避并发一致性的复杂度。事实证明这个取舍是对的——G1 的停顿预测模型保证了转移阶段的 STW 时间是可预期的。

ZGC 和 Shenandoah 的 STW 阶段极少。ZGC 把标记和转移都做到并发了,STW 只在初始标记和少量边界点存在。实测 JDK 21 的分代 ZGC 能稳定在 0.5ms 以内。Shenandoah 的 STW 阶段稍多(初始标记、最终标记、初始引用更新、最终引用更新),但每个阶段都非常短,整体停顿通常在 2-5ms。

G1 停顿时间瓶颈分析

G1 的 Young GC 和 Mixed GC 都走标记-复制,分三个阶段:

  • 标记阶段:从 GC Roots 出发标记存活对象。
  • 转移阶段:把存活对象复制到新的内存地址。
  • 重定位阶段:更新所有指向旧地址的指针。

具体到混合回收,可以分成标记、清理、复制三块来看。

标记阶段

  • 初始标记:STW。从 GC Roots 出发标记直接子节点。GC Roots 数量有限,耗时非常短。
  • 并发标记:不 STW。做可达性分析找存活对象。耗时最长但跟应用线程并行。
  • 再标记:STW。修正并发阶段发生变化的对象引用。对象不多,耗时较短。

清理阶段

STW。清点出有存活对象的分区和没有存活对象的分区。不实际清理也不做复制,快。

复制阶段

STW。转移阶段分配新内存、复制对象成员变量。内存分配很快,但对象成员变量的复制耗时跟存活对象数量和对象复杂度成正比。对象越复杂,复制越久。

四个 STW 阶段里,初始标记短,再标记短,清理短,转移最长。G1 停顿时间的核心瓶颈就是转移阶段。

JDK 版本演变

不同 JDK 版本的默认 GC 一直在变,下面列出各个 LTS 版本站点的状态:

  • JDK 8(2014):默认 Parallel Scavenge + Parallel Old。可以切 CMS,G1 可用但不是默认。JDK 8 是 CMS 的鼎盛时期,不少互联网公司在那个阶段用 CMS。
  • JDK 9(2017):默认变成 G1。CMS 标废弃但仍可用。
  • JDK 11(2018 LTS):G1 继续默认。ZGC 实验特性引入(-XX:+UnlockExperimentalVMOptions -XX:+UseZGC)。
  • JDK 14(2020):CMS 正式移除。在这之前还没升级的应用被迫切走。
  • JDK 15(2020):ZGC 转正,不再需要实验参数。Shenandoah 转正。
  • JDK 17(2021 LTS):G1 默认。ZGC 和 Shenandoah 同时可用,但都是不分代版本。
  • JDK 21(2023 LTS):G1 默认。分代 ZGC 引入(-XX:+UseZGC -XX:+ZGenerational)。这是目前 GC 选择最丰富的 LTS 版本。

如果你的项目还在 JDK 8 上,建议先升到 JDK 17 或 21。JDK 8 到 21,GC 层面最大的变化就是默认从 Parallel 换成 G1,以及有了 ZGC 和 Shenandoah 这两个大堆低延迟选项。大多数 Java 应用升级 JDK 时,GC 参数只需要改一行-XX:+UseG1GC就能用上 G1。

选型思路

选哪个收集器,取决于堆大小、延迟要求和 JDK 版本。

堆不大(4G-8G),对响应时间敏感的话,G1 是最稳妥的选择。JDK 21 的 G1 已经很成熟,不需要额外调优就能跑得不错。不需要为了"试试新的"去切 ZGC。

堆很大(几十 G 到 T 级),延迟要求 ms 级以下的话,ZGC。JDK 21 的分代 ZGC 吞吐量已经够用了,不分代版本在大量对象频繁创建的场景下吞吐量会偏低,分代之后好很多。

追求吞吐量,不介意偶尔 STW 的话,Parallel。批处理和离线计算的首选。如果你在跑 Spark 或者 Flink 的任务,Parallel 通常是更好的选择——停顿时间不敏感,但每小时处理的任务量敏感。

JDK 8 升到 JDK 21 最简单的不换代码方案:从 Parallel 切到 G1,改-XX:+UseG1GC,大多数应用不需要做额外适配。如果你在 JDK 8 上用 CMS,升级时要关注 G1 有没有兼容性问题。大部分情况下 G1 能直接接手,但有些低延迟的旧系统在切到 G1 后会发现 Minor GC 的停顿变长了(因为 G1 的 Young GC 是 STW 的,而 CMS 的新生代 ParNew 也是 STW 的,实际上两边的 Young GC 都是 STW,差别主要在老年代)。

容器环境下的 GC 选型有一点要注意:如果 CPU 限制很严格(比如只分配 1-2 核),G1 的多线程优势出不来,反而 Serial 的停顿更短。ZGC 在容器里表现不错,因为它的 GC 线程数会自适应调整。

不要为了"最新的就是最好的"上 ZGC。如果堆只有 4G,G1 已经够用。ZGC 的优势在大堆上才明显,小堆上用 ZGC 反而可能因为读屏障的开销让吞吐量下降。

说到底,GC 选型不是一道选择题,是一道权衡题:延迟要多少,吞吐量能不能让,堆有多大,什么 JDK,团队对 GC 调优的维护能力如何。把这些条件列清楚,选哪个自然就出来了。