必须承认的是, 在低停顿收集器领域, CMS 是一个不够成熟的产品, 毕竟它推出的时间太早, 在架构设计上就输给了它的后辈们, jdk 14 甚至直接废弃了这个曾经影响一个时代的收集器先驱; 但是在 jdk 1.8 依旧是生产主力的今天, CMS 在江湖上仍然有它的一席之地, 只要我们还在用 G1 尚未臻至完美的 jdk 1.8, CMS 就是一个永远绕不开的技术话题;

Concurrent Mark Sweep 收集器, 随着 jdk 1.5 发布, 是 hotspot 推出的第一个低停顿收集器, 是低停顿收集器的开山鼻祖;

总体设计

兼容难题

早期 hotspot 的开发者为了统一垃圾收集器的开发规范, 设计了分代收集器开发框架, 定义了严格的继承体系, 早期的 Serial, Serial Old, ParNew 等皆是在此框架下开发, 所以它们可以灵活自由组合, 享受统一框架的红利;
CMS 的开发者, 也想遵循这套体系, 从而可以与其它收集器拉通标准, 但是很快他们就发现这次有些不一样, 之前的分代收集器框架, 设计的时候没有考虑过低停顿的问题, 通常都是一条龙流程:

  • 准备为新对象分配内存, 但 eden 区满了;
  • young gc 激活, 试图将部分大龄对象向年老代转移 (或者遇到大对象需要直接在年老代分配);
  • 紧接着发现担保失败, 年老代空间也不够分配了;
  • 于是 full gc 激活, 一把收回全部可用空间;

在 CMS 之前的 collector 都是 stw 的, 所以即便堆空间消耗殆尽了也不必担心; 但 CMS 想要低停顿, 就必须尽可能与用户线程并发执行, 那用户线程就不可避免制造新的垃圾, 若是堆空间耗尽了才触发 gc, 要怎么允许用户一边制造新垃圾, 一边又不至于 OOM?

思来忖去, 最后得出的结论是: 原有的分代收集器架构根本无法适用于低停顿收集器领域, 必须在逻辑上有所调整; 为此, CMS 的开发者硬是逼出了两个新概念: foreground collector 与 background collector:

  • foreground collector: 用于兼容分代收集器框架, 以获取与其他收集器灵活组合的好处, 它的实现其实就是最普通的 mark-sweep / mark-sweep-compact, foreground collector 与已有的那些 Serial, Parallel 一样, 不能与用户线程并发, 因为它面对的是堆空间内存不够分配, 需要镇场子的兜底场景;
  • background collector: 是 CMS 的精髓所在, 实现低停顿的关键部件; 网上各种讲 CMS 收集过程原理的文章, 通常都是指的 background collector; 在上文已经提及, 要做到收集线程与用户线程并发, 就不能等待堆空间消耗殆尽了才启动收集, 必须预留一部分空间给用户线程以应付可能产生的新垃圾; 那么当给定了这个预留空间的具体比例后, background collector 便可以安排一个定时任务, 间断性扫描监测当前的堆空间使用情况, 一旦达到设定阈值便启动一轮新的收集;

主干逻辑

所以根据上一小节的描述, CMS 的主干逻辑差不多是下图这个样子了:

CMS 主干逻辑
CMS 主干逻辑

网上很多文章将 foreground collector 与非 full gc 的 mark-sweep 收集器等同对待, 并将带压缩的 mark-sweep-compact 与 foreground / background collector 看做是同一个级别的不同概念; 之前我在看这些文章时, 一直对这样的设计感到很困惑, 总觉得缺乏架构的简洁与统一, 现在这张图终于治好了我的强迫症;
关于 background collector 的定时任务, 下面小节会详细说明其原理, 这一节需要对图中 “是否需要 full gc” 这个判断点详细展开说明一下;
图中的那个判断, 对应了 hotspot 代码里的如下逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
bool should_compact = false;
bool should_start_over = false;
decide_foreground_collection_type(clear_all_soft_refs, &should_compact, &should_start_over);
...
if (should_compact) {
...
// mark-sweep-compact
do_compaction_work(clear_all_soft_refs);
...
} else {
// mark-sweep
do_mark_sweep_work(clear_all_soft_refs, first_state,
should_start_over);
}

可以看到, 关键在于 decide_foreground_collection_type 这个方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void CMSCollector::decide_foreground_collection_type(bool clear_all_soft_refs, bool* should_compact,
bool* should_start_over) {
...
// 判断是否压缩的逻辑
*should_compact = UseCMSCompactAtFullCollection && // 是否允许 full gc 压缩
((_full_gcs_since_conc_gc >= CMSFullGCsBeforeCompaction) || // foreground collector 执行次数
GCCause::is_user_requested_gc(gch->gc_cause()) || // 有没有用户主动触发 gc
gch->incremental_collection_will_fail(true)); // 增量 GC 是否可能会失败
*should_start_over = false;
if (clear_all_soft_refs && !*should_compact) { // 是否清理所有 SoftReference
if (CMSCompactWhenClearAllSoftRefs) {
*should_compact = true;
} else {
if (_collectorState > FinalMarking) {
_collectorState = Resetting; // skip to reset to start new cycle
reset(false);
*should_start_over = true;
}
}
}
}

