CMS 收集器原理与缺点
问题
CMS(Concurrent Mark Sweep)被称为"低延迟"收集器,它到底是怎么工作的?为什么它曾经是互联网标配,却在 JDK 14 被正式移除?
分析
CMS 是 HotSpot 史上第一个以低停顿为主要目标的商业级收集器。它诞生于 JDK 1.4.2,那时 GC 要么是串行(Serial GC:单线程,全停顿,适合桌面和单核),要么是并行(Parallel Scavenge + Parallel Old:多线程但依然 STW,适合批处理)。CMS 的突破在于——它让 GC 的大部分工作可以和业务线程同时跑。
但突破不意味着完美。CMS 的四个致命问题(并发模式失败、浮动垃圾、内存碎片、退化到 Serial Old)让它在 G1 成熟后迅速退场。理解它,就是理解"并发 GC"这个设计维度上的得与失。
CMS 四步流程
CMS 处理老年代,分为四个阶段,其中两个是并发(和用户线程同跑),两次需要 STW(Stop The World):
线程示意图(时间轴从左到右):
用户线程: ████████████████████████████████████████████████████
↑ 初始标记 ↑ ↑ 重新标记 ↑
(STW 短) ↑ (STW 稍长)
并发标记: ════════════════════
并发清除: ════════════════════
时间 ──────────────────────────────────────────────────────────→阶段 1:初始标记(Initial Mark)— STW
标记 GC Roots 直接关联的对象。这一步只标记第一层,不遍历对象图。
// 伪代码示意:初始标记的范围
// GC Roots:栈帧局部变量、静态字段、JNI 引用、synchronized 锁对象
// 只标记这些 Roots 直接指向的对象
Set<Object> initialMarkSet = roots.stream()
.flatMap(root -> root.directRefs().stream())
.collect(toSet());这一步非常快,因为只扫 Roots 引用的一级。多线程应用下,线程数越多,Roots 数量越大,但总体仍是毫秒级。
阶段 2:并发标记(Concurrent Mark)— 并发
从初始标记的对象出发,遍历整个对象图。这是 CMS 四步中最耗时的阶段,但它和用户线程并行运行,所以用户感知不到停顿。
// 并发标记:三色标记 + 增量更新
// 白色 = 未访问,灰色 = 自身已标记但引用未遍历,黑色 = 已扫描完成
void concurrentMark(Set<Object> initialSet) {
WorkQueue<Object> gray = new WorkQueue<>(initialSet);
while (!gray.isEmpty()) {
Object obj = gray.poll();
for (Object ref : obj.references()) {
if (isWhite(ref)) {
markGray(ref); // 标记为灰色
gray.add(ref);
}
}
markBlack(obj); // 标记为黑色
}
}并发标记期间用户线程可能修改引用关系,增量更新(Incremental Update)机制记录变更:如果黑色对象新增了对白色对象的引用,CMS 会把该黑色对象"退化"回灰色,等待重新扫描。
阶段 3:重新标记(Remark)— STW
修正并发标记阶段因用户线程运行导致的引用变化。这一步比初始标记长,但比并发标记短得多。典型的优化是 -XX:+CMSScavengeBeforeRemark——在 Remark 之前先做一次 Minor GC,减少新生代存活对象,从而减少 Remark 需要扫描的对象数量。
// 增量更新的核心逻辑
// 当用户线程将一个新对象引用写入一个已被标记为黑色的对象时
void onReferenceWrite(Object owner, Object newRef) {
if (isBlack(owner) && isWhite(newRef)) {
// 增量更新:把黑色对象打回灰色
setGray(owner);
// 对应的 CMS 行为:在 Remark 阶段重新扫描 owner 的引用链
}
}阶段 4:并发清除(Concurrent Sweep)— 并发
清理未被标记的对象。由于用的是标记-清除(Mark-Sweep)而非标记-整理,这一步不移动对象,直接回收内存块。
void concurrentSweep() {
for (HeapBlock block : oldGen.blocks()) {
if (!block.isMarked()) {
block.free(); // 加入空闲链表
}
}
// 不移动存活对象,不压缩堆
}三大致命缺陷
1. 并发模式失败(Concurrent Mode Failure)
这是 CMS 最不能忽视的问题。并发标记和清除期间,用户线程还在分配对象,老年代空间必须预留够用。如果预留空间不足,CMS 会暂停并发流程,退化为 Serial Old 收集器做一次 STW 的 Full GC(单线程,标记-整理-压缩,停顿可达秒级乃至分钟级)。
// 触发条件伪代码
if (oldGen.used() + estimatedFloatingGarbage > cmsTriggerThreshold) {
// 启动 CMS 并发周期
startCMSConcurrentCycle();
}
// 并发周期中,用户线程分配导致老年代空间不足
void allocateInOld(int size) {
if (!oldGen.hasEnoughContinuousSpace(size)) {
if (cmsInProgress) {
// 并发模式失败!退化为 Serial Old Full GC
throw new ConcurrentModeFailure();
}
}
}触发阈值由 -XX:CMSInitiatingOccupancyFraction 控制,默认 68%。设得太低 → 频繁触发 CMS,设得太高 → 容易并发失败。这是一个典型的"调参博弈"。
2. 浮动垃圾(Floating Garbage)
并发标记和清除期间,用户线程产生的垃圾被称为浮动垃圾。这些对象只能等到下次 GC 才能回收。例如:
// 浮动垃圾场景
// 时间线:并发标记阶段
UserThread: Object obj = new Object(); // 此时对象被标记为"存活"
obj = null; // 引用断开,但标记已经完成
GC Thread: // 不会再标记这个对象
// 这个对象就是浮动垃圾,只能等下一轮 GC 清理浮动垃圾的存在意味着 CMS 必须预留额外空间。预留空间 = 浮动垃圾量 + 并发分配的新对象。CMS 会估算这个值,但估算不准时就会导致并发模式失败。
3. 内存碎片
标记-清除不压缩,回收区域变成不连续的碎片。即使老年代总空闲空间足够,如果没有连续空间容纳一个大对象,也会触发 Full GC。
// 模拟碎片化:老年代 100MB,回收后剩余 50MB 空闲
// 但分散在 10 个 5MB 的碎片里
// 分配一个 20MB 的大对象 → 失败 → Full GC
// 实际业务场景:重复创建/销毁大数组、大对象缓存
void allocateLargeObject() {
byte[] bigData = new byte[20 * 1024 * 1024]; // 20MB
// 如果老年代碎片化,这里会抛出 OOM: Java heap space
// 或者触发 Full GC 尝试整理
}-XX:CMSFullGCsBeforeCompaction=0 可以让每次 Full GC 都做碎片整理,但 Full GC 本身是 STW 的,代价很大。
调优参数与局限
| 参数 | 作用 | 典型值 |
|---|---|---|
-XX:+UseConcMarkSweepGC | 启用 CMS | 启用 |
-XX:CMSInitiatingOccupancyFraction | 老年代触发百分比 | 68-75 |
-XX:+UseCMSInitiatingOccupancyOnly | 只用用户设定的阈值,不用 JVM 预测 | 开启 |
-XX:+CMSScavengeBeforeRemark | Remark 前做 Minor GC | 开启 |
-XX:CMSFullGCsBeforeCompaction | 几次 Full GC 后做一次压缩 | 0 |
-XX:+CMSParallelInitialMarkEnabled | 初始标记并行化 | 开启 |
为什么 CMS 被 G1 取代
根本原因是架构局限。CMS 的增量更新需要 Remark 来保证正确性,而 Remark 本身是 STW 的。G1 用 SATB(Snapshot-At-The-Beginning)替换了增量更新,用 Region 设计解决了碎片化,用 Mixed GC 替代了 Full GC 的大部分场景——这些都是 CMS 架构上改不了的。
JDK 9 标记 CMS 为 Deprecated,JDK 14 正式移除。如果你还在用 CMS(旧系统或遗留配置),尽快迁移到 G1 或 ZGC。
总结
CMS 是 GC 从"追求吞吐量"到"追求低延迟"的转折点。它的核心设计——并发标记、增量更新、并发清除——在今天的高级 GC(G1、ZGC、Shenandoah)中仍然能看到影子。但它的三个致命缺陷(并发模式失败、浮动垃圾、内存碎片)是原有架构无法治本的。CMS 的历史价值在于:它证明了"并发 GC"是可行的,但同时也证明了标记-清除 + 增量更新的组合在 Java 大堆场景下不够健壮。
学习 CMS 不是去用它,而是理解并发 GC 的取舍,以及后来者(G1、ZGC)如何一步步解决这些问题。