06 经典垃圾回收器——Serial、Parallel、CMS 深度剖析

摘要:

如果说上一篇介绍的三种基础算法是”食材”,那么垃圾回收器就是将食材烹饪成菜肴的”厨师”——同样的食材,不同的烹饪方式,结果完全不同。本文深入剖析 HotSpot 历史上三代经典垃圾回收器:Serial/Serial Old(单线程,STW 全停,简单可靠,JDK 1.3 之前的唯一选择)、Parallel Scavenge/Parallel Old(多线程并行,追求最大吞吐量,JDK 8 之前服务端默认)、CMS(Concurrent Mark Sweep)(并发标记减少停顿,首次引入”与用户线程并发工作”的 GC,开启了低延迟 GC 的时代)。理解这三代回收器的演进,是理解 G1、ZGC 等现代 GC 的必要前提——现代 GC 的每一个设计决策,都是在解决 CMS 留下的遗留问题。


第 1 章 垃圾回收器的两个核心评价维度

在深入各个收集器之前,需要建立评价 GC 的统一语言。GC 的性能主要由两个相互对立的维度衡量:

吞吐量(Throughput):用户线程运行时间占总时间的比例:

吞吐量 = 用户代码运行时间 / (用户代码运行时间 + GC 停顿时间)

吞吐量 99% 意味着程序 100 秒中只有 1 秒在做 GC,99 秒在处理业务。吞吐量高的 GC 适合批处理任务(比如 Hadoop、Spark 作业)——用户不在意某次 GC 停顿了 2 秒,只要总任务完成时间短就好。

停顿时间(Latency / Pause Time):单次 GC 造成的最大 STW 时间。停顿时间低的 GC 适合交互式应用(Web 服务器、金融交易系统)——用户可以接受整体吞吐量低一些,但不能接受某次请求突然卡顿 2 秒。

设计哲学:吞吐量与延迟的天然对立

这两个目标在一定程度上是天然对立的。要提高吞吐量,就要减少 GC 的”管理开销”,让 GC 集中干活(例如并行 STW,虽然停顿时间长,但管理开销小,总效率高)。要降低延迟,就要让 GC 与用户线程并发工作(并发 GC),代价是 GC 线程和用户线程互相竞争 CPU,总体吞吐量略有下降。这个权衡贯穿整个 GC 发展史。


第 2 章 Serial 收集器——单线程时代的奠基者

2.1 Serial 是什么

Serial 收集器(新生代)和 Serial Old 收集器(老年代)是 HotSpot 最早的垃圾回收器,也是最简单的实现。

核心特点

  • 单线程:整个 GC 过程只使用一个线程完成
  • Stop-The-World:GC 期间,所有用户线程全部暂停,等待 GC 完成

sequenceDiagram
    participant UT as "用户线程"
    participant GC as "GC 线程(单线程)"
    
    UT->>UT: "运行业务代码"
    UT->>GC: "Eden 满,触发 Minor GC"
    Note over UT: "STW:所有用户线程停止"
    GC->>GC: "Serial:单线程复制算法回收新生代"
    GC->>UT: "GC 完成,恢复用户线程"
    UT->>UT: "继续运行业务代码"

2.2 Serial 的算法

  • 新生代(Serial):复制算法(Eden + Survivor 的复制收集)
  • 老年代(Serial Old):标记-整理算法

2.3 为什么 Serial 至今没有被废弃

听起来 Serial 是个”老古董”,但它至今仍然是 HotSpot 的合法选项,原因有三:

简单可靠:Serial 是所有收集器中代码最简单、最好理解的,没有线程间协调的复杂性,也没有任何并发 Bug 的风险。

适合小堆/单核环境:在客户端应用(桌面应用、嵌入式设备)中,堆通常很小(几十 MB 到几百 MB),单次 GC 停顿时间本来就很短(可能就几十毫秒),多线程并行 GC 的线程切换和同步开销反而可能超过收益。