从代码中可以看到, 总共有四种情况会导致 CMS 判断需要 full gc 做 mark-sweep-compact, 对整个堆的所有代进行回收:

  1. 开启了 UseCMSCompactAtFullCollection 选项并且 foreground collector 执行次数达到设定的 CMSFullGCsBeforeCompaction 值;
  2. 用户主动触发 gc, 如调用 System.gc(), 或者 jmap -dump:live / jmap -histo:live 命令;
  3. 空间分配担保判断不能冒险 (当前年老代最大可用空间小于历次晋升到年老代的对象的平均大小之和);
  4. young gc 已经失败, 如空间分配担保失败 promotion failed;
  5. 开启了 CMSCompactWhenClearAllSoftRefs 选项, 表示要清理所有软引用;

低停顿的关键原理

收集过程

阶段 释义 类型
CMS-initial-mark 初始标记 stw
CMS-concurrent-mark 并发标记 concurrent
CMS-concurrent-preclean 预清理 concurrent
CMS-concurrent-abortable-preclean 可中断预清理 concurrent
CMS-final-remark 重新标记 stw
CMS-concurrent-sweep 并发清理 concurrent
CMS-concurrent-reset 并发重置 concurrent

初始标记

初始标记以 stop-the-world 模式快速扫描并标记 gc root 能关联到的对象, 停顿时间极短;

并发标记

从初始标记的对象开始, 对堆中对象进行可达性分析, 递归扫描整个堆里的对象图找出要回收的对象, 这个阶段耗时较长, 需要与用户程序并发执行;
实现 gc 线程和用户线程并发执行的算法是三色标记法:

  • 黑色:表示根对象, 或者该对象与它引用的对象都已经被扫描过了;
  • 灰色:该对象本身已经被标记, 但是它引用的对象还没有扫描完;
  • 白色:未被扫描的对象, 如果扫描完所有对象之后, 最终为白色的为不可达对象, 也就是垃圾对象;

但是原始的三色标记法有两个问题:

  • 漏标问题:
    三色标记法的漏标问题
    三色标记法的漏标问题
  • 浮动垃圾问题:
    浮动垃圾问题
    浮动垃圾问题

CMS 的解决方案:

  • 写屏障: 在并发标记过程中, 如果一个白色对象被一个黑色对象引用时, 会将黑色对象重新标记为灰色, 从而让垃圾收集器可以在重新标记阶段重新扫描;
    使用增量更新算法, 在并发标记阶段时如果一个白色对象被一个黑色对象引用, 会将黑色对象重新标记为灰色, 让垃圾收集器在重新标记阶段重新扫描;

重新标记

相关 gc 日志

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
2020-11-14T17:45:06.250+0800: 4.134: [GC (CMS Initial Mark) [1 CMS-initial-mark: 0K(1756416K)] 190272K(2063104K), 0.0550579 secs] [Times: user=0.18 sys=0.00, real=0.05 secs] 
2020-11-14T17:45:06.305+0800: 4.189: Total time for which application threads were stopped: 0.0553667 seconds, Stopping threads took: 0.0000412 seconds

2020-11-14T17:45:06.305+0800: 4.189: [CMS-concurrent-mark-start]
2020-11-14T17:45:06.320+0800: 4.203: [CMS-concurrent-mark: 0.014/0.014 secs] [Times: user=0.05 sys=0.00, real=0.02 secs]

2020-11-14T17:45:06.320+0800: 4.203: [CMS-concurrent-preclean-start]
2020-11-14T17:45:06.324+0800: 4.208: [CMS-concurrent-preclean: 0.004/0.004 secs] [Times: user=0.01 sys=0.00, real=0.00 secs]
2020-11-14T17:45:06.325+0800: 4.208: Total time for which application threads were stopped: 0.0009297 seconds, Stopping threads took: 0.0000444 seconds

2020-11-14T17:45:06.325+0800: 4.209: [CMS-concurrent-abortable-preclean-start]
CMS: abort preclean due to time 2020-11-14T17:45:11.592+0800: 9.475: [CMS-concurrent-abortable-preclean: 4.211/5.267 secs] [Times: user=10.30 sys=0.27, real=5.27 secs]
2020-11-14T17:45:11.592+0800: 9.476: Total time for which application threads were stopped: 0.0001745 seconds, Stopping threads took: 0.0000389 seconds

