面试里 GC 这块,经常被问到两个问题:Minor GC 和 Full GC 到底有什么区别?CMS 和 G1 应该怎么选?前一个问题考的是对 GC 运行机制的理解深度,后一个问题考的是对收集器设计取舍的判断。这篇文章把这两件事串在一起讲,先理清三种 GC 的分类和触发场景,再从几个维度对比 CMS 和 G1,最后落到不同场景的选型思路上。不包括算法原理和收集器基本介绍——那些上一篇文章已经讲过了。
Minor / Major / Full GC
很多刚接触 JVM GC 的人会被这三个术语搞混,特别是 Major GC 和 Full GC,不少人把它们当同一个东西用。其实分清楚之后,排查问题会顺畅很多。
Minor GC(Young GC)
Minor GC 只回收年轻代,包括 Eden 区和两个 Survivor 区(S0 和 S1)。触发条件很简单:Eden 区满了。
大多数对象在年轻代就死了,所以 Minor GC 虽然发生频繁,每次的代价不大。停顿时间通常在几毫秒到几十毫秒,跟堆大小和存活对象数量有关。
回收过程大致是这样:Eden 区开始分配对象,慢慢填满。触发 Minor GC 后,把 Eden 和 Survivor 里的存活对象一起复制到另一个 Survivor 区。如果存活对象超过 Survivor 区的大小,多的部分直接晋升到老年代。每次从 Eden 转移过来的对象会经历一次"年龄"计数,年龄到了阈值(默认 15)就升到老年代。
在实际线上场景里,如果你发现 Minor GC 频繁(几秒一次),通常意味着 Eden 区太小,或者对象创建速率太高。调整-Xmn增大年轻代空间可以降低频率,但单次停顿时间会略微增加。
Major GC
Major 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 GC
Full 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*:file=gc.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)→并发清除(不 STW)
- G1:初始标记(STW)→ 并发标记(不 STW)→ 最终标记(STW)→筛选回收(STW + 并行复制)
注意:CMS 的清除阶段是并发的,不 STW;G1 的复制阶段是 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。
一个额外的话题:还要不要学 CMS
CMS 在 JDK 14 已经被移除了。如果你在 JDK 8 上维护老系统,可能需要了解 CMS 的调优。如果你已经在 JDK 11+,重点就是 G1 和 ZGC 了。面试里问 CMS,面试官多半是想看你对"为什么 CMS 被淘汰"的理解深度,而不是背那四个阶段。
选型场景
CMS 还在 JDK 8 上的时候怎么选,升到 JDK 17/21 之后又怎么选,是两码事。
什么时候该用 CMS(JDK 8 场景)
说"该用 CMS"已经有点过时了,因为 JDK 14 以后它不存在了。但如果你的项目还在 JDK 8 上,且满足以下条件,CMS 在当时确实是一个合理选择:
- 停顿时间敏感。CMS 的并发阶段多,在老年代回收期间应用几乎不停顿。对于响应时间要求高的在线服务,CMS 比 Parallel 更适合。
- 老年代回收是主要矛盾。Minor GC 频率不高,但 Full GC 一来就拖垮延迟。
它的缺点头上也说了:碎片问题会导致不定期 Full GC,CPU 占用高,遇到 Concurrent Mode Failure 反而更糟。所以用 CMS 一般要配合-XX:CMSInitiatingOccupancyFraction=75这样提前触发回收的参数,外加-XX:+UseCMSCompactAtFullCollection在 Full GC 时做碎片整理。
什么时候用 G1
G1 是 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 每次只能回收很少的 Region,GC 频率暴增,吞吐量下降。50ms 的目标可能反而导致 200ms 的实际表现——因为频率高了,整体停顿总和更大了。
盲目升级 JDK 后不改 GC 参数。从 JDK 8 的 Parallel 升到 JDK 21 的 G1,行为变化很大。原来没出现过的 Mixed GC 可能出现,停顿特征也不一样。上线前至少留一周的压测观察期。
只看平均延迟,不看 P99/P999。Parallel 的平均停顿可能跟 G1 差不多,但长尾停顿的差距很大。如果你在优化延迟,一定要对比 P99 以上的指标。
容器里不配置 GC 感知。在 Docker 或 Kubernetes 里跑 Java,JVM 默认拿到的 CPU 和内存信息是宿主机的,不是容器的限制值。JDK 8u131+ 支持-XX:+UseContainerSupport(默认开启),可以让 JVM 识别容器的 CPU 和内存限制。如果你的 JDK 版本不支持这个参数,手动配-XX:ActiveProcessorCount和-Xmx来匹配容器规格。
Spring Boot 应用频繁创建代理类导致元空间不足。CGLIB、Javassist 等字节码框架在应用启动或热加载时生成大量类,如果元空间太小会触发 Full GC。用-XX:MaxMetaspaceSize=256m给一个合理的上限,或者增大初始元空间减少动态扩容的触发。
总结
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设一个目标(默认 200ms),G1 的停顿预测模型会根据历史数据计算这轮选多少个 Region 能在目标时间内完成。如果设置得太小(比如 50ms),每次回收的 Region 少,GC 频率上升,吞吐量下降。
无碎片
G1 的复制算法决定了它不会产生内存碎片。Region 内部做复制整理,整体等效于标记-整理。这个特性在长时间运行的服务里很关键——CMS 跑几天就需要 Full GC 来整理碎片,G1 不需要。
对比 CMS 的改进
G1 解决的是 CMS 几个根深蒂固的问题:
- 更少的 Full GC。碎片导致 CMS 的 Full GC 几乎是必然发生的,G1 没有这个困扰。
- 停顿可预测。CMS 的停顿时间忽长忽短,G1 的预测模型至少能给出一个可接受的上限。
- 不需要配对。CMS 要配一个新生代收集器(通常是 ParNew),G1 一个人全包。
- 并行 Full GC。JDK 10 起 G1 的 Full GC 从单线程改成并行,等于又少了一个极端情况下停顿爆炸的隐患。
不是没有缺点
- 小堆上优势不明显。堆在 4G 以下时,G1 的 Region 管理和写屏障开销可能超过收益。
- 停顿预测依赖历史数据。刚启动或流量突增时,预测不稳定,实际停顿可能超出目标。
- 大对象处理。Humongous Region 不做迁移,直接整块回收,如果大对象频繁分配,老年代的回收效率会受影响。
实际调优建议
如果你决定用 G1,大部分情况下保持默认就行。JDK 21 的 G1 不需要像 CMS 那样配一堆参数。值得关注的只有几个:
-Xmx和-Xms设成一样,避免运行中动态调整堆大小。-XX:MaxGCPauseMillis=200默认值对绝大多数应用够用,别降到 50 以下。- 如果 Mixed GC 跟不上对象分配速度,可以调低
-XX:InitiatingHeapOccupancyPercent(默认 45),让并发标记提前触发。 - GC 日志一定要开,线上出了问题没有日志就等于没有证据。
总的来说,G1 是 JDK 9 到 JDK 21 之间最通用的选择。不是因为它没有短板,而是因为它没有大的硬伤。