容器场景的意外适用:在 CPU 只有 1 核的容器中(-XX:ActiveProcessorCount=1),并行收集器退化为实际只有 1 个 GC 线程,与 Serial 无异,但 Serial 省去了多线程框架的开销。

启用方式-XX:+UseSerialGC(同时使用 Serial + Serial Old)


第 3 章 Parallel 收集器——吞吐量至上的并行 GC

3.1 从 Serial 到 Parallel 的关键一步

Parallel Scavenge 收集器(新生代)和 Parallel Old 收集器(老年代)是 Serial 的多线程版本,也是 JDK 8 服务端 JVM 的默认 GC(-server 模式)。

核心改变:将单线程 GC 改为多个 GC 线程并行执行,大幅缩短 STW 停顿时间(在多核机器上,N 个 GC 线程理论上将 GC 时间缩短为 Serial 的 1/N)。


sequenceDiagram
    participant UT as "用户线程(多线程)"
    participant GC1 as "GC 线程 1"
    participant GC2 as "GC 线程 2"
    participant GC3 as "GC 线程 3"
    
    UT->>GC1: "Eden 满,触发 Minor GC"
    Note over UT: "STW:所有用户线程停止"
    GC1->>GC1: "并行处理 Eden 区段 1"
    GC2->>GC2: "并行处理 Eden 区段 2"
    GC3->>GC3: "并行处理 Eden 区段 3"
    GC1->>UT: "3 个 GC 线程完成,恢复"

注意:这里的”并行”是指多个 GC 线程之间并行,用户线程仍然全部停止(STW 依然存在,只是时间变短了)。

3.2 Parallel Scavenge 的目标——最大化吞吐量

Parallel Scavenge 与 ParNew(也是并行新生代收集器)的关键区别在于设计目标不同

  • ParNew:为了配合 CMS 而设计,着重降低停顿时间,可以与 CMS 配合使用
  • Parallel Scavenge:追求最大化吞吐量(Throughput),不是最小化单次停顿

Parallel Scavenge 提供了两个关键参数:

-XX:MaxGCPauseMillis:期望最大 GC 停顿时间(毫秒)。JVM 会尽量保证单次 GC 停顿不超过此值,但不是绝对保证。注意:为了缩短停顿,JVM 可能会减小新生代大小(新生代越小,每次 GC 处理的对象越少,停顿越短)——但这会导致 Minor GC 更频繁,总体吞吐量可能下降。

-XX:GCTimeRatio:GC 时间占总时间的比例上限。默认值 99,表示 GC 时间不超过总时间的 1/(1+99) = 1%(即吞吐量至少 99%)。

-XX:+UseAdaptiveSizePolicy:自适应调节策略(Ergonomics)。JVM 根据实际运行的 GC 情况,自动调整新生代大小(-Xmn)、Eden 与 Survivor 的比例(-XX:SurvivorRatio)、对象晋升年龄阈值(-XX:MaxTenuringThreshold)等参数,不需要手动调优——这是 Parallel Scavenge 的独特卖点,也是它被称为”自适应收集器”的原因。

3.3 Parallel Old——Parallel Scavenge 的老年代搭档

在 JDK 6 之前,Parallel Scavenge 没有匹配的老年代并行收集器,只能与 Serial Old 配合——这是一个尴尬的组合(新生代多线程 GC,老年代单线程 GC,整体受限于老年代的单线程效率)。

Parallel Old 收集器(JDK 6 引入)填补了这个空缺,使用多线程 + 标记-整理算法回收老年代,与 Parallel Scavenge 配合组成真正的”全并行”组合。

JDK 8 默认 GC:-XX:+UseParallelGC(新生代 Parallel Scavenge + 老年代 Parallel Old)

GC 线程数:默认等于 CPU 核心数(最多 8 个),也可以通过 -XX:ParallelGCThreads=N 指定。


第 4 章 CMS 收集器——并发 GC 的里程碑

4.1 CMS 解决的核心问题

