Minor GC、Major GC 与 Full GC:触发条件、日志识别和优化方向(062)

JVM原理与调优约 15 分钟阅读8,131 字符
知识库文档 63 / 251

一、为什么这三个名字总让人搞混

几乎每个 Java 工程师都听过"Minor GC""Major GC""Full GC"这三个词,但很少有人能准确说清楚它们之间的边界。原因很简单:这三个名字不是 JVM 规范定义的术语,而是社区在描述不同收集器行为时约定俗成的说法,而不同的收集器实现,对"Major GC"到底指什么,答案并不一致。

  • 在 Serial/Parallel 收集器的语境里,"Major GC"通常等价于"对老年代的收集",而这几乎总是伴随一次 Full GC(因为它们的老年代收集器本身就是整堆收集)。
  • 在 CMS 里,"Major GC"特指针对老年代的并发收集周期,它不一定是 Full GC——只有当 CMS 并发失败(Concurrent Mode Failure)退化时,才会触发真正的 Full GC。
  • 在 G1 里,官方文档几乎不使用"Major GC"这个词,而是用"Mixed GC"描述同时回收年轻代和部分老年代 Region 的收集,"Full GC"则专指 G1 收集失败后退化的单线程整堆收集。
  • ZGC、Shenandoah 干脆没有"Major GC"的概念,它们的每一次 GC 周期都是面向整堆的并发收集,用"Minor/Major/Full"来描述反而会造成误解。

所以本文的核心目标,不是给你一套放之四海皆准的定义,而是帮你建立一个可以套用到任意收集器的分析框架:GC 发生在哪个分代、由什么条件触发、收集器怎么执行、日志里长什么样、以及如何针对性优化。这比死记硬背术语定义更有价值——面试官问"Major GC 和 Full GC 有什么区别"时,真正想考察的是你是否理解分代收集的触发链路,而不是术语本身。

二、内存分代回顾:GC 发生的物理舞台

要理解触发条件,先要清楚 GC 发生在哪。主流分代收集器(Serial/Parallel/CMS/G1 的默认模式)把堆分为:

  • Eden 区:新对象的默认分配位置。
  • Survivor 区(S0、S1):两块大小相等的区域,采用"复制算法",任意时刻只有一块在使用。
  • 老年代(Old/Tenured):存放存活时间较长或体积较大的对象。
  • 元空间(Metaspace):JDK 8 之后类元数据的存储区,属于本地内存,不在堆内,但会参与 Full GC 的触发判断。

文章配图
文章配图

年轻代和老年代的设计假设是"弱分代假设"(Weak Generational Hypothesis):绝大多数对象都是短命的,只有少数对象会存活很久。这个假设决定了两代使用完全不同的收集策略——年轻代高频、轻量地收集,老年代低频、彻底地收集。这也是"Minor GC"和"Major/Full GC"在成本和频率上天差地别的根本原因。

三、Minor GC:年轻代的高频清扫

触发条件:Eden 区空间不足以分配新对象时,JVM 会先尝试触发一次 Minor GC(也叫 Young GC),而不是立即扩容或报错。这是一个同步、阻塞式的触发——分配失败直接引发收集,不需要等待任何后台线程判断。

执行过程(以复制算法为例):

  1. 遍历 Eden 区和当前使用中的 Survivor 区,标记所有存活对象(通过可达性分析,从 GC Roots 出发)。
  2. 将存活对象复制到另一块空闲的 Survivor 区。
  3. 复制过程中,每个对象的年龄计数器加 1(对象头 Mark Word 中记录,最大 15,对应 4 bit)。
  4. 清空 Eden 区和刚才使用的那块 Survivor 区,交换两块 Survivor 区的角色。

文章配图
文章配图

Minor GC 之所以"快",核心原因有两个:一是 GC Roots 到年轻代的引用链通常很短,标记开销小;二是复制算法只处理存活对象,天然不产生内存碎片,分配效率高。但也正因为它触发频繁(高并发场景下可能秒级触发),STW(Stop-The-World)时间虽短,累积起来也会影响吞吐量,这是调优的重点区域之一。

