27-CMS 收集器原理与调优
前面讲的收集器(Serial、ParNew、Parallel Scavenge)在回收老年代时都必须 STW,堆越大停顿越长。对于交互式 Web 服务,几百毫秒的停顿就可能导致请求超时。CMS(Concurrent Mark Sweep)是 HotSpot 第一款让老年代回收与用户线程并发进行的收集器,把停顿从"秒级"降到"百毫秒级",曾长期是低延迟场景的首选。本篇深入剖析 CMS 的四阶段流程、三色标记算法、Concurrent Mode Failure 与内存碎片问题,并给出生产级调优参数清单。
CMS 的四个阶段
CMS 的老年代回收分为四个阶段,其中两个是并发的(应用线程不停):
阶段1:初始标记 (Initial Mark) ── STW,极短 阶段2:并发标记 (Concurrent Mark) ── 并发,最耗时 阶段3:重新标记 (Remark) ── STW,较短 阶段4:并发清除 (Concurrent Sweep)── 并发,清理垃圾时间线:
应用线程: ████░░███████████████████░░██████████████████░░█████████ ↑ IM ↑ RM ↑ CS STW STW 并发 GC 线程: ──── 并发标记 ──── ──── 并发清除 ────初始标记(Initial Mark)
STW,但耗时极短。它只标记GC Roots 能直接关联的对象(即 GC Roots 的一跳邻居),不遍历整个对象图。
GC Roots → A → B → C → ... ↑ 初始标记只走到这一层由于范围小,停顿通常几毫秒到几十毫秒。这一步依赖 ParNew 做新生代回收(Minor GC)来减少跨代引用扫描。
并发标记(Concurrent Mark)
并发,耗时最长但不停应用。从初始标记的根开始遍历整个对象图,标记所有可达对象。期间应用线程还在跑,可能产生新的引用变化。
这里的核心挑战是:标记过程中对象引用关系在变,如何保证标记正确?这引出了三色标记算法。
重新标记(Remark)
STW,暂停应用线程,修正并发标记期间因引用变化导致的标记偏差。这是 CMS 中第二个 STW 点,停顿通常比初始标记长,但远短于并发标记。
重新标记会处理"并发标记期间新产生的引用"——主要是通过SATB(Snapshot-At-The-Beginning)思路和增量更新机制,确保漏标对象被补标。
并发清除(Concurrent Sweep)
并发,清理未被标记的对象,回收空间。这一阶段不停应用线程,所以即使耗时较长也不影响延迟。缺点是清理后会产生内存碎片(详见后文)。
三色标记算法
并发标记的理论基础是三色标记(Tri-color Marking),把对象分为三种状态:
- 白色:尚未被标记(候选垃圾)。
- 灰色:已被标记,但其引用的对象还没全部标记(待处理)。
- 黑色:已被标记,且其引用的对象也已全部标记(存活,不会被回收)。
初始:所有对象为白色,GC Roots 为灰色 过程:从灰色对象出发,将其引用对象标灰,自身标黑 结束:剩余白色对象即为垃圾[黑]A ──→ [灰]B ──→ [白]C ──→ [白]D │ └──→ [黑]E并发标记的"漏标"问题
并发标记时,应用线程可能修改引用,导致两种问题:
问题1:黑色对象指向白色对象(新增引用)
标记前:[黑]A [白]C(C 即将被回收) 标记后:[黑]A ──→ [白]C (A 新引用了 C)A 已标记为黑色(不会再扫描),所以 C 不会被标记——但 C 实际已被引用,不该回收。这就是漏标,会导致存活对象被误回收,是致命错误。
问题2:灰色对象断开到白色对象的引用
标记前:[灰]B ──→ [白]C (C 等待 B 扫描) 标记后:[灰]B ──✗ (B 断开了对 C 的引用,但 C 被其他黑对象引用)如果 B 是唯一会扫描到 C 的灰色对象,断开后 C 永远不会被标灰——同样漏标。
CMS 的解决方案:增量更新
CMS 用增量更新(Incremental Update)解决问题1:当黑色对象新指向白色对象时,记录这次写操作。重新标记阶段,把这些"黑色→白色"的引用重新扫描一遍,补标为灰色。
// 伪代码:CMS 的写屏障voidfieldWrite(ObjectblackObj,Fieldf,ObjectwhiteObj){if(isBlack(blackObj)&&isWhite(whiteObj)){cardTable.mark(blackObj);// 记录到 Card Table}// 实际写入blackObj.f=whiteObj;}重新标记时遍历 Card Table,重新扫描这些黑色对象。这就是重新标记存在的根本原因——它要"补救"并发期间的漏标。
注意:G1 用的是SATB(在并发开始时拍快照),思路不同但目标一致,下一篇会详述。
Concurrent Mode Failure
CMS 并发回收的代价是:回收速度跟不上分配速度时,老年代空间不够用。这时触发Concurrent Mode Failure。
触发条件
CMS 在并发阶段(标记或清除)期间,应用线程还在分配对象进入老年代。如果老年代空间耗尽,而 CMS 还没回收完,就会:
- 触发 Full GC,退化为 Serial Old(单线程、STW、Mark-Compact)。
- 整个堆停顿,可能几秒到十几秒。
时间线: 应用分配: ──→ 老年代快满 ──→ 继续分配 ──→ 老年代耗尽 ──→ 💥 Concurrent Mode Failure CMS 回收: 并发标记中 ──────────────→ 还没回收完 ──→ 降级 Serial Old为什么降级为 Serial Old
因为此时老年代空间已耗尽,无法继续并发回收(并发回收需要额外空间做标记)。唯一能做的就是 STW、全堆整理——而 CMS 自身没有 STW Full GC 实现,只能借用 Serial Old。
这是 CMS 最大的痛点:一次 Concurrent Mode Failure 可能让停顿从 50ms 飙到 5 秒,对延迟敏感的系统是灾难。
预防策略
- 调低触发阈值:让 CMS 提早开始回收(见
CMSInitiatingOccupancyFraction)。 - 扩大老年代:给回收留出更多空间余量。
- 降低分配速率:优化业务代码,减少大对象分配。
内存碎片问题
CMS 用标记-清除(Mark-Sweep)算法,不整理内存。长期运行后老年代会出现大量碎片:
[占用][空][占用][空空][占用][空][占用][空空空] ↑ 碎片:总和够,但不连续当需要分配一个 5MB 大对象,碎片总和有 10MB 但最大连续块只有 2MB 时,分配失败——触发 Full GC 整理碎片。
Full GC with Compact
碎片严重时,CMS 会触发一次Full GC + Compact:
- Serial Old 单线程整理,STW。
- 整理后碎片消失,但停顿很长。
[占用][空][占用][空空][占用] → [占用占用占用][空空空空空空] 整理前(碎片) 整理后(连续)这就是 CMS 虽然标榜"低延迟",但偶尔的长停顿常被诟病的原因:日常 GC 很快,但碎片触发 Full GC 时可能数秒。
关键调优参数
-XX:CMSInitiatingOccupancyFraction
设置老年代占用率阈值,达到后触发 CMS 回收:
-XX:CMSInitiatingOccupancyFraction=70默认值是92%(JDK 8),太高了——意味着老年代用到 92% 才开始回收,留给并发回收的空间很紧,容易 Concurrent Mode Failure。
生产建议设70-80,给并发回收留足余量。配合-XX:+UseCMSInitiatingOccupancyFraction使用(JDK 8 需显式开启手动模式):
-XX:+UseConcMarkSweepGC\-XX:+UseCMSInitiatingOccupancyFraction\-XX:CMSInitiatingOccupancyFraction=75-XX:+UseCMSCompactAtFullCollection
开启后,每次 Full GC 后做内存整理(默认开启)。关闭则只清除不整理,停顿短但碎片累积。
-XX:+UseCMSCompactAtFullCollection# 开启整理(默认)-XX:CMSFullGCsBeforeCompaction=0# 多少次 Full GC 后整理,0=每次都整理由于 CMS Full GC 已经是 STW,整理多花的几十毫秒通常可接受。一般保持默认。
-XX:+CMSScavengeBeforeRemark
重新标记前先做一次 Minor GC。好处:新生代里的垃圾先清掉,减少重新标记要扫描的跨代引用,缩短重新标记停顿。
-XX:+CMSScavengeBeforeRemark强烈建议开启,尤其当新生代较大时。代价是多了次 Minor GC,但通常物有所值。
-XX:ParallelCMSThreads
CMS 并发线程数,默认(ParallelGCThreads + 3) / 4。太多会挤占应用 CPU,太少回收跟不上。一般保持默认,CPU 紧张时可下调。
调优参数清单
下面是一份生产级 CMS 参数模板(JDK 8),按需调整:
java-Xms4g-Xmx4g-Xmn1g\-XX:+UseConcMarkSweepGC-XX:+UseParNewGC\-XX:CMSInitiatingOccupancyFraction=70\-XX:+UseCMSInitiatingOccupancyFraction\-XX:+CMSScavengeBeforeRemark\-XX:+UseCMSCompactAtFullCollection\-XX:CMSFullGCsBeforeCompaction=0\-XX:ParallelGCThreads=8\-XX:ConcGCThreads=2\-XX:+PrintGCDetails-XX:+PrintGCDateStamps\-Xloggc:/var/log/gc.log\-cpMyApp com.example.Main要点:
- 堆固定(
-Xms = -Xmx):避免堆扩展触发额外 GC。 - 新生代 1GB:约占堆 25%,给老年代留足空间。
- 阈值 70%:老年代到 70% 就回收,余量充足。
- 重新标记前 Minor GC:减少重新标记停顿。
- GC 日志:必开,用于事后分析。
代码示例:制造 CMS Full GC
/** * 演示 CMS 行为与 Concurrent Mode Failure * 适用 JDK 8 * * 运行: * java -Xms1g -Xmx1g -Xmn256m * -XX:+UseConcMarkSweepGC -XX:+UseParNewGC * -XX:CMSInitiatingOccupancyFraction=70 * -XX:+UseCMSInitiatingOccupancyFraction * -XX:+PrintGCDetails -XX:+PrintGCDateStamps * -Xloggc:gc.log -cp MyApp CmsGcDemo */publicclassCmsGcDemo{staticfinalint_1MB=1024*1024;staticfinaljava.util.List<byte[]>CACHE=newjava.util.ArrayList<>();publicstaticvoidmain(String[]args)throwsException{// 持续往老年代填充,制造压力for(inti=0;i<1000;i++){CACHE.add(newbyte[2*_1MB]);// 大对象直接进老年代if(i%5==0)CACHE.subList(0,CACHE.size()/2).clear();// 偶尔清理制造碎片Thread.sleep(10);}}}观察日志中的关键字段:
# 并发标记开始 [2026-07-17T10:00:01.234] [GC [CMS-concurrent-mark-start] # 并发标记结束 [2026-07-17T10:00:02.567] [CMS-concurrent-mark: 1.333/1.333 secs] # 初始标记(STW) [2026-07-17T10:00:01.000] [GC (CMS Initial Mark) [1 CMS-initial-mark: 600M(768M)] 800M(1024M), 0.012 secs] # 重新标记(STW) [2026-07-17T10:00:02.400] [GC (CMS Final Remark) [1 CMS-remark: 650M(768M)] 850M(1024M), 0.045 secs] # Concurrent Mode Failure(灾难) [2026-07-17T10:00:03.000] [GC (CMS Mode failure): 768M(768M) 1024M(1024M) 5.678 secs]看到CMS Mode failure就说明 CMS 没顶住,触发了 Serial Old Full GC——这时就该调阈值或扩老年代了。
CMS 的衰落
CMS 在 JDK 9 被标记deprecated,JDK 14 被彻底移除。原因:
- 内存碎片:Mark-Sweep 固有问题,Full GC 整理停顿不可控。
- Concurrent Mode Failure:高分配速率下不可预测的降级。
- 浮动垃圾:并发期间产生的垃圾本轮回收不掉。
- 代码维护负担:CMS 实现复杂,G1 已经能更好满足低延迟需求。
新项目不应再用 CMS。JDK 11+ 直接用 G1,JDK 15+ 考虑 ZGC/Shenandoah。但理解 CMS 对学习 G1、ZGC 的并发回收思路至关重要——它们都站在 CMS 的肩膀上。
实践要点
1. 阈值宁低勿高
默认 92% 太激进。生产环境设 70-80,宁可多回收几次,也别等耗尽。
2. 监控 Concurrent Mode Failure
CMS 日志里出现concurrent mode failure就是红线报警。即使一周一次也要追查原因——通常是分配速率突增或老年代太小。
3. 大对象是 CMS 杀手
大对象(如大数组、大字符串)直接进老年代,快速消耗空间。如果业务有大量大对象分配,考虑:
- 限制单次分配大小。
- 复用缓冲区,避免频繁创建。
4. 浮动垃圾不可避免
并发标记期间产生的垃圾,本轮回收不掉,只能等下次。这是 CMS 并发设计的固有代价,无法消除,只能靠提高回收频率缓解。
5. 迁移到 G1 的时机
- JDK 升级到 11+:直接切 G1。
- 堆超过 4GB:G1 分区回收比 CMS 更可控。
- Concurrent Mode Failure 频发:G1 的 Mixed GC 能更好处理碎片。
小结
- CMS是第一款让老年代回收与用户线程并发的收集器,四个阶段中初始标记和重新标记STW,并发标记和并发清除与应用并发。
- 三色标记(黑灰白)是并发标记的理论基础,CMS 用增量更新解决黑色对象新引用白色对象的漏标问题。
- Concurrent Mode Failure是 CMS 最大的痛点:老年代耗尽时降级为 Serial Old,停顿可能数秒,靠调低触发阈值预防。
- 内存碎片是 Mark-Sweep 的固有缺陷,靠
UseCMSCompactAtFullCollection在 Full GC 后整理缓解。 - 关键调优参数:
CMSInitiatingOccupancyFraction=70、CMSScavengeBeforeRemark、固定堆大小;JDK 14 后 CMS 已移除,新项目应迁移到 G1 或 ZGC。
下一篇我们进入 G1——它用分区化设计解决了 CMS 的碎片和 Full GC 不可控问题,是 JDK 9+ 的默认收集器,也是现代 JVM 调优的核心。
更多内容:JVM调优实战