Parallel GC 大幅缩短了 STW 停顿时间(通过多线程并行),但停顿依然存在——整个 GC 过程中用户线程全部停止。在大堆(数 GB)的老年代 Full GC 时,即使多线程并行,停顿也可能达到数秒。

CMS(Concurrent Mark Sweep)收集器的目标:让 GC 的大部分工作与用户线程并发执行,从而将 STW 停顿时间压缩到极短(只在极少数步骤需要 STW)。

这是 HotSpot GC 历史上的里程碑:第一次实现了”GC 线程与用户线程同时运行”,开创了低延迟 GC 的先河。

4.2 CMS 的四个阶段

CMS 对老年代使用并发标记-清除算法,整个回收过程分为四个阶段:


graph LR
    subgraph "STW(极短)"
        A["初始标记\nInitial Mark"]
    end
    subgraph "并发(与用户线程同时)"
        B["并发标记\nConcurrent Mark"]
    end
    subgraph "STW(极短)"
        C["重新标记\nRemark"]
    end
    subgraph "并发(与用户线程同时)"
        D["并发清除\nConcurrent Sweep"]
    end

    A --> B --> C --> D

    classDef stw fill:#ff5555,stroke:#ff5555,color:#f8f8f2
    classDef concurrent fill:#50fa7b,stroke:#50fa7b,color:#282a36
    class A,C stw
    class B,D concurrent

阶段 1:初始标记(Initial Mark)—— STW,极短

只标记直接与 GC Roots 关联的老年代对象(GC Roots 能直接引用的,不进行深度扫描)。仅需标记一层,工作量极小,STW 时间非常短(通常几毫秒)。

阶段 2:并发标记(Concurrent Mark)—— 并发,最耗时

从初始标记的结果出发,对整个老年代进行深度可达性遍历(三色标记)。这是 CMS 最耗时的阶段,但与用户线程并发运行,不会造成停顿。

代价:用户线程在并发标记期间会修改引用关系(新建对象、改变引用指向),导致已标记的状态不再准确,需要后续的重新标记阶段来修正。

阶段 3:重新标记(Remark)—— STW,较短

修正并发标记期间因用户线程运行导致的标记变动。CMS 使用增量更新(Incremental Update) 方案:在并发标记阶段,通过写屏障记录所有发生变化的引用(黑色对象新增的白色引用),重新标记阶段再次扫描这些变动的对象。

这个阶段比初始标记耗时稍长(需要扫描写屏障记录的变动集合),但远短于并发标记阶段,通常只有数十毫秒到 1-2 秒。

重新标记采用多线程并行-XX:ParallelCMSThreads 控制线程数),进一步缩短 STW 时间。

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

将未被标记的垃圾对象的内存空间加入空闲列表,与用户线程并发运行。由于使用标记-清除算法(而不是标记-整理),不移动对象,不需要更新引用,可以安全地与用户线程并发进行。

并发清除期间产生的新垃圾(用户线程在清除阶段新创建后又立即死亡的对象)无法在本轮 CMS 中回收,称为浮动垃圾(Floating Garbage),等待下一轮 CMS 处理。

4.3 CMS 的配套新生代收集器

CMS 用于老年代,新生代需要配套的收集器。通常与 ParNew 收集器配合:

  • ParNew 是 Serial 的多线程版本,专为配合 CMS 而设计(可以与 CMS 的 GC 线程协同工作)
  • 启用方式:-XX:+UseConcMarkSweepGC(JVM 自动选择 ParNew + CMS + Serial Old)

4.4 CMS 的三个致命缺陷

CMS 首创了并发 GC 的思想,但它自身存在三个无法根治的缺陷,这也是它在 JDK 9 被废弃、JDK 14 被移除的原因:

缺陷 1:CPU 资源竞争

并发标记和并发清除阶段,CMS 线程与用户线程争夺 CPU 时间。默认 CMS 线程数为 (CPU 核心数 + 3) / 4,在 4 核机器上是 1 个线程,占用 25% 的 CPU;在 8 核上是 2 个线程,占用 25%。

