一、写在前面:CMS 的谢幕与 G1 的加冕
如果你接触 Java 的时间足够早,一定听过这样一句调侃:“CMS 就像一辆没有刹车的跑车,越快越危险。”在 JDK 8 及更早的时代,CMS(Concurrent Mark Sweep,并发标记清除)几乎是所有高响应服务端应用调优的首选收集器。电商、支付、交易系统里大量出现-XX:+UseConcMarkSweepGC的身影,似乎只要开启 CMS,就能显著降低停顿时间,离“零停顿”更进一步。
然而,技术的演进从来不会因为习惯而停下脚步。JDK 9 将 CMS 正式标记为Deprecated,JDK 14 则彻底移除了 CMS 的实现代码。取代它的,正是我们今天的主角:G1(Garbage-First)收集器。G1 从 JDK 7 中作为实验特性登场,到 JDK 9 成为默认收集器,再到如今几乎统治服务端 JVM 的垃圾回收场景,它用 Region 化的堆布局、可预测的停顿模型和持续进化的算法,完成了一场漂亮的上位。
本文不是一篇泛泛而谈的科普,而是一份面向真实调优与原理深挖的长文。我们将从垃圾回收的基础理论讲起,逐步拆解 CMS 的实现、缺陷与退役原因,再全面剖析 G1 的数据结构、回收流程、停顿模型和调优参数,最后给出 CMS 到 G1 的迁移建议与实战案例。无论你是正在准备把线上旧系统从 CMS 迁移到 G1,还是想系统理解 G1 的实现细节,这篇文章都能给你提供一个相对完整的知识框架。
本文假设读者已经了解 Java 基础语法与 JVM 的基本概念。文中的所有配置均基于 OpenJDK 17 LTS 环境验证,个别参数在 JDK 8 与 JDK 17 之间可能存在差异,我会在涉及处特别说明。
二、垃圾回收的底层地基:先把基础打牢
很多人一上来就研究 G1 的 Region 和 RSet,结果发现根本看不懂,原因往往是底层基础没有打牢。要理解 CMS 为什么失败、G1 为什么成功,必须先回到三个最根本的问题:什么是垃圾?怎么找到垃圾?找到之后怎么清理?
2.1 什么是垃圾:引用与对象生命周期
在 Java 中,所谓的“垃圾”指的就是从任何 GC Roots 出发都无法到达的对象。主流 JVM 使用的判定方式是可达性分析,而不是已经被淘汰的引用计数法。引用计数法无法解决循环引用的问题,比如两个对象互相持有对方的引用,但外界已经无法访问它们,引用计数永远不为零,造成内存泄漏。
可达性分析的基本单位是GC Roots。常见的 GC Roots 包括:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象;
- 方法区中类静态属性引用的对象;
- 方法区中常量引用的对象;
- 本地方法栈中 JNI(即 Native 方法)引用的对象;
- 被同步锁持有的对象;
- JVM 内部引用的对象(如系统类加载器、基本数据类型对应的 Class 对象等)。
理解 GC Roots 非常关键,因为后续 CMS 的并发标记、G1 的 STAB(Snapshot-At-The-Beginning,原始快照)算法,本质上都是在围绕“如何在不暂停应用的情况下正确、完整地找到从 GC Roots 出发可达的对象”这一难题做文章。
2.2 分代假设:为什么堆要分层
现代 JVM 垃圾回收器几乎都建立在两个经验假设之上:
- 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕死的,存活时间极短。
- 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集的对象,越难以消亡。
基于这两个假设,HotSpot 将堆划分为新生代(Young Generation)和老年代(Old Generation)。新生代又被细分为 Eden 区和两个 Survivor 区(From 与 To,也叫 S0 与 S1)。新创建的对象大多先进入 Eden 区,经历一次 Minor GC 后如果仍然存活,就被复制到 Survivor 区,之后每熬过一次 Minor GC 年龄就加一,达到阈值(默认 15,受-XX:MaxTenuringThreshold控制)后晋升到老年代。
这一分层让垃圾回收器可以“区别对待”:新生代回收频繁但成本低,通常使用标记-复制算法;老年代回收频率低,但对象密度大、大对象多,历史上使用过标记-清除或标记-整理算法。CMS 和老年代的矛盾,正是从这组算法选择中埋下的伏笔。
2.3 三种基础回收算法
无论收集器再怎么复杂,其底层都绕不开三种基础算法:标记-清除、标记-复制、标记-整理。
标记-清除(Mark-Sweep)
先标记出所有需要回收的对象,标记完成后统一回收所有被标记的对象。它的最大优点是简单,不需要移动存活对象;缺点是产生大量内存碎片。CMS 采用的就是这种算法,碎片问题也成了 CMS 最被诟病的硬伤之一。
标记-复制(Mark-Copy)
将内存分为大小相等的两块,每次只使用其中一块。回收时把存活对象复制到另一块上,然后一次性清理使用过的那一块。优点是吞吐量高、没有碎片;缺点是空间利用率只有一半。Edem 和 Survivor 采用的是这种思路。到了 G1 时代,Region 内的回收本质上也是一种带优化的复制算法,复制成了主流选择。
标记-整理(Mark-Compact)
标记完成后不直接清理,而是让所有存活对象向一端移动,然后清理边界以外的内存。优点是既能消除碎片,又能避免复制算法中“留一半空间”的浪费;缺点是移动存活对象需要更新引用、移动成本高,并且必须暂停用户线程,无法和用户线程并发执行。Parallel Old 采用这种算法,而 G1 的 Full GC 也用整理来兜底。
2.4 吞吐量与停顿:永远的天平
垃圾回收器的好坏,通常用两个指标衡量:吞吐量(Throughput)和停顿时间(Pause Time)。吞吐量是运行用户代码的时间占总运行时间的比例;停顿时间是垃圾收集期间应用被暂停的总时长。此外,还有内存占用、碎片率等次要指标。
鱼和熊掌不可兼得。Serial、Parallel 追求高吞吐量,但不顾停顿;CMS 追求低停顿,却牺牲吞吐量并留下碎片;G1 的目标则是在可预测的停顿时间的前提下,尽可能地获得较高的吞吐量。理解了这架天平,你就能明白为什么 G1 的很多参数都是在“停顿时间”和“吞吐量”之间做平衡。
三、走进 CMS:优雅设计背后的先天缺陷
CMS 的全称是 Concurrent Mark Sweep,它以获取最短回收停顿时间为目标。在互联网应用崛起的年代,服务器响应时间直接决定用户体验,CMS 恰好迎合了这一需求,因此在 JDK 5 到 JDK 8 期间被大规模采用。
3.1 CMS 的适用对象与设计目标
CMS 是一种老年代收集器,通常与新生代的 ParNew 收集器配合使用。它的核心思想是:把最耗时的标记、清除阶段与用户线程并发执行,从而降低单次停顿时间。CMS 适用于对停顿时间敏感的服务,如 Web 服务端、在线交易系统等。
典型配置如下:
-XX:+UseConcMarkSweepGC -XX:+UseParNewGC -Xms4096m -Xmx4096m -XX:CMSInitiatingOccupancyFraction=75 -XX:+UseCMSInitiatingOccupancyOnly3.2 CMS 回收的四个阶段
CMS 的一次老年代回收通常分为整整四个阶段。理解这四个阶段,也就能看懂 CMS 的所有优劣势来源。
阶段一:初始标记(Initial Mark)
这个阶段需要Stop The World(STW),但停顿时长极短,因为它只标记GC Roots 能直接关联到的对象,速度非常快,通常只需要几毫秒到十几毫秒。这个阶段完成之后,就有一个初始的存活对象集合。
阶段二:并发标记(Concurrent Mark)
从初始标记得到的对象出发,与用户线程并发地遍历整个对象图,找出所有可达对象。这一阶段不 STW,持续时间较长,可能长达数秒。并发标记期间,用户线程依然在运行,因此可能产生两种“错误”:
- 漏标(Missing):新产生的对象没有被标记,导致对象被错误回收,后果严重,绝对不允许。
- 浮动垃圾(Floating Garbage):标记期间原本可达的对象又变成垃圾,但 CMS 这一轮不会回收它们,等到下一轮再处理,这是可以接受的。
为了避免漏标,CMS 在并发标记阶段使用了增量更新(Incremental Update)算法来记录引用变化。
阶段三:重新标记(Remark)
并发标记阶段结束后,由于用户线程又修改了一部分引用,上一阶段的标记结果已经不完全准确。重新标记阶段需要STW,用来修复并发标记阶段的遗漏和误差。这个阶段的停顿时间通常比初始标记长得多,是 CMS 停顿的主要来源之一。为了尽量缩短重新标记的停顿,CMS 会借助一些优化手段,比如新生代对象尽量在重新标记前触发一次 Minor GC,减少需要修正的对象数量。
阶段四:并发清除(Concurrent Sweep)
重新标记完成后,开始与用户线程并发地清理垃圾对象。这一阶段也不 STW。由于使用标记-清除算法,清除后的内存会产生碎片,留下大量不连续的可用空间。
整个流程可以用下面这张表格总结:
| 阶段 | 是否 STW | 主要工作 | 耗时特征 |
|---|---|---|---|
| 初始标记 | 是 | 标记 GC Roots 直接关联对象 | 极短,毫秒级 |
| 并发标记 | 否 | 遍历对象图 | 长,与业务并发 |
| 重新标记 | 是 | 修正并发期间引用变化 | 较短,但明显大于初始标记 |
| 并发清除 | 否 | 清理垃圾对象 | 与业务并发 |
3.3 CMS 的三大顽疾
CMS 的设计看似优雅,但工程实践中暴露出几个无法绕开的致命问题。这些问题不是参数调优就能彻底解决的,而是由算法本质决定的。
顽疾一:内存碎片与 Concurrent Mode Failure
标记-清除算法最大的代价是内存碎片。随着运行时间变长,老年代会出现大量小块空闲空间,当需要分配一个大对象时,即使总的空闲内存充足,也可能因为找不到连续空间而分配失败。此时 CMS 会退化为使用 Serial Old 收集器进行一次Full GC,Single-threaded 的整理过程会导致长时间停顿,往往高达几秒甚至几十秒,对在线服务是灾难性的。
更糟的情况是Concurrent Mode Failure(并发模式失败):在并发标记、并发清除阶段,由于用户线程还在不断产生新对象晋升老年代,如果老年代在收集完成前就被填满,CMS 无法继续,只能紧急退化为 Full GC 进行停顿整理。停顿时间急剧上升,这正是“跑车没有刹车”说法的由来。
为了降低碎片影响,CMS 提供了-XX:+UseCMSCompactAtFullCollection和-XX:CMSFullGCsBeforeCompaction等参数,在 Full GC 时进行整理,但整理操作是全停顿的,治标不治本。
顽疾二:CPU 资源敏感
CMS 的并发标记和并发清除阶段都要消耗大量 CPU 资源。虽然默认的并发线程数是(CPU 核数 + 3) / 4,看似不多,但在 CPU 核数较少的机器上,GC 线程与业务线程抢占 CPU,导致业务吞吐量明显下降。在容器化、资源受限的环境中,这个问题尤为突出。
顽疾三:浮动垃圾与触发时机难控
并发清除阶段用户线程仍然会产生新垃圾,这些“浮动垃圾”本轮无法回收,只能留给下一轮。因此 CMS 不能等到老年代快满时才回收,必须预留一部分空间给并发期间的分配。这就引出了-XX:CMSInitiatingOccupancyFraction参数,通常在 68% 到 75% 左右触发回收。触发比例设太高,容易引发 Concurrent Mode Failure;设太低,又会频繁触发 CMS,增加 CPU 开销。这个参数非常难调,也让 CMS 的运维复杂度居高不下。
3.4 CMS 的退役之路
从 JDK 9 开始,CMS 被标记为弃用,并在 JDK 14 中彻底移除。官方给出的替代方案就是 G1。对开发者而言,从 JDK 8 升级到更高版本时,必须把 GC 参数从 CMS 切换到 G1、ZGC 或 Shenandoah。
你可以通过java -XX:+PrintCommandLineFlags -version查看当前 JDK 的默认 GC。以 JDK 17 为例,默认已经使用了 G1。
java -XX:+PrintCommandLineFlags -version # 输出示例: # -XX:+UseG1GC如果仍然使用 CMS 参数启动 JDK 17,会出现类似Unrecognized VM option 'UseConcMarkSweepGC'的报错,提示参数已经不再被支持。
四、G1 亮相:区域化堆与可预测停顿
G1(Garbage-First)的设计目标是:在停顿时间可控的前提下,尽量提升吞吐量。它不再使用连续的内存分代布局,而是把堆划分成一个个大小相等的Region,彻底改变了传统收集器的运作方式。
4.1 Region:把堆切成棋盘
G1 的堆由一组大小相等的 Region 组成,每个 Region 在逻辑上可以扮演不同的角色:Eden、Survivor、Old,以及特殊的Humongous(大对象)区域。Region 的大小通过-XX:G1HeapRegionSize指定,必须是 2 的 N 次幂,范围从 1MB 到 32MB,默认会根据堆大小自动计算,大约使 Region 总数在 2048 个左右。
这种设计的最大好处是:回收不再局限于“整个新生代”或“整个老年代”。G1 可以挑选垃圾最多的若干个 Region 进行回收,这就是 “Garbage-First” 名字的由来——优先回收收益最高的垃圾。
4.2 G1 的回收类型
G1 的回收动作分为四类,理解它们能帮你读日志、看监控时快速定位问题。
Young GC
只回收新生代 Region。当 Eden 区被填满时触发,整个过程 STW,使用并行复制算法,把存活对象复制到 Survivor 区或晋升到老年代 Region。Young GC 的效率和并行能力较高,停顿通常较短。
Mixed GC
这是 G1 最具特色的回收方式。除了回收所有新生代 Region,还会加入一部分老年代 Region进行回收。G1 会根据停顿时间目标和垃圾收益选择“最值得回收”的若干个老年代 Region,而不是一次性回收全部老年代。Mixed GC 同样 STW,但回收范围可控,因此停顿时间可控。
Full GC
当对象分配速度太快、Region 严重不足,或者 Mixed GC 无法跟上内存增长时,G1 会退化为单线程的 Full GC(JDK 10 以后支持并行 Full GC),对整个堆做标记-整理。Full G