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

发布时间:2026/7/7 18:05:42
九个 JVM 垃圾回收器,一篇从头捋到尾 面试里聊到 JVM垃圾回收器几乎是必问的。Serial、Parallel、CMS、G1、ZGC 这些名字你大概都听过但每个出现在什么版本、解决什么问题、最后怎么退出的串起来讲清楚的人不多。这篇文章把九个收集器从头捋一遍顺便分析各阶段的 STW 停顿。不包括算法原理那些够再写一篇。垃圾回收器详解先说说 G1 出来之前那些分代的收集器。它们按新生代和老年代分开工作互相配对。配对的模式通常是这样新生代一个收集器老年代一个收集器JDK 8 之前没 G1 的时候大家都这么搭着用。Serial 收集器最古老的一个。复制算法新生代单线程标记、清理、复制都在一个线程里跑完。优点是实现简单没有线程切换的开销在单核环境或者客户端模式下效率反而高。现在的服务器基本都是多核Serial 用得少了但如果你在 Docker 里只分配了一个 CPUSerial 反而是最稳的选择。启动参数-XX:UseSerialGC。跟其他收集器配对的组合是 Serial Serial Old或者 Serial CMSCMS 当老年代Serial 管新生代前提是 JDK 版本还支持 CMS。不过在单核机器的限定场景下Serial 的停顿反而可能比并行收集器更短——因为少了两三倍的线程上下文切换。ParNew 收集器复制算法新生代并行。本质上是 Serial 的多线程版本但它存在的价值几乎完全系在 CMS 身上CMS 只能配 ParNew 做新生代回收。JDK 9JEP 291标了废弃JDK 14JEP 363随 CMS 一起从 HotSpot 正式移除。JDK 14 以后 ParNew 就不存在了面试里知道它跟 CMS 的绑定关系就行。Parallel Scavenge 收集器复制算法新生代并行。跟 ParNew 的区别是关注点不同ParNew 活着是为了配合 CMSParallel 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 RegionEden 全部清空。这个过程 STW但因为 Region 小停顿可控。Mixed GC 是 G1 的特色。当老年代的堆占用比例超过-XX:InitiatingHeapOccupancyPercent默认 45%G1 会启动并发标记周期。标记完成后不是一次性全收而是分几次 Mixed GC每次选一部分老年代 Region 加进 Collection Set跟新生代一起回收。这样可以打散停顿时间。并发标记周期本身也有几个阶段初始标记STW很短随 Young GC 一起做、并发标记不 STW、再标记STW用 SATB 算法处理并发期间变化的对象、清理STW统计每个 Region 的存活对象和回收收益。这里用到 SATBSnapshot-At-The-Beginning算法在初始标记时拍一张堆快照并发标记期间只记录快照之后的变化不重复扫描已经标记过的对象。G1 还有一个重要的数据结构叫 Remembered SetRSet。每个 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 21G1 已经非常成熟默认配置下大多数应用不需要额外调优。ZGC 收集器JDK 11 实验特性引入JDK 15 正式可用JDK 21 引入分代 ZGCJEP 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 82014默认 Parallel Scavenge Parallel Old。可以切 CMSG1 可用但不是默认。JDK 8 是 CMS 的鼎盛时期不少互联网公司在那个阶段用 CMS。JDK 92017默认变成 G1。CMS 标废弃但仍可用。JDK 112018 LTSG1 继续默认。ZGC 实验特性引入-XX:UnlockExperimentalVMOptions -XX:UseZGC。JDK 142020CMS 正式移除。在这之前还没升级的应用被迫切走。JDK 152020ZGC 转正不再需要实验参数。Shenandoah 转正。JDK 172021 LTSG1 默认。ZGC 和 Shenandoah 同时可用但都是不分代版本。JDK 212023 LTSG1 默认。分代 ZGC 引入-XX:UseZGC -XX:ZGenerational。这是目前 GC 选择最丰富的 LTS 版本。如果你的项目还在 JDK 8 上建议先升到 JDK 17 或 21。JDK 8 到 21GC 层面最大的变化就是默认从 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。如果堆只有 4GG1 已经够用。ZGC 的优势在大堆上才明显小堆上用 ZGC 反而可能因为读屏障的开销让吞吐量下降。说到底GC 选型不是一道选择题是一道权衡题延迟要多少吞吐量能不能让堆有多大什么 JDK团队对 GC 调优的维护能力如何。把这些条件列清楚选哪个自然就出来了。