对于 CPU 本来就紧张的应用,CMS 的并发阶段会明显影响用户线程的执行速度(吞吐量下降),即使表面上没有 STW 停顿,响应时间也可能增加。

缺陷 2:浮动垃圾与并发失败(Concurrent Mode Failure)

CMS 在并发清除阶段必须预留足够的老年代空间给用户线程分配新对象(晋升的对象)——因为此时 GC 还没有完成,老年代还没有被清理。

如果并发清除的速度跟不上对象晋升的速度(老年代快速被填满),就会发生 Concurrent Mode Failure(并发模式失败):CMS 来不及并发清理,紧急启动 Serial Old 收集器进行全堆单线程 Full GC!这会导致比正常 CMS 更长的 STW 停顿(有时高达数十秒),是生产环境最严重的 GC 事故之一。

预防措施:

  • -XX:CMSInitiatingOccupancyFraction=75:老年代占用率达到 75% 时就触发 CMS(默认 92%,太晚了)
  • -XX:+UseCMSInitiatingOccupancyOnly:禁止 JVM 自动调整触发阈值,始终使用指定比例

缺陷 3:内存碎片化

CMS 使用标记-清除算法,不移动对象,回收后老年代内存会越来越碎片化。随着时间推移:

  • 大对象分配失败(找不到连续大块空闲内存),触发 Full GC
  • 即使空闲空间总量足够,碎片化也会导致反复 Full GC

缓解措施:

  • -XX:+UseCMSCompactAtFullCollection:Full GC 时进行内存整理(但整理需要 STW,停顿时间增加)
  • -XX:CMSFullGCsBeforeCompaction=N:每隔 N 次 Full GC 后,进行一次内存整理

这些参数只是缓解,无法根治——碎片化是标记-清除算法的本质,换算法才是根本解决。

4.5 CMS 的典型 GC 日志解读

# JDK 8 格式(-XX:+PrintGCDetails)
[GC (CMS Initial Mark) [1 CMS-initial-mark: 512000K(1024000K)] ← 初始标记:老年代 512MB/1GB
  514000K(2048000K), 0.0089670 secs]                           ← 全堆 514MB/2GB,耗时 8.9ms(STW)

[CMS-concurrent-mark-start]                                    ← 并发标记开始
[CMS-concurrent-mark: 0.582/0.582 secs]                       ← 并发标记耗时 582ms(无 STW)

[CMS-concurrent-preclean-start]                                ← 预清理(并发)
[CMS-concurrent-preclean: 0.024/0.024 secs]

[GC (CMS Final Remark)                                         ← 重新标记(STW)
  [YG occupancy: 251936 K (1024000 K)]
  [Rescan (parallel) , 0.1598220 secs]                        ← 并行重扫,159ms(STW)
  [weak refs processing, 0.0000543 secs]
  [class unloading, 0.0044898 secs]
  [1 CMS-remark: 512000K(1024000K)] 763936K(2048000K), 0.1711230 secs]

[CMS-concurrent-sweep-start]                                   ← 并发清除
[CMS-concurrent-sweep: 0.218/0.218 secs]                      ← 并发清除 218ms(无 STW)

[CMS-concurrent-reset-start]                                   ← 重置内部数据结构
[CMS-concurrent-reset: 0.014/0.014 secs]

关键数据:本次 CMS 总耗时约 1 秒(含并发阶段),STW 时间:初始标记 8.9ms + 重新标记 171ms = 约 180ms。这就是 CMS 降低停顿的核心价值——将原本可能数秒的 Full GC STW,压缩到约 180ms。


第 5 章 三代收集器的工程权衡总结

5.1 收集器组合策略

场景推荐组合原因
客户端应用、小堆(< 512MB)Serial + Serial Old简单,单核性能好,停顿短(堆小)
批处理/吞吐量优先(Hadoop/Spark)Parallel Scavenge + Parallel Old最大化 CPU 利用率,自适应调节
交互式应用、延迟敏感(JDK 8 服务端)ParNew + CMS并发标记减少老年代停顿
JDK 9+ 服务端(大多数场景)G1综合 Parallel 的吞吐量 + CMS 的低延迟