2020-11-14T17:45:11.592+0800: 9.476: [GC (CMS Final Remark) [YG occupancy: 173321 K (306688 K)]2020-11-14T17:45:11.592+0800: 9.476: [Rescan (parallel) , 0.0380948 secs]2020-11-14T17:45:11.630+0800: 9.514: [weak refs processing, 0.0001539 secs]2020-11-14T17:45:11.630+0800: 9.514: [class unloading, 0.0082249 secs]2020-11-14T17:45:11.639+0800: 9.522: [scrub symbol table, 0.0051294 secs]2020-11-14T17:45:11.644+0800: 9.528: [scrub string table, 0.0010024 secs][1 CMS-remark: 19239K(1756416K)] 192561K(2063104K), 0.0549428 secs] [Times: user=0.17 sys=0.00, real=0.05 secs]

2020-11-14T17:45:11.647+0800: 9.531: [CMS-concurrent-sweep-start]
2020-11-14T17:45:11.651+0800: 9.535: [CMS-concurrent-sweep: 0.004/0.004 secs] [Times: user=0.01 sys=0.00, real=0.01 secs]

2020-11-14T17:45:11.651+0800: 9.535: [CMS-concurrent-reset-start]
2020-11-14T17:45:11.668+0800: 9.552: [CMS-concurrent-reset: 0.015/0.017 secs] [Times: user=0.05 sys=0.02, real=0.01 secs]

调参选项

CMS 的调参选项以多而复杂著称, 总计 70+ 个选项可以调优, 所以说想要用好 CMS, 必须对其运行原理, 核心设计思路有比较深刻的理解, 才能掌握这些繁多的选项, 因地制宜地调优;

下面将介绍常用的 CMS 选项:
基本选项:

1
2
3
4
5
6
7
8
# 开启 ParNew + CMS + Serial Old 的组合, 其中 Serial Old 作为 CMS 出现 Concurrent Mode Failure 后的备用收集器
-XX:+UseConcMarkSweepGC

# 设置 CMS 并行工作的线程数, 默认是 (cpu 数量 + 3) / 4
-XX:ConcGCThreads=2

# 允许 CMS 清理永久代 (元数据区), 默认不开启
-XX:+CMSClassUnloadingEnabled

CMS 触发条件相关选项:

1
2
3
4
# 触发 CMS 的年老代空间占用百分比阈值, 默认仅第一次使用, 后续动态调整
-XX:CMSInitiatingOccupancyFraction=92
# 每次都使用 CMSInitiatingOccupancyFraction 指定的阈值作为触发条件, 而不是仅第一次使用
-XX:+UseCMSInitiatingOccupancyOnly

initial-mark 相关选项:

1
2
# 允许并行 initial-mark
-XX:+CMSParallelInitialMarkEnabled

abortable-preclean 相关选项:

1
2
3
4
5
6
7
8
# 触发 abortable-preclean 的条件, 默认是 eden 区内存占用超过 2 Mb
-XX:CMSScheduleRemarkEdenSizeThreshold=2m
# abortable-preclean 阶段的最长执行时长, 默认 5 s, 与 CMSMaxAbortablePrecleanLoops 共同决定何时主动退出
-XX:CMSMaxAbortablePrecleanTime=5000
# 控制 abortable-preclean 阶段的扫描次数, 默认 0, 即不退出, 与 CMSMaxAbortablePrecleanTime 共同决定何时主动退出
-XX:+CMSMaxAbortablePrecleanLoops
# abortable-preclean 的中断条件, 默认是 eden 区内存占用到达 50%
-XX:CMSScheduleRemarkEdenPenetration=50

remark 相关选项:

1
2
3
4
# 在 CMS remark 阶段前执行一次 ygc, 以减少年老代对年轻代的引用, 降低 remark 时的开销
-XX:+CMSScavengeBeforeRemark
# 允许并行 final-remark
-XX:+CMSParallelRemarkEnabled

full gc compact 相关选项:

1
2
3
4
# 允许在碎片整理 (jdk 1.8 已经被 deprecated)
-XX:+UseCMSCompactAtFullCollection
# 上一次 CMS gc 执行过后, 再执行多少次 full gc 才会做压缩 (jdk 1.8 已经被 deprecated)
-XX:CMSFullGCsBeforeCompaction=2

历史演进

增加后台收集启动的阈值

取消 mark-sweep 收集

Normally CMS reverts to the SerialOld GC when it needs to do a full GC. There is a mode in CMS to instead use the “foreground collector”. This is a single threaded stop-the-world mode which completes an ongoing concurrent CMS collection (a normal “background” collection in CMS).

Java Performance, The Definitive Guide 曾提到:

Here, CMS started a young collection and assumed that there was enough free space to hold all the promoted objects (otherwise, it would have declared a concurrent mode failure).
That assumption proved incorrect: CMS couldn’t promote the objects because the old generation was fragmented (or, much less likely, because the amount of memory to be promoted was bigger than CMS expected).

参考资料