JVM CMS 垃圾回收器深度解析
CMS(Concurrent Mark Sweep,并发标记清除)是 JVM 历史上第一款真正意义上的低停顿老年代垃圾回收器,从 JDK 1.4 引入,JDK 9 被标记为 Deprecated,JDK 14 正式移除。虽然它已退出历史舞台,但其设计思想(并发回收、分阶段标记)直接影响了后来的 G1、ZGC、Shenandoah,同时也是高级 Java 面试的高频考点。
一、CMS 的设计目标
在 CMS 出现之前,老年代回收主要依靠 Serial Old(单线程、STW)和 Parallel Old(多线程、STW)。这两者的停顿时间与堆大小正相关——堆越大,停顿越长。
CMS 的核心目标是:将大部分回收工作放到与用户线程并发执行的阶段,把 STW 时间压缩到毫秒级,且停顿时间不随堆大小线性增长。
这个目标决定了它的两个基本策略:
- 并发(Concurrent):标记和清除阶段与业务线程同时运行
- 标记-清除(Mark-Sweep):不采用复制/整理算法,避免移动对象带来的长时间停顿
二、回收的四个阶段
CMS 的一个完整回收周期分为四个阶段,其中两个 STW、两个并发:
下面是 CMS 完整回收周期的 Mermaid 流程图,标注了各阶段的 STW 情况,以及并发失败(Concurrent Mode Failure)时回退到 Serial Old 的路径:
说明:图中红色节点(初始标记、重新标记)为 STW 阶段,绿色节点(并发标记、并发清除)与业务线程并发执行;当并发标记期间预留空间不足时,CMS 会触发 Concurrent Mode Failure,回退到 Serial Old 单线程整理式 Full GC,停顿可能达到数秒。
2.1 初始标记(Initial Mark)—— STW
- 只标记 GC Roots 直接关联的对象(老年代中被新生代对象引用的对象、被栈/本地方法区引用的对象)
- 虽然是 STW,但由于不做整堆遍历,只扫描一层引用,速度极快
- 通常会搭载一次 Minor GC(可配置),借机减少需要扫描的根
2.2 并发标记(Concurrent Mark)—— 与业务线程并发
- 从初始标记的根出发,遍历整个老年代对象图,标记所有存活对象
- 这是整个周期中耗时最长的阶段,但不暂停业务线程
- 风险:标记过程中业务线程在修改引用关系,会产生标记不一致问题(下文详述)
2.3 重新标记(Remark)—— STW
- 修正并发标记期间因业务线程运行而导致的标记变动
- 核心是处理并发标记期间产生/变化的引用:
- 新晋升到老年代的对象
- 老年代对象引用关系的变化
- 借助Write Barrier + 模卡表(Card Table)优化:并发标记期间,所有对老年代对象的引用变更都会把对应 Card 标为 Dirty(增量更新 / Incremental Update),Remark 只需重新扫描 Dirty Card,大幅缩短 STW 时间
- 可通过
-XX:+CMSScavengeBeforeRemark让 Remark 前先做一次 Minor GC,减少扫描新生代的负担
2.4 并发清除(Concurrent Sweep)—— 与业务线程并发
- 清理标记阶段判定为死亡的对象,回收其空间
- 由于是标记-清除算法,只回收不搬迁对象,空闲列表(Free List)记录可用空间
停顿时间小结:整个周期中只有 Initial Mark 和 Remark 两个短 STW,且都与堆大小基本无关(只与 GC Roots 数量、Dirty Card 数量相关),这就是 CMS 低停顿的本质。
下面是 CMS 一个完整回收周期内业务线程与 GC 线程时间线的 Mermaid 时序图,横轴为时间,标注了 Initial Mark(STW)、Concurrent Mark、Remark(STW)、Concurrent Sweep 四个阶段,以及各阶段业务线程是否暂停:
说明:图中红色区域(初始标记、重新标记)为 STW 阶段,业务线程暂停;绿色区域(并发标记、并发清除)与业务线程并发执行,业务线程不暂停。整个周期中只有两个短 STW,且都与堆大小基本无关,这就是 CMS 低停顿的本质。
三、Write Barrier:并发的基石
并发标记最大的难点是:标记的同时业务线程在改对象图。经典的三色标记(Tri-color Marking)模型描述了可能出问题的两种场景:
3.1 三色标记模型
三色标记(Tri-color Marking)是并发标记算法的理论基础,它把对象图遍历过程中的对象抽象为三种颜色:
| 颜色 | 含义 |
|---|---|
| 白色 | 尚未被扫描到的对象(初始状态,可能是垃圾) |
| 灰色 | 自身已扫描,但成员引用尚未扫描完(正在处理中) |
| 黑色 | 自身及成员引用均已扫描完成(确认存活) |
对象图的遍历过程就是"把白色变灰、灰色变黑"的过程:从 GC Roots 出发,先把根对象标记为灰色,然后逐个扫描灰色对象的成员引用,把引用的白色对象变灰,扫描完自身所有成员后该对象变黑,如此往复直到没有灰色对象为止。
并发标记的安全性要求:扫描结束后不存在"存活对象仍是白色"的情况。如果并发标记期间业务线程修改了对象图,就可能出现"本该存活的对象仍是白色"的漏标(Missed Mark),这是正确性问题,会导致存活对象被错误回收,必须避免。
3.2 两种破坏条件
并发标记期间若业务线程同时做了两个动作,就会产生漏标(浮动垃圾是反向问题,只浪费空间不致命;漏标是正确性问题,致命):
- 条件一:黑色对象新增了指向白色对象的引用
- 黑色对象已被视为"扫描完成",若此时它新增指向某个白色对象的引用,该白色对象就不会再被扫描到,最终被误判为垃圾
- 条件二:灰色对象到该白色对象的引用被删除
- 灰色对象尚未扫描完,若它到某个白色对象的引用被删除,该白色对象就失去了被扫描的路径,同样会被漏标
漏标的处理方式:要保证不漏标,只需破坏上述任一条件即可,业界有两种经典方案:
增量更新(Incremental Update)——CMS 采用
- 针对条件一:当黑色对象插入新指向白色对象的引用时,通过 Write Barrier 记录该黑色对象(存入标记栈),Remark 阶段把它重新变灰、重扫,从而覆盖到新增的白色引用
- 优点:只记录"新增引用"这一小部分变更,Remark 阶段扫描量小
- 缺点:需要 STW 的 Remark 阶段来重扫被记录的黑色对象
原始快照(SATB,Snapshot-At-The-Beginning)——G1 采用
- 针对条件二:在引用被删除时,通过 Write Barrier 记录被删除的旧引用(即"快照"),保证并发标记开始时存活的对象在标记结束时仍被视为存活
- 优点:不需要 STW 重扫,标记过程更平滑
- 缺点:可能保留一些"并发标记期间已死"的对象(浮动垃圾),需要下一轮回收
对比:G1 采用的是原始快照(SATB,Snapshot-At-The-Beginning),在引用被删除时记录旧引用,破坏条件二。这是 CMS 与 G1 在并发标记实现上的核心差异之一,面试高频。
3.2 两种破坏条件
并发标记期间若业务线程同时做了两个动作,就会产生漏标(浮动垃圾是反向问题,只浪费空间不致命;漏标是正确性问题,致命):
- 条件一:黑色对象新增了指向白色对象的引用
- 条件二:灰色对象到该白色对象的引用被删除
CMS 采用增量更新(Incremental Update):当黑色对象插入新指向白色对象的引用时,通过 Write Barrier 记录该黑色对象(存入标记栈),Remark 阶段把它重新变灰、重扫。这破坏了条件一。
对比:G1 采用的是原始快照(SATB,Snapshot-At-The-Beginning),在引用被删除时记录旧引用,破坏条件二。这是 CMS 与 G1 在并发标记实现上的核心差异之一,面试高频。
3.3 Card Table 与 Dirty Card
- 老年代按 512 字节划分为若干 Card,构成全局卡表(Card Table)
- 新生代 GC 时,通过记录老年代→新生代引用的 Dirty Card 避免全老年代扫描(Remembered Set 思想的简化版)
- CMS 并发标记阶段复用卡表记录变更,Remark 阶段只扫 Dirty Card
- 代价:Write Barrier 本身有性能开销,维护卡表也有并发竞争成本
四、CMS 的三大固有问题
4.1 内存碎片(Mark-Sweep 的代价)
- 标记-清除不移动对象,长期运行后老年代碎片化严重
- 碎片带来的典型故障:大对象无法分配——总空闲内存足够,但没有一块连续空间,触发 Full GC
- 缓解手段:
-XX:+UseCMSCompactAtFullCollection(JDK 9 前默认开启,Full GC 时整理碎片,但整理过程 STW 且串行);-XX:CMSFullGCsBeforeCompaction=N(N 次 Full GC 后整理一次)
4.2 浮动垃圾(Floating Garbage)
- 并发清除阶段业务线程仍在运行,此期间产生的垃圾只能等下一轮回收
- 因此 CMS不能等老年代满了才开始回收,必须提前启动
-XX:CMSInitiatingOccupancyFraction=75 -XX:+UseCMSInitiatingOccupancyOnly:老年代占用达到 75%(经验值)时触发回收,预留 25% 空间给并发期间的业务分配
下面是 CMS 触发回收时机与老年代占用率关系的 Mermaid 时序图,横轴为时间,纵轴为老年代占用百分比,标注了CMSInitiatingOccupancyFraction=75%的触发线、并发回收窗口,以及 Concurrent Mode Failure 发生时老年代占满的路径:
说明:图中蓝色区域为正常并发回收窗口——老年代占用达到 75% 触发线时 CMS 提前启动回收,预留 25% 空间给并发期间的业务分配;红色区域为 Concurrent Mode Failure 路径——若并发回收期间业务分配过快或碎片导致连续空间不足,老年代被占满,CMS 回退到 Serial Old 单线程整理式 Full GC,停顿可能达到数秒。
4.3 Concurrent Mode Failure(并发模式失败)
这是 CMS 最著名的故障场景:
- CMS 并发回收期间,业务线程还在分配内存(并发阶段业务线程与回收线程同时在写老年代)
- 若预留空间不够分配(尤其是碎片导致连续空间不足),业务线程无法继续分配
- 此时 JVM 触发Serial Old 单线程整理式 Full GC,停顿可能达到数秒——CMS 的低停顿优势瞬间归零
调优思路(实战):
- 调低
CMSInitiatingOccupancyFraction(如 70),让 CMS 提前回收 - 减少碎片:合理设置
CMSFullGCsBeforeCompaction,定期整理 - 控制晋升速度:调大新生代、降低对象晋升年龄,避免大促/批处理场景短时间大量对象直接进入老年代
- 排查大对象:
-XX:PretenureSizeThreshold让超大对象直接进入老年代未必是坏事,但碎片敏感场景要注意 - 监控日志:
-XX:+PrintGCDetails中出现concurrent mode failure是明确告警信号
另外还有Promotion Failure(晋升失败):Minor GC 时新生代对象要晋升到老年代,但老年代没有足够连续空间(即使是 Minor GC 也会连带触发 Full GC)。
五、常用参数速查
| 参数 | 作用 | 备注 |
|---|---|---|
-XX:+UseConcMarkSweepGC | 启用 CMS(老年代),新生代默认 ParNew | JDK 14 移除 |
-XX:CMSInitiatingOccupancyFraction=75 | 触发阈值(老年代占比) | 需配合下一参数才稳定生效 |
-XX:+UseCMSInitiatingOccupancyOnly | 严格按上述阈值触发 | 不设则 JVM 会自行启发式调整 |
-XX:+UseCMSCompactAtFullCollection | Full GC 时整理碎片 | JDK 9 前默认开 |
-XX:CMSFullGCsBeforeCompaction=0 | 每 N 次 Full GC 后整理 | 0 表示每次都整理 |
-XX:+CMSParallelRemarkEnabled | 并行执行 Remark | 降低 Remark 停顿 |
-XX:+CMSScavengeBeforeRemark | Remark 前先 Minor GC | 减少扫新生代 |
-XX:+CMSParallelInitialMarkEnabled | 并行执行初始标记 | 降低初始标记停顿 |
-XX:ParallelCMSThreads | CMS 并发线程数 | 默认(CPU数+3)/4 |
-XX:+PrintGCDetails/-Xlog:gc* | GC 日志 | JDK 8 / JDK 9+ 语法 |
注意ParallelCMSThthreads默认值的含义:回收线程会占用 1/4 的 CPU 资源。如果应用本身 CPU 紧张(比如量化交易这类低延迟场景),并发阶段业务吞吐会被明显拉低——低停顿是用 CPU 换来的。
六、CMS vs G1 vs ZGC
| 维度 | CMS | G1 | ZGC/Shenandoah |
|---|---|---|---|
| 算法 | 标记-清除 | 标记-整理(Region 整体复制) | 染色指针/读屏障 + 转发指针 |
| 堆布局 | 物理分代(连续) | 逻辑分代(Region 网格) | Region,不分代(ZGC 初期) |
| 碎片 | 有 | 无 | 无 |
| 并发标记 | 增量更新 | SATB | 各有实现 |
| 停顿量级 | 10~200ms | 10~200ms(可预测模型) | <1ms / 亚毫秒 |
| 停顿与堆大小 | 基本无关 | 基本无关 | 完全无关 |
| 适用堆 | ≤8G | 4G~32G | 百 GB~TB 级 |
| 状态 | 已移除(JDK 14) | JDK 9+ 默认 | JDK 15+ 生产可用 |
CMS 的历史地位在于证明了并发回收这条路走得通;G1 用 Region 化解决了碎片问题;ZGC/Shenandoah 用着色指针/读屏障把并发推进到了**并发整理(对象移动也可与业务线程并发)**的时代。
七、思考
Q1:CMS 为什么不直接用标记-整理?
移动对象必须修正所有指向该对象的引用,这个操作无法与业务线程并发安全地完成(会看到"对象正在被搬运"的中间状态),只能 STW。为了保住低停顿,CMS 拆中选择了标记-清除,用碎片问题换取停顿时间。
Q2:CMS 什么时候触发 Full GC?
三种情况:① 老年代空间不足以支撑并发回收(Concurrent Mode Failure);② 晋升失败(Promotion Failed);③ 显式调用System.gc()(可用-XX:+DisableExplicitGC禁用,但注意堆外内存 DirectByteBuffer 依赖 System.gc() 分配,NIO 重度应用建议改用-XX:+ExplicitGCInvokesConcurrent)。
Q3:CMS 新生代为什么必须配 ParNew?
CMS 需要特定的 Write Barrier 逻辑配合其并发标记,Serial/Parallel Scavenge 无法与之协同。JDK 8 时代 CMS + ParNew 是标准组合。
Q4:CMSInitiatingOccupancyFraction 设多少合适?
经验值 70~80。设太高容易 Concurrent Mode Failure,太低则频繁触发并发回收、浪费 CPU。对碎片敏感或晋升频繁的应用应偏低。配合-XX:+UseCMSInitiatingOccupancyOnly保证行为可预测。
Q5:生产上怎么发现 CMS 的碎片问题?
GC 日志中反复出现concurrent mode failure且伴随 Serial Old 的长停顿;老年代使用率呈"锯齿但底部逐渐抬高"形态;jstat -gcutil观察老年代 Full GC 后回落到的水位一次比一次高。
八、总结
CMS 的历史贡献是确立了并发回收的设计范式:用并发标记 + 短停顿修正的模式把停顿从"秒级、与堆大小正相关"降到"毫秒级、基本与堆无关"。它的两大先天缺陷——内存碎片和浮动垃圾带来的空间预留压力——根源于标记-清除算法,无法根治,最终被 Region 化、支持并发整理的 G1 取代。
对今天仍在维护 JDK 8 老系统的工程师,CMS 调优仍然是实战课题:控制碎片、监控 Concurrent Mode Failure、合理设置触发阈值;而对新系统,JDK 17/21 + G1(或 ZGC)已是更优选择。理解 CMS 的演进逻辑,也就理解了现代低延迟回收器的设计动机。