四、对象晋升老年代的四条路径

对象不会无限留在年轻代,满足以下任一条件就会晋升(Promotion)到老年代:

  1. 年龄计数器达到阈值:默认阈值由 -XX:MaxTenuringThreshold 控制(默认 15,G1 下常见默认更小)。对象每熬过一次 Minor GC,年龄 +1,达到阈值直接晋升。
  2. 动态年龄判定(Dynamic Age Determination):如果 Survivor 区中,相同年龄的对象大小之和超过了 Survivor 区容量的一半,那么大于或等于该年龄的对象会直接晋升,不需要等到达到 MaxTenuringThreshold。这是一个很多人忽略的细节——晋升年龄是动态计算的,不是死板地等于配置值。
  3. 大对象直接进入老年代:超过 -XX:PretenureSizeThreshold 的对象会跳过年轻代直接分配在老年代(Serial/Parallel 生效,G1 通过 Humongous Object 机制单独处理超过 Region 一半大小的对象)。
  4. Survivor 空间不足时的分配担保(Handle Promotion):如果 Minor GC 后存活对象太多,Survivor 区放不下,多余的对象会直接晋升老年代;如果老年代剩余空间也不足以担保这批对象,则会提前触发一次 Full GC。

文章配图
文章配图

这四条路径里,第 4 条是理解"为什么年轻代问题会引发老年代 GC"的关键——它说明 Minor GC 和 Full GC 之间并不是完全独立的两件事,年轻代的对象存活率飙升,会直接把压力传导到老年代。

五、Major GC:一个被滥用的术语

如第一节所说,"Major GC"没有统一定义,这里给出一个更精确的说法:Major GC 一般指"针对老年代的收集动作",但它是否等价于 Full GC(整堆收集,通常 STW 且暂停时间长),取决于具体的收集器实现:

收集器 老年代收集器 Major GC 是否等价 Full GC 说明
Serial Serial Old(标记-整理) 是 单线程整堆收集,无并发能力
Parallel Parallel Old(标记-整理) 是 多线程但仍是整堆 STW
CMS CMS(标记-清除,并发) 通常不是 并发收集老年代,仅在退化时才是 Full GC
G1 无独立老年代收集器,用 Mixed GC 概念不适用 正常情况用 Mixed GC 渐进式回收老年代 Region,Full GC 是失败退化路径
ZGC / Shenandoah 无分代(或 Generational ZGC 有年轻代但整体并发) 概念不适用 每次 GC 周期都是面向整堆的并发收集,没有传统意义的 Major GC

这张表格是本文最值得记住的一张——面试中被问到"Major GC 和 Full GC 区别",最好的回答不是背定义,而是直接说:"这个术语在不同收集器里含义不同,我可以分别说明",然后按表格逐一展开。这既展示了准确的知识,又避免了被术语本身带偏。

六、Full GC 的触发条件:五种典型场景

相比 Minor GC 的单一触发条件,Full GC 的触发场景要复杂得多,因为它往往是"其他手段都不够用"之后的兜底动作:

  1. 老年代空间不足:Minor GC 后对象晋升,或大对象直接分配,导致老年代无法容纳新对象。
  2. 元空间(Metaspace)不足:类加载过多(典型场景:大量动态代理、频繁热部署、OSGi 类加载器泄漏)导致 Metaspace 触及 -XX:MaxMetaspaceSize 上限。
  3. **System.gc()**或**Runtime.getRuntime().gc()**被显式调用:默认行为下这是一个"建议性"的 Full GC 请求(Serial/Parallel/CMS 会直接执行;G1 下可以用 -XX:+DisableExplicitGC 屏蔽或用 G1PeriodicGCInterval 改为并发式)。
  4. CMS 的 Concurrent Mode Failure:CMS 并发标记/清理过程中,老年代空间被新晋升对象耗尽,并发收集来不及完成,退化为单线程 Full GC——这是 CMS 场景下最典型、也最影响业务的 Full GC 诱因。
  5. G1 的 Evacuation Failure / 分配担保失败:Mixed GC 过程中,如果没有足够空间容纳复制的对象(Region 不够用),G1 会放弃当前的并发/增量收集,退化为一次单线程的 Full GC,这也是 G1 用户最不想看到的场景。