5.2 演进脉络:每一代都在解决上一代的问题

Serial(1 线程,长 STW)
    → Parallel(N 线程,STW 时间缩短 N 倍,但仍有 STW)
        → CMS(并发标记,STW 极短,但碎片化 + 并发失败)
            → G1(Region 化,可预测停顿,解决碎片问题,下一篇详述)
                → ZGC/Shenandoah(并发转移,亚毫秒停顿)

每一代收集器都是在前一代的痛点上做出新的取舍,没有任何一个收集器能做到”完全没有代价”——有的是 STW 时间长,有的是 CPU 竞争,有的是内存碎片,有的是复杂的调优。

理解这条演进脉络,比单独记忆每个收集器的特点更有价值——当你需要在 G1 和 ZGC 之间做选型时,理解它们分别解决了什么问题、引入了什么新代价,才能做出有依据的判断。


第 6 章 总结

Serial/Serial Old:单线程,全程 STW,代码最简单。适合小堆和单核环境,依然是容器场景的合理选项。

Parallel Scavenge/Parallel Old:多线程并行 STW GC,吞吐量最优。JDK 8 服务端默认选项,适合 CPU 密集、批处理类型的应用。自适应调节策略减少手动调参工作量。

CMS:史上首个并发 GC,初始标记 + 重新标记 STW(各几毫秒到数百毫秒),并发标记 + 并发清除与用户线程同时进行。首次将老年代 GC 停顿压缩到亚秒级,开创低延迟 GC 时代。代价:CPU 竞争、浮动垃圾、内存碎片化、Concurrent Mode Failure 风险。JDK 9 废弃,JDK 14 移除。

下一篇 07 G1 收集器——Region 化内存与混合回收 将深入 G1——它继承了 CMS 的并发标记思想,但通过 Region 化的内存布局从根本上解决了碎片化问题,并引入了停顿预测模型,在低延迟和高吞吐量之间找到了更好的平衡点,成为 JDK 9 起的默认 GC。


参考文献

  1. 周志明, 《深入理解 Java 虚拟机(第三版)》, 第 3.5 章:经典垃圾收集器
  2. Jon Masamitsu, “Concurrent Mark Sweep Collector Enhancements”, Sun Microsystems Blog, 2005
  3. 美团技术博客, “Java中9种常见的CMS GC问题分析与解决”, 2020
  4. OpenJDK Wiki, “CMS Collector”, wiki.openjdk.org
  5. JEP 291: Deprecate the Concurrent Mark Sweep (CMS) Garbage Collector (JDK 9)
  6. JEP 363: Remove the Concurrent Mark Sweep (CMS) Garbage Collector (JDK 14)

思考题

  1. CMS(Concurrent Mark Sweep)使用’初始标记→并发标记→重新标记→并发清除’四个阶段。并发标记阶段 GC 线程和应用线程同时运行,可能导致’浮动垃圾’(Floating Garbage)。浮动垃圾的产生机制是什么?CMS 的 -XX:CMSInitiatingOccupancyFraction 为什么默认设置为 68%(JDK 6)或 92%(JDK 8)而非更高?
  2. Parallel Scavenge 收集器关注的是吞吐量(= 应用时间 / (应用时间 + GC 时间)),而 CMS 关注的是停顿时间。在一个既需要高吞吐量(批处理 ETL)又需要低延迟(偶尔的 HTTP 请求处理)的混合型应用中,你会选择哪种收集器?能否通过 JVM 参数让 Parallel Scavenge 兼顾延迟?
  3. CMS 在并发清除阶段如果应用线程分配内存过快,导致老年代空间不足,会触发’Concurrent Mode Failure’,降级为 Serial Old 进行 Full GC。这个 Full GC 的停顿时间可能是秒级的。除了增大堆内存,你有哪些手段来避免 Concurrent Mode Failure?