GC 运行机制与对比选型:一篇讲清楚 Minor / Major / Full GC 和 CMS vs G1

📅 2026/7/7 18:04:02
GC 运行机制与对比选型:一篇讲清楚 Minor / Major / Full GC 和 CMS vs G1
面试里 GC 这块经常被问到两个问题Minor GC 和 Full GC 到底有什么区别CMS 和 G1 应该怎么选前一个问题考的是对 GC 运行机制的理解深度后一个问题考的是对收集器设计取舍的判断。这篇文章把这两件事串在一起讲先理清三种 GC 的分类和触发场景再从几个维度对比 CMS 和 G1最后落到不同场景的选型思路上。不包括算法原理和收集器基本介绍——那些上一篇文章已经讲过了。Minor / Major / Full GC很多刚接触 JVM GC 的人会被这三个术语搞混特别是 Major GC 和 Full GC不少人把它们当同一个东西用。其实分清楚之后排查问题会顺畅很多。Minor GCYoung GCMinor GC 只回收年轻代包括 Eden 区和两个 Survivor 区S0 和 S1。触发条件很简单Eden 区满了。大多数对象在年轻代就死了所以 Minor GC 虽然发生频繁每次的代价不大。停顿时间通常在几毫秒到几十毫秒跟堆大小和存活对象数量有关。回收过程大致是这样Eden 区开始分配对象慢慢填满。触发 Minor GC 后把 Eden 和 Survivor 里的存活对象一起复制到另一个 Survivor 区。如果存活对象超过 Survivor 区的大小多的部分直接晋升到老年代。每次从 Eden 转移过来的对象会经历一次年龄计数年龄到了阈值默认 15就升到老年代。在实际线上场景里如果你发现 Minor GC 频繁几秒一次通常意味着 Eden 区太小或者对象创建速率太高。调整-Xmn增大年轻代空间可以降低频率但单次停顿时间会略微增加。Major GCMajor GC 这个词在不同资料里的定义不太一样。严格来说它指仅回收老年代的 GC。比如 CMS 做一次老年代并发回收那就是 Major GC。G1 的 Mixed GC 在某些分类里也被算作 Major GC因为它既收了新生代也收了部分老年代 Region。触发条件通常是老年代空间不足或者 GC 统计模型发现对象晋升速度太快不回收老年代的话很快就要出问题。Major GC 发生的频率比 Minor GC 低得多但每次的耗时会更长——老年代里的对象存活率高标记和清理的工作量自然更大。不过要注意市面上很多监控工具把非 Minor GC的 GC 事件统称 Major GC甚至直接标成 Full GC。所以看监控数据时最好结合 GC 日志确认具体触发了哪个收集器而不是只看事件名称。Full GCFull GC 是整个 GC 体系里代价最大的操作。它回收年轻代 老年代并且通常伴随方法区/元空间的类卸载。这里有一个容易误解的点元空间Metaspace用的是本地内存不在堆里。不能说Full GC 会回收元空间更准确的说法是Full GC 期间 JVM 可能对元空间中无用的类元数据进行卸载。触发 Full GC 的场景有几种显式调用。System.gc()或者Runtime.getRuntime().gc()。虽然 JVM 不保证立即执行但默认情况下这个调用会触发一次 Full GC。生产代码里别写这个除非你有特殊理由。如果真的遇到第三方库调了System.gc()可以用-XX:DisableExplicitGC禁掉。空间分配担保失败。Minor GC 之前JVM 会先检查老年代的连续空间够不够放这次可能晋升的对象。判断依据是老年代最大连续空间 新生代所有对象大小或者 历次晋升的平均大小如果都不满足JVM 觉得有风险就直接触发 Full GC。Minor GC 之后存活对象放不进老年代同样触发 Full GC。元空间/永久代不足。Java 8 以后是元空间之前是永久代。当元空间满了JVM 会在抛 OOM 之前先尝试一次 Full GC看能不能通过类卸载腾出空间。动态生成大量类的应用比如 CGLIB 代理、Groovy 脚本容易遇到这个情况。CMS 的 Concurrent Mode Failure。CMS 在并发标记/清理时用户线程和新对象还在跑。如果老年代填满的速度超过了 CMS 回收的速度CMS 就会放弃当前的并发回收回退到 Serial Old 做一次全量 STW 回收。这是 CMS 最怕的场景停顿时间会从几十毫秒跳到几秒甚至十几秒。晋升失败Promotion Failed。CMS 执行并发标记时新生代的对象升上来了但老年代没有足够的连续空间容纳它们。这也触发 Full GC。在 CMS 调优时-XX:CMSInitiatingOccupancyFraction就是用来控制早点开始回收别等到实在没空间了才做的。Full GC 的特点是STW全堆扫描。一次 Full GC 的耗时从几百毫秒到几十秒都有可能取决于堆大小和存活对象量。线上应该尽量监控 Full GC 的次数和耗时如果频繁触发说明要么内存配置不合理要么有内存泄漏。怎么从 GC 日志里看出来只看监控面板不够很多时候需要翻 GC 日志才能定位问题。JDK 8 到 JDK 21 的 GC 日志参数有变化但打开方式差不多# JDK 8 -XX:PrintGCDetails -XX:PrintGCDateStamps -Xloggc:gc.log # JDK 11 -Xlog:gc*:filegc.log:time,uptime,level,tags日志里的关键信息看停顿类型Young GC vs Full GC、停顿耗时、各代空间的变化。如果日志里 Full GC 频繁出现先看触发原因——是System.gc()调用还是空间分配担保失败还是 Concurrent Mode Failure。原因不同解法完全不同。-XX:DisableExplicitGC可以屏蔽显式调用但如果是空间不足导致的 Full GC屏蔽掉只是让问题晚一点暴露不是解决。Full GC 频率异常的排查思路遇到 Full GC 频繁先做三件事看堆占用趋势。用jstat -gccause或者 GC 日志里的堆占用百分比看老年代是不是一直在涨、降不下来。如果是大概率是内存泄漏。看触发原因。GC 日志里会写触发 Full GC 的原因——System.gc()、Allocation Failure、Metadata GC Threshold。不同原因指向不同问题。看每次回收的效果。Full GC 之后老年代占用有没有明显下降如果没有说明对象真的活着需要调大堆或排查代码里的对象缓存。这几个步骤走下来大部分 Full GC 异常都能找到根因。CMS vs G1 对比CMS 和 G1 是面试里被问得最多的两个收集器也是从 JDK 8 升到 JDK 17/21 路上绕不开的话题。下面从几个维度拆开对比。使用范围CMS 只管老年代。新生代要靠别人——通常是 ParNew老系统里也能配 Serial。G1 一个人全包新生代老年代都管不需要搭配另一个收集器。这是两者最根本的设计差异。停顿时间CMS 的目标是尽量短的停顿。初始标记和重新标记 STW但并发标记和并发清除不 STW所以整体停顿时间短。问题是不稳定遇到 Concurrent Mode Failure 就崩了。G1 的目标是可预测的停顿。通过 Region 分片和停顿预测模型把单次停顿控制在-XX:MaxGCPauseMillis设定的目标以内默认 200ms。代价是转移阶段全程 STW但停顿时间是确定的。内存碎片CMS 用标记-清除算法不整理内存。老年代跑久了碎片越来越多大对象分配时需要连续空间找不到就触发 Full GC。G1 从两个 Region 之间看是复制算法整体效果等效于标记-整理不会产生碎片。Humongous Region 专门放大对象但如果没有足够的连续 Region 放下大对象还是可能触发 Full GC。回收过程两个收集器的阶段划分有结构上的相似性但第四阶段完全不同CMS初始标记STW→ 并发标记不 STW→ 重新标记STW→并发清除不 STWG1初始标记STW→ 并发标记不 STW→ 最终标记STW→筛选回收STW 并行复制注意CMS 的清除阶段是并发的不 STWG1 的复制阶段是 STW 的。CMS 选择并发清除来缩短停顿代价是碎片和浮动垃圾。G1 选择 STW 复制来换取确定性和无碎片。浮动垃圾CMS 并发清除时用户线程还在跑新产生的垃圾叫浮动垃圾这轮清不掉留到下一轮。预留的空间不够就会 Concurrent Mode Failure降级到 Serial Old。G1 同样有浮动垃圾——并发标记和并发清理阶段用户线程也在产生新对象。但因为 G1 用 Region 做增量回收浮动垃圾的影响范围有限整体比 CMS 可控。G1 的极端情况不叫 Concurrent Mode Failure叫 Evacuation Failure或 To-space Exhausted发生在复制存活对象时目标 Region 空间不足也会回退到 Full GC。一个额外的话题还要不要学 CMSCMS 在 JDK 14 已经被移除了。如果你在 JDK 8 上维护老系统可能需要了解 CMS 的调优。如果你已经在 JDK 11重点就是 G1 和 ZGC 了。面试里问 CMS面试官多半是想看你对为什么 CMS 被淘汰的理解深度而不是背那四个阶段。选型场景CMS 还在 JDK 8 上的时候怎么选升到 JDK 17/21 之后又怎么选是两码事。什么时候该用 CMSJDK 8 场景说该用 CMS已经有点过时了因为 JDK 14 以后它不存在了。但如果你的项目还在 JDK 8 上且满足以下条件CMS 在当时确实是一个合理选择停顿时间敏感。CMS 的并发阶段多在老年代回收期间应用几乎不停顿。对于响应时间要求高的在线服务CMS 比 Parallel 更适合。老年代回收是主要矛盾。Minor GC 频率不高但 Full GC 一来就拖垮延迟。它的缺点头上也说了碎片问题会导致不定期 Full GCCPU 占用高遇到 Concurrent Mode Failure 反而更糟。所以用 CMS 一般要配合-XX:CMSInitiatingOccupancyFraction75这样提前触发回收的参数外加-XX:UseCMSCompactAtFullCollection在 Full GC 时做碎片整理。什么时候用 G1G1 是 JDK 9 起的默认收集器适用场景比 CMS 宽得多大堆内存。4G 以上的堆G1 的 Region 分区策略能有效控制单次停顿。堆越大G1 相对于 Parallel 的优势越明显。对内存碎片敏感。CMS 的碎片问题在 G1 里不存在。需要平衡的 GC 表现。G1 在吞吐量和延迟之间找了一个中间位置不是哪项最好但各项都不差。不确定的 JDK 升级路径。如果项目未来要升到 JDK 17/21直接用 G1 可以省掉一次调优周期。G1 在堆较小4G 以下且存活率很高的时候优势不明显。这时候 Parallel 或 Serial 反而可能更合适。什么时候用 Parallel虽然这篇文章主要对比 CMS 和 G1但 Parallel 在实际线上的占比非常高所有 JDK 8 应用在不改配置的情况下都是 Parallel。它适合批处理、离线计算。Spark、Flink 任务、定时报表——这类场景对单次停顿不敏感在乎的是每小时处理了多少数据。堆大小中等存活率低。Parallel 的吞吐量优势在对象朝生夕死的场景下最明显。升到 JDK 21 之后怎么选JDK 21 提供了最丰富的 GC 选择G1默认、分代 ZGC、Parallel、Serial、Shenandoah。选哪个不再取决于X 收集器有哪些缺点而是你的业务要什么延迟优先就 ZGC吞吐量优先就 Parallel不想折腾就 G1。CMS 已经不在选项里了。常见的 GC 调优误区调优 GC 最常犯的错误是看了篇最佳实践就改参数改完不看效果。下面是几个容易踩的坑堆设得太大不是好事。堆越大单次 GC 停顿越长。不是所有场景都需要大堆。如果应用的内存需求就在 4G 左右给了 16G 反而让 Full GC 的代价变高。MaxGCPauseMillis 设得越小越好不是。设 50ms 意味着 G1 每次只能回收很少的 RegionGC 频率暴增吞吐量下降。50ms 的目标可能反而导致 200ms 的实际表现——因为频率高了整体停顿总和更大了。盲目升级 JDK 后不改 GC 参数。从 JDK 8 的 Parallel 升到 JDK 21 的 G1行为变化很大。原来没出现过的 Mixed GC 可能出现停顿特征也不一样。上线前至少留一周的压测观察期。只看平均延迟不看 P99/P999。Parallel 的平均停顿可能跟 G1 差不多但长尾停顿的差距很大。如果你在优化延迟一定要对比 P99 以上的指标。容器里不配置 GC 感知。在 Docker 或 Kubernetes 里跑 JavaJVM 默认拿到的 CPU 和内存信息是宿主机的不是容器的限制值。JDK 8u131 支持-XX:UseContainerSupport默认开启可以让 JVM 识别容器的 CPU 和内存限制。如果你的 JDK 版本不支持这个参数手动配-XX:ActiveProcessorCount和-Xmx来匹配容器规格。Spring Boot 应用频繁创建代理类导致元空间不足。CGLIB、Javassist 等字节码框架在应用启动或热加载时生成大量类如果元空间太小会触发 Full GC。用-XX:MaxMetaspaceSize256m给一个合理的上限或者增大初始元空间减少动态扩容的触发。总结GC 运行机制的分类Minor / Major / Full GC是排查问题的基础CMS 和 G1 的对比是选型的核心。记住几件事分清 Full GC 和 Major GC 不是抠字眼是为了定位时不被监控数据误导CMS 已经是过去时但理解它为什么被淘汰能帮你更好地理解 G1 的设计取舍选型没有银弹堆大小、延迟要求、JDK 版本三个条件列好选项自然就出来了。JDK 的 GC 还在继续演进。分代 ZGC 在 JDK 21 刚落地未来的版本可能默认 GC 会从 G1 变成 ZGC。如果那时候你的应用还在跑 G1不用急着换——除非你的堆已经大到 G1 的停顿扛不住了。GC 选型的上限不是谁的指标最强而是你的业务能接受的代价是多少。G1 特色G1 在 JDK 9 被设为默认收集器不是因为它每项指标都最强而是因为它把之前的收集器踩过的坑基本都填上了。分区设计G1 最大的特色是分区。以前的收集器把堆划成连续的年轻代和老年代G1 把堆切成 2048 个 Region 然后动态分配角色。这块 Region 这轮是 Eden下轮可能就是 Survivor或者变成老年代在跑。这意味着不再需要固定的分代大小比例。G1 可以根据运行时数据动态调整 Eden 和 Survivor 的数量逼近停顿时间目标。增量回收G1 不是全堆一起扫而是每次挑收益最高的 Region 来回收。Garbage First 这个名字就是这么来的先处理垃圾最多的 Region赚回的空间最大、花的时间最短。这个机制让 G1 的停顿时间变得可预测。通过-XX:MaxGCPauseMillis设一个目标默认 200msG1 的停顿预测模型会根据历史数据计算这轮选多少个 Region 能在目标时间内完成。如果设置得太小比如 50ms每次回收的 Region 少GC 频率上升吞吐量下降。无碎片G1 的复制算法决定了它不会产生内存碎片。Region 内部做复制整理整体等效于标记-整理。这个特性在长时间运行的服务里很关键——CMS 跑几天就需要 Full GC 来整理碎片G1 不需要。对比 CMS 的改进G1 解决的是 CMS 几个根深蒂固的问题更少的 Full GC。碎片导致 CMS 的 Full GC 几乎是必然发生的G1 没有这个困扰。停顿可预测。CMS 的停顿时间忽长忽短G1 的预测模型至少能给出一个可接受的上限。不需要配对。CMS 要配一个新生代收集器通常是 ParNewG1 一个人全包。并行 Full GC。JDK 10 起 G1 的 Full GC 从单线程改成并行等于又少了一个极端情况下停顿爆炸的隐患。不是没有缺点小堆上优势不明显。堆在 4G 以下时G1 的 Region 管理和写屏障开销可能超过收益。停顿预测依赖历史数据。刚启动或流量突增时预测不稳定实际停顿可能超出目标。大对象处理。Humongous Region 不做迁移直接整块回收如果大对象频繁分配老年代的回收效率会受影响。实际调优建议如果你决定用 G1大部分情况下保持默认就行。JDK 21 的 G1 不需要像 CMS 那样配一堆参数。值得关注的只有几个-Xmx和-Xms设成一样避免运行中动态调整堆大小。-XX:MaxGCPauseMillis200默认值对绝大多数应用够用别降到 50 以下。如果 Mixed GC 跟不上对象分配速度可以调低-XX:InitiatingHeapOccupancyPercent默认 45让并发标记提前触发。GC 日志一定要开线上出了问题没有日志就等于没有证据。总的来说G1 是 JDK 9 到 JDK 21 之间最通用的选择。不是因为它没有短板而是因为它没有大的硬伤。