文章配图
文章配图

第 4、5 两种"退化型"Full GC 是生产环境最需要警惕的——它们意味着"本来设计为并发、低停顿的收集器,因为空间规划不足而被迫走了最差路径"。绝大多数线上 Full GC 故障排查,最终都会定位到这两类原因之一。

七、GC 日志实战:一眼识别 Minor GC 与 Full GC

JDK 9 之后统一日志框架(-Xlog:gc*)取代了老式的 -XX:+PrintGCDetails,格式更规整,但仍需要练习才能一眼分辨。以 G1 为例,典型日志片段:

code
[12.345s][info][gc] GC(42) Pause Young (Normal) (G1 Evacuation Pause) 512M->128M(1024M) 15.234ms
[45.678s][info][gc] GC(58) Pause Young (Concurrent Start) (G1 Humongous Allocation) 800M->300M(1024M) 25.100ms
[89.012s][info][gc] GC(59) Pause Full (G1 Evacuation Pause) 950M->400M(1024M) 1523.456ms

识别要点:

  • **Pause Young (Normal)**:普通的年轻代收集(对应传统的 Minor GC)。
  • **Pause Young (Concurrent Start)**:这次年轻代收集同时触发了并发标记周期的开始——不是 Full GC,只是"顺便"启动了下一轮标记。
  • **Pause Full**:这才是真正的 Full GC,注意暂停时间的量级差异——前两条是十几到二十几毫秒,第三条是 1.5 秒,差了近百倍,这就是 Full GC 对业务的真实杀伤力。
  • 堆大小变化 950M->400M(1024M):GC 前占用 → GC 后占用(总容量)。如果 Full GC 后占用量依然很高(比如 GC 前后几乎没变化),说明存在内存泄漏,而不是单纯的容量不足。

文章配图
文章配图

八、优化方向:从"减少晋升"到"规避退化"

减少 Minor GC 频率与晋升压力:

  • 适当增大年轻代(-Xmn 或 -XX:NewRatio),让短命对象有更充分的空间在 Eden/Survivor 中死亡,减少晋升到老年代的对象数量。
  • 检查是否存在"朝生夕死但恰好跨越了几次 GC"的对象(比如批处理中攒批的大 List),这类对象最容易触发动态年龄判定而被批量误判为"存活时间长",提前晋升。
  • 避免不必要的大对象分配(比如一次性读取大文件到内存),减少直接进入老年代或触发 Humongous Object 分配的情况。

规避 Full GC,尤其是退化型 Full GC:

  • CMS/G1 场景下,给老年代留出足够的"安全余量",避免晋升速度追上并发收集速度——具体做法是提前触发并发周期(G1 的 -XX:InitiatingHeapOccupancyPercent,默认 45%,如果晋升速度快可以适当调低)。
  • 定期审查 Metaspace 使用趋势,特别是使用了大量动态代理框架(Spring AOP、CGLIB、字节码增强类中间件)的服务,避免类爆炸式增长。
  • 代码层面禁止业务代码直接调用 System.gc();确实需要主动触发整理时,评估是否可以用 G1 的并发式 G1PeriodicGCInterval 代替。
  • 拿到日志后先看"GC 前后占用差异"再看"频率":占用差异小、频率高,是分配速率问题(调优年轻代);占用差异大、频率低但每次都很痛,往往是对象生命周期设计问题(调优晋升策略)。

文章配图
文章配图

九、常见面试陷阱

