Skip to content

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 直接关联的对象。这一步只标记第一层,不遍历对象图。

java
// 伪代码示意:初始标记的范围
// GC Roots:栈帧局部变量、静态字段、JNI 引用、synchronized 锁对象
// 只标记这些 Roots 直接指向的对象
Set<Object> initialMarkSet = roots.stream()
    .flatMap(root -> root.directRefs().stream())
    .collect(toSet());

这一步非常快,因为只扫 Roots 引用的一级。多线程应用下,线程数越多,Roots 数量越大,但总体仍是毫秒级。

阶段 2:并发标记(Concurrent Mark)— 并发

从初始标记的对象出发,遍历整个对象图。这是 CMS 四步中最耗时的阶段,但它和用户线程并行运行,所以用户感知不到停顿。

java
// 并发标记:三色标记 + 增量更新
// 白色 = 未访问,灰色 = 自身已标记但引用未遍历,黑色 = 已扫描完成
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 需要扫描的对象数量。

java
// 增量更新的核心逻辑
// 当用户线程将一个新对象引用写入一个已被标记为黑色的对象时
void onReferenceWrite(Object owner, Object newRef) {
    if (isBlack(owner) && isWhite(newRef)) {
        // 增量更新:把黑色对象打回灰色
        setGray(owner);
        // 对应的 CMS 行为:在 Remark 阶段重新扫描 owner 的引用链
    }
}

阶段 4:并发清除(Concurrent Sweep)— 并发

清理未被标记的对象。由于用的是标记-清除(Mark-Sweep)而非标记-整理,这一步不移动对象,直接回收内存块。

java
void concurrentSweep() {
    for (HeapBlock block : oldGen.blocks()) {
        if (!block.isMarked()) {
            block.free();  // 加入空闲链表
        }
    }
    // 不移动存活对象,不压缩堆
}

三大致命缺陷

1. 并发模式失败(Concurrent Mode Failure)

这是 CMS 最不能忽视的问题。并发标记和清除期间,用户线程还在分配对象,老年代空间必须预留够用。如果预留空间不足,CMS 会暂停并发流程,退化为 Serial Old 收集器做一次 STW 的 Full GC(单线程,标记-整理-压缩,停顿可达秒级乃至分钟级)。

java
// 触发条件伪代码
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 才能回收。例如:

java
// 浮动垃圾场景
// 时间线:并发标记阶段
UserThread:  Object obj = new Object();  // 此时对象被标记为"存活"
            obj = null;                   // 引用断开,但标记已经完成
GC Thread:                                // 不会再标记这个对象
// 这个对象就是浮动垃圾,只能等下一轮 GC 清理

浮动垃圾的存在意味着 CMS 必须预留额外空间。预留空间 = 浮动垃圾量 + 并发分配的新对象。CMS 会估算这个值,但估算不准时就会导致并发模式失败。

3. 内存碎片

标记-清除不压缩,回收区域变成不连续的碎片。即使老年代总空闲空间足够,如果没有连续空间容纳一个大对象,也会触发 Full GC。

java
// 模拟碎片化:老年代 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:+CMSScavengeBeforeRemarkRemark 前做 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)如何一步步解决这些问题。

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。