结合前面几节,这里列出几个最容易踩坑的说法,遇到时可以直接纠正:

  • "Major GC 就是 Full GC" —— 不准确,取决于收集器;CMS 和 G1 下二者不等价。
  • "G1 没有 Full GC" —— 错误,G1 有 Full GC,只是它是失败退化路径,不是正常回收手段,出现即代表调优或容量规划有问题。
  • "调大堆内存就能减少 Full GC" —— 只是延缓,如果根因是内存泄漏或对象生命周期设计不合理,更大的堆只会让每次 Full GC 的暂停时间更长。
  • "Minor GC 不会影响老年代" —— 不对,对象晋升是从年轻代到老年代的常态路径,年轻代配置不当会直接推高老年代压力,进而诱发 Full GC。

理解了这套触发条件和日志识别方法之后,再去看 G1、ZGC、Shenandoah 等具体收集器的实现细节(本系列前几篇已经覆盖),就会发现它们本质上都是在这套"分代收集 + 触发条件 + 退化路径"的框架上做取舍优化,而不是全新发明了一套体系。

延伸阅读

  • 《深入理解 Java 虚拟机》(周志明)第 3 章,垃圾收集器与内存分配策略
  • OpenJDK 官方文档:-Xlog:gc* 统一日志框架使用指南
  • 「04 JVM 原理与调优」系列相关篇目:058(垃圾回收基础算法)、059(Serial/Parallel/CMS)、060(G1)、061(ZGC 与 Shenandoah)

十、面试官视角问答

以下问题不是对文档内容的复述,而是从面试官追问的角度出发,考察你是否真正理解触发条件背后的设计取舍。阅读时间不计入前文的 40-50 分钟。

Q1:G1 的 Full GC 为什么是单线程的?这明显是个性能倒退的设计,为什么不直接把 Parallel Old 拿过来用?

这是个容易被表面回答带偏的问题。G1 的 Full GC 单线程设计不是"忘了优化",而是一个故意的降级信号:G1 的整套架构(Region 化、Remembered Set、SATB)都是围绕"增量式、可预测停顿"设计的,一旦真的走到需要整堆压缩的地步,说明前面的 Young GC/Mixed GC 都已经无法维持增量收集的假设(比如空间高度碎片化、晋升速度远超回收速度)。这时候用单线程 Serial 式的标记-整理,虽然慢,但逻辑最简单、最不容易出错,本质上是"用最保守的方式止血",而不是"性能最优的方式止血"。JDK 10 之后引入了 Parallel Full GC(多线程),大幅降低了这个惨案的严重程度,但即便是并行版本,它依然是整堆 STW,这个本质没有变——它仍然是失败退化路径,出现频率本身就是一个需要报警和排查的信号,而不是靠"让它变快"去掩盖容量规划的问题。

Q2:动态年龄判定的具体规则你能说清楚吗?如果 Survivor 区设置得特别小,会发生什么?

动态年龄判定的规则是:JVM 按年龄从小到大遍历 Survivor 区中的对象,累加每个年龄段对象的总大小,一旦累加值超过 Survivor 区容量的一半,就把当前遍历到的这个年龄记为新的晋升阈值,所有达到或超过这个年龄的对象在这次 GC 就直接晋升——即便这个年龄远小于 MaxTenuringThreshold。如果 Survivor 区设置得特别小(比如 -XX:SurvivorRatio 设得很大,Survivor 占比过低),会出现两个连锁问题:一是刚经历过一次 Minor GC、年龄还很小的对象就可能因为总大小超标被提前判定晋升,导致本该在年轻代死亡的对象过早进入老年代,直接推高老年代压力和 Full GC 频率;二是 Survivor 空间不足还会触发"分配担保"式的直接晋升(本文第四节的路径④),进一步加剧这个问题。这也是为什么很多人调优时会发现"单纯调大年轻代总大小没用,还要看 Survivor 比例"——这两个参数要一起看,不能孤立调整。

Q3:CMS 的 Concurrent Mode Failure 和 G1 的 Evacuation Failure,本质上是不是同一类问题?

是的,本质相同:并发/增量收集器的核心假设是"回收速度快于对象晋升速度",一旦这个假设被打破,收集器没有退路,只能退化成整堆 STW。CMS 是"老年代空间被晋升对象耗尽,并发清理没清完新对象就满了";G1 是"Mixed GC 阶段没有足够的空闲 Region 承接复制的存活对象"。两者的诱因也高度相似:短时间内对象晋升速率陡增(比如突发大流量导致大量对象存活时间被拉长)、并发收集线程数或触发时机配置不当(比如 CMS 没设置合适的 CMSInitiatingOccupancyFraction,G1 的 InitiatingHeapOccupancyPercent 触发得太晚)。所以两者的应对策略也相通:提前触发并发收集周期,给"回收速度追赶晋升速度"留出更多时间窗口,而不是等空间几乎耗尽才开始并发标记。理解了这一层,你会发现不同收集器的"退化型 Full GC"其实是同一个设计矛盾的不同表现形式,而不是孤立的知识点。

Q4:一个线上服务,Full GC 频率从原来的每天 1 次突然变成每小时 1 次,堆内存配置完全没有改动,你会怎么排查?

我会按照"先分类,再定位"的顺序推进,而不是一上来就去调参数。第一步,看 GC 日志判断这是"容量型"还是"泄漏型"——重点看 Full GC 前后堆占用的下降幅度,如果每次都能回落到接近正常水位,是容量/晋升速率问题;如果占用持续走高、Full GC 后依然居高不下,几乎可以确定是内存泄漏或者对象生命周期被意外拉长。第二步,看时间线上有没有相关变更——这类"突然变化"背后几乎总有一个触发点:是否上线了新功能引入了大对象缓存、是否有依赖库升级改变了默认的连接池/线程池大小、是否流量或数据分布发生了变化(比如某个批处理任务的数据量陡增)。第三步,如果怀疑泄漏,直接上 heap dump,用 MAT 或类似工具看 Dominator Tree,找到占用异常的对象和它们的 GC Roots 引用链,而不是靠猜。第四步,如果确认是容量问题而非代码问题,再回头调整年轻代大小、Survivor 比例或并发收集触发阈值。这个顺序很重要——面试官想看到的不是"背了几个调优参数",而是你有没有一个从现象到根因的排查框架。

Q5:Generational ZGC 引入了年轻代,是不是也有"Minor GC"?它和传统分代收集器的 Minor GC 有什么本质区别?

概念上可以类比,但机制完全不同,这也是这个问题容易被问倒的地方。传统分代收集器的 Minor GC 是STW 式的:标记、复制、更新引用全部发生在一次停顿里,用户线程完全暂停。Generational ZGC 的年轻代收集虽然也是"只处理年轻代对象、频率更高",但它复用的是 ZGC 的染色指针和读屏障机制,标记和重分配阶段依然是并发的,只有初始标记和再标记这类极短的阶段才需要 STW——也就是说,它把"分代"这个提升吞吐量的思路,嫁接到了"并发整理"这个降低延迟的框架上,两者不是互斥的。这正是 Generational ZGC 相比 ZGC 早期版本的核心改进:早期 ZGC 不分代,所有对象无论死得多快都要跟着整堆一起并发标记,浪费了大量 CPU 去反复标记那些本该早点死掉的短命对象;引入年轻代之后,短命对象在年轻代内快速、独立地被回收,只有真正存活下来的对象才会被"提升"进入老年代参与整堆的并发周期,这直接降低了 CPU 开销、提升了吞吐量,同时保留了 ZGC 亚毫秒级停顿的核心优势。所以回答"是不是也有 Minor GC"时,准确的说法是:概念上是,但它是并发式的年轻代收集,而不是传统意义上 STW 的 Minor GC。