概述
前文《垃圾收集器全景:Serial/Parallel/CMS/G1》揭示了 CMS 的定位——它是第一款真正支持与应用线程并发执行的老年代收集器,目标是“最小化回收停顿时间”。然而这一目标是通过“不移动对象”换来的——CMS 使用标记-清除算法,避免了标记-整理的长 STW,却引入了碎片化和浮动垃圾两大副产品。更早的《Java 垃圾收集算法》篇拆解了标记-清除算法的碎片化原理,本文正是要在 CMS 的具体实现中,亲眼见证碎片化如何一步步导致 Concurrent Mode Failure,最终降级为 Serial Old 单线程 Full GC 的工程悲剧。
CMS(Concurrent Mark Sweep)是一款面向互联网服务端应用、以缩短回收停顿为核心目标的老年代并发收集器。它基于并发标记-清除算法,通过增量更新(Incremental Update)写屏障在并发标记期间修正引用变更,并利用**空闲列表(Free List)**管理不连续的空闲内存。其核心设计原则是:用“不移动对象”换取“极短的回收停顿”,将最繁重的标记与清除工作与应用线程并发执行,只在关键的同步点进行短暂的 STW。CMS 在 JDK 1.4 引入,JDK 5 中成熟,长期是低延迟应用的主流选择,直至 JDK 14 被正式移除。
CMS 的设计哲学可凝练为三点,它们贯穿本文所有算法模块:
- 用并发换停顿(Concurrency for Pause) :将标记和清除两个最耗时的阶段从 STW 中剥离,交给后台并发线程完成。应用线程几乎不受影响,初始标记和重新标记的 STW 合计通常仅几十毫秒,远远低于 Parallel Old 的秒级停顿。
- 用空间换正确性(Space for Correctness) :预留部分老年代空间(
CMSInitiatingOccupancyFraction)给并发期间新分配的对象和浮动垃圾,避免空间耗尽。同时,写屏障额外消耗 CPU,以空间和吞吐量换取并发标记的正确性。 - 容忍碎片(Fragmentation Tolerance) :接受标记-清除带来的内存碎片,利用空闲列表和后续压缩参数(
UseCMSCompactAtFullCollection)进行事后补救,而不在并发清除时移动对象。这种容忍是 CMS 最大胆也最致命的选择。
CMS 核心机制与代价一览:
CMS 机制 设计目的 伴生代价 并发标记-清除 避免长时间 STW 标记-清除导致内存碎片,空闲列表管理复杂 增量更新写屏障 修复并发标记的漏标 每次引用赋值都要检查,增加应用线程 CPU 开销;Remark 阶段需重新扫描灰化节点,STW 时间变长 预留空间(92%) 吸收浮动垃圾和并发期间分配 老年代实际可用率降低,大堆下预留空间浪费增大 空闲列表(FreeList) 管理碎片化内存 无法满足大对象连续空间需求,可能触发 Full GC 降级 Serial Old Full GC 兜底保障,确保空间耗尽时能完成回收 单线程标记-整理,停顿随堆大小线性增长,秒级甚至数十秒,完全违背 CMS 低延迟初衷 这五项设计选择,使 CMS 在低延迟 GC 领域开创了先河,但也埋下了碎片化和停顿不可预测的隐患——这正是 G1 要系统性解决的核心问题。
“为什么 CMS 号称低延迟,线上却经常出现数秒的 Full GC?三色标记法在并发标记中如何漏标——增量更新写屏障又是如何修复的?什么是浮动垃圾——为什么 CMS 默认老年代使用率达到 92% 才触发?碎片化到底是如何产生的——为什么 CMS 有总空闲内存却无法分配大对象?Concurrent Mode Failure 的降级过程是怎样的——为什么一次 Serial Old Full GC 能让 CMS 之前的努力付诸东流?”——这些问题的答案,藏在 CMS 的 concurrentMarkSweepGeneration.cpp 源码、三色标记的灰/白/黑节点状态转换、增量更新写屏障的 mark_black_to_gray 逻辑、以及空闲列表 Free List 的碎片化管理中。本文将从一次 CMS GC 的 (initial-mark) 日志行开始,贯穿并发标记的三色算法、重新标记的 STW 根源、并发清除的碎片化积累,直至 Concurrent Mode Failure 的降级终点,完整揭示 CMS 的辉煌与无奈。
核心要点
- CMS 四阶段:初始标记 STW(1-10ms)→ 并发标记(不 STW,三色标记+增量更新)→ 重新标记 STW(几十-几百ms)→ 并发清除(不 STW,空闲列表回收)
- 三色标记法:白色(未访问/垃圾)、灰色(已访问子节点未扫描)、黑色(全部扫描)
- 增量更新写屏障:黑色→白色引用变更时,将黑色重新标记为灰色重新扫描
- 浮动垃圾:并发标记后新产生的垃圾本轮无法回收,
CMSInitiatingOccupancyFraction(默认 92%)预留空间 - 碎片化:标记-清除不移动对象,空闲列表碎片化,大对象分配失败
- Concurrent Mode Failure:老年代被填满 → 降级为 Serial Old 单线程 Full GC → STW 骤增至秒级
文章组织架构图
flowchart TD
A["第一章: 全景序章<br/>CMS 完整生命周期流程"] --> B["第二章: CMS 四阶段流程<br/>初始标记 STW → 并发标记 → 重新标记 STW → 并发清除"]
B --> C["第三章: 三色标记法与增量更新<br/>灰/白/黑状态转换 + 写屏障漏标修复"]
C --> D["第四章: 浮动垃圾与触发阈值<br/>产生机制 + CMSInitiatingOccupancyFraction 的预留策略"]
D --> E["第五章: 碎片化防治<br/>空闲列表碎片 + UseCMSCompactAtFullCollection 压缩"]
E --> F["第六章: Concurrent Mode Failure<br/>降级为 Serial Old Full GC 的完整链路"]
F --> G["第七章: CMS 调优与 GC 日志分析<br/>参数组合、日志解读、何时升级到 G1"]
classDef process fill:#f1f5f9,stroke:#334155,color:#1e293b;
class A,B,C,D,E,F,G process;
分层说明: 第一章建立 CMS 从触发到结束的宏观认知;第二章拆解四阶段的 STW 分布与执行细节;第三章是全文核心——三色标记法在并发标记中的应用与增量更新漏标修复;第四、五章揭示浮动垃圾和碎片化两大副产品及其防治策略;第六章呈现 CMS 的最终悲歌——Concurrent Mode Failure 的降级过程;第七章回归工程调优与从 CMS 到 G1 的迁移建议。关键结论:CMS 是一场用“不移动对象”换取“低停顿”的豪赌——它通过标记-清除算法避免了整理的长 STW,却埋下了碎片化和浮动垃圾的隐患。当老年代剩余空间不足时,这场豪赌以 Concurrent Mode Failure 宣告失败,降级为 Serial Old 单线程 Full GC,之前所有的并发努力付诸东流。理解 CMS 的辉煌与无奈,才能深刻理解 G1 为什么要用 Region 级标记-复制彻底终结碎片化时代。
第一章 全景序章:CMS 垃圾收集完整生命周期流程
CMS 的回收行为可抽象为一条闭环链路:从对象分配开始,经历年轻代晋升、老年代阈值触发、并发标记与清除,直至空间归还或灾难性降级。下示流程图覆盖了该链路中的所有关键决策节点与执行阶段,各节点均对应特定的数据结构和算法机制。理解此全景图是后续深入各模块的前提。
flowchart TD
Start(["应用线程分配对象"]) --> EdenAlloc["对象分配在 Eden 区<br/>优先使用 TLAB"]
EdenAlloc --> EdenFill{"Eden 空间不足?"}
EdenFill -- "否" --> Return1(["快速分配完成"])
EdenFill -- "是" --> YoungGC["触发 ParNew Young GC<br/>STW,多线程复制回收"]
YoungGC --> CheckOld{"老年代使用率<br/>>= CMSInitiatingOccupancyFraction?"}
CheckOld -- "是" --> InitialMark["借道执行初始标记<br/>(initial-mark) STW<br/>标记 GC Roots 直接可达对象"]
CheckOld -- "否" --> PromoteOnly["仅晋升存活对象到老年代"]
InitialMark --> ConcurMark["启动并发标记<br/>CMS-concurrent-mark<br/>三色标记遍历对象图<br/>增量更新写屏障修复漏标"]
PromoteOnly --> OldAccumulate["老年代占用率上升"]
OldAccumulate --> CheckOld
ConcurMark --> Preclean["并发预清理<br/>CMS-concurrent-preclean<br/>处理脏卡,缩减 Remark 工作量"]
Preclean --> Remark["重新标记 Remark STW<br/>CMS Final Remark<br/>修正并发期引用变更<br/>处理新 GC Roots + 脏卡 + 灰化节点"]
Remark --> Sweep["并发清除<br/>CMS-concurrent-sweep<br/>扫描未标记对象<br/>空间回收到空闲列表 FreeList"]
Sweep --> SweepDone{"并发清除期间<br/>老年代剩余空间充足?"}
SweepDone -- "是" --> Reset["CMS-concurrent-reset<br/>重置标记位图,周期结束"]
SweepDone -- "否" --> CMF["Concurrent Mode Failure<br/>中止 CMS 并发周期"]
ConcurMark --> MarkFail{"并发标记期间<br/>老年代被填满?"}
MarkFail -- "是" --> CMF
MarkFail -- "否" --> Preclean
CMF --> Fallback["降级为 Serial Old Full GC<br/>单线程标记-整理<br/>STW 时间骤增至秒级"]
Fallback --> Return2(["空间强制回收,碎片整理"])
Reset --> Return3(["CMS 周期完成,等待下次触发"])
classDef startEnd fill:#fef3c7,stroke:#d97706,color:#92400e;
classDef decision fill:#ede9fe,stroke:#8b5cf6,color:#4c1d95;
classDef process fill:#f1f5f9,stroke:#334155,color:#1e293b;
class Start,Return1,Return2,Return3 startEnd;
class EdenFill,CheckOld,SweepDone,MarkFail decision;
class EdenAlloc,YoungGC,InitialMark,PromoteOnly,ConcurMark,OldAccumulate,Preclean,Remark,Sweep,Reset,CMF,Fallback process;
a) 主旨概括: 该图展示了 CMS 从对象分配触发 Young GC、老年代阈值检测启动并发周期、四阶段执行、到可能遭遇 Concurrent Mode Failure 降级的完整闭环链路。
b) 逐元素分解:
- Eden 分配:新对象在年轻代 Eden 区的 TLAB 中通过指针碰撞分配,高效无锁。
- Young GC 与借道:ParNew Young GC 时若老年代达到阈值,复用了 STW 完成初始标记,日志中显示
(initial-mark)。 - 并发标记:与应用线程并发执行三色标记,增量更新写屏障记录黑色节点的引用修改,防止漏标。
- 预清理:可选的并发阶段,尽量在 Remark 前处理脏卡,缩短 Remark STW。
- 重新标记:必须 STW,修正所有并发期间的引用变化,是 CMS 停顿的主要可控部分。
- 并发清除:与应用线程并发,将白色对象空间加入 FreeList,不移动存活对象。
- 空间竞速:并发标记和清除期间,老年代可能被应用线程的分配填满,若预留空间耗尽则触发 Concurrent Mode Failure。
- 降级 Full GC:CMS 中止,启用 Serial Old 单线程标记-整理,停顿时间从毫秒级跃升至秒级甚至数十秒。
c) 设计原理映射: CMS 的全生命周期本质上是“预留空间”与“并发回收速度”之间的竞速。CMSInitiatingOccupancyFraction 决定了起跑线,ConcGCThreads 和写屏障开销决定了回收速度,应用分配速率决定了消耗速度。一旦消耗速度超过回收速度,预留空间被击穿,低停顿的承诺即被单线程 Full GC 彻底摧毁。
d) 工程联系与关键结论: 生产环境中使用 CMS 的核心任务,就是保证上图右侧的“并发清除成功”路径占绝对主导,避免落入左下角的“Concurrent Mode Failure”分支。这要求合理设置触发阈值、预留足够老年代空间、优化代码降低分配速率,并做好监控预警。
1.1 分配阶段:TLAB 与对象分流
对象分配是 GC 的起点。在 CMS 中,分配路径取决于对象大小和所在区域。
普通对象分配进入年轻代的 Eden 区。为降低多线程分配时的竞争,每个 Java 线程在 Eden 区内部预先申请一块线程本地分配缓冲(Thread Local Allocation Buffer, TLAB)。线程在 TLAB 内部使用指针碰撞(Bump-the-Pointer)进行无锁分配;仅当 TLAB 耗尽时,才需向堆全局空闲列表申请新的 TLAB 空间。该机制将高频率的小对象分配开销从全局同步降至局部操作。TLAB 的大小可通过 -XX:TLABSize 指定,JVM 同时支持自适应调整(-XX:+ResizeTLAB),根据线程历史分配速率动态缩放。
大对象直接分配在老年代进行。当一个对象的大小超过某个阈值(由 -XX:PretenureSizeThreshold 指定,默认为 0 表示所有对象先尝试在 Eden 分配)时,它会被直接分配到老年代,跳过年轻代。这样做是为了避免在 Young GC 时复制大对象带来的高昂开销。大对象在老年代中通过空闲列表(FreeList)查找合适大小的空闲块进行分配。如果找不到足够大的连续空间,可能提前触发一次 Full GC 来整理碎片。
1.2 Young GC(ParNew):并行复制与分代晋升
当 Eden 区空间不足以容纳新的对象分配请求时,JVM 触发 ParNew Young GC。这是一次 STW 暂停,所有应用线程被挂起,由 -XX:ParallelGCThreads 指定数量的 GC 工作线程并行执行回收。ParNew 是 CMS 的默认年轻代收集器,采用标记-复制算法。
根枚举与复制:GC 线程首先从一组 GC Roots(线程栈帧、JNI 句柄、类静态变量、同步监视器等)出发,扫描直接可达对象。随后,将这些存活对象从 Eden 区和 From-Survivor 区复制到 To-Survivor 区或老年代。
对象年龄增长与晋升:每经历一次 Young GC 且存活的对象,其对象头中的 GC 年龄字段(_age)递增 1。该字段占 4 位,故最大年龄为 15(由 -XX:MaxTenuringThreshold 设定,默认为 15)。晋升至老年代的条件包括两条路径:
- 固定年龄阈值:GC 年龄达到
MaxTenuringThreshold。 - 动态年龄计算:若 Survivor 空间中某一年龄及以上的所有对象大小总和超过 Survivor 空间的 50%(由
-XX:TargetSurvivorRatio控制),则大于等于该年龄的对象直接晋升。此机制避免 Survivor 空间被逐渐填满。
晋升对象复制到老年代的空闲空间,原 Eden 和 From-Survivor 区被整体清空并归还给空闲列表,用于下一轮分配。
跨代引用与卡表:Young GC 在扫描 GC Roots 之外,还需要扫描老年代中指向年轻代的引用。为了不扫描整个老年代,CMS 使用**卡表(Card Table)**技术。老年代空间被划分为 512 字节的“卡页”(Card),当老年代对象修改了指向年轻代的引用时,写屏障会将对应的卡页标记为“脏”(dirty)。Young GC 时只需扫描这些脏卡,即可找到所有跨代引用。ParNew 的日志中通常包含 [ParNew: ...] 字样,清晰显示了年轻代的回收情况。
1.3 老年代积累与 CMS 触发
随着 Young GC 不断晋升对象,以及大对象直接分配,老年代中的存活数据持续累积,使用率上升。CMS 的触发由两个关键参数控制:
-XX:CMSInitiatingOccupancyFraction(默认 92):当老年代使用率达到该阈值时,CMS 并发周期被触发。剩余的 8% 空间作为“安全垫”,用于吸收并发回收期间的浮动垃圾和新对象分配。-XX:+UseCMSInitiatingOccupancyOnly(默认 false):若开启,则仅使用固定阈值触发;若关闭(默认),JVM 还会根据历史统计动态调整触发时机,在预测到即将需要 GC 时提前启动。
一旦老年代使用率触及阈值,CMS 启动并发回收周期。
1.4 并发标记周期:四阶段协同与增量更新
并发标记周期是整个 CMS 回收的核心,负责标定老年代中的存活对象。它由四个阶段(外加可选的预清理)组成,交替使用 STW 与并发执行:
阶段一:初始标记(Initial Mark, STW)。这是第一次 STW 停顿,仅标记从 GC Roots 直接可达的对象。由于扫描深度极浅,停顿通常仅 1-10 毫秒。该阶段常与一次 Young GC 合并执行(借道),复用 ParNew 的 STW 时间。合并执行时,GC 日志中 Young GC 行末尾会出现 (initial-mark) 标识。
阶段二:并发标记(Concurrent Mark, 并发)。多个并发标记线程(数量由 -XX:ConcGCThreads 控制)从初始标记获得的灰色根集合出发,递归地应用三色标记法遍历整个老年代对象图。这是整个并发周期耗时最长的阶段。在此期间,应用线程仍在运行,可能修改对象引用。CMS 通过增量更新(Incremental Update)写屏障应对:当黑色对象修改引用指向白色对象时,写屏障将黑色对象重新标记为灰色,并记录其所在卡页为脏,确保 Remark 阶段能修正。
可选阶段:预清理(Preclean, 并发)。在并发标记之后、重新标记之前,CMS 会尝试一个并发的预清理阶段。它处理并发标记期间积累的脏卡,尝试在 Remark 的 STW 到来之前尽可能多地完成引用变更的修正,从而缩减 Remark 的工作量。预清理是“可中止”的(Abortable Preclean),如果 JVM 发现老年代空间即将耗尽,会主动中止预清理,迅速进入重新标记或直接触发 Full GC。
阶段三:重新标记(Remark, STW)。这是第二次 STW 停顿,也是 CMS 停顿中最显著、最需要调优的部分。它必须暂停所有应用线程,原子地完成三项工作:
- 重新扫描 GC Roots(包括并发标记期间新创建的线程栈);
- 遍历所有在并发标记期间被标记为脏的卡页;
- 处理增量更新写屏障记录的灰化节点的任务队列,重新扫描它们以找到新增引用。
CMSParRemarkTask::work() 并行执行上述任务,其 STW 时间通常在数十到数百毫秒,但在写密集场景下可能更长。-XX:+CMSScavengeBeforeRemark 参数可在 Remark 前强制进行一次 Young GC,减少年轻代对老年代的引用数量,从而缩短 Remark 时间。
阶段四:并发清除(Concurrent Sweep, 并发)。与应用线程并发执行,遍历老年代内存块,检查标记位图。未被标记的对象即被视为垃圾,其占用的内存块被回收到空闲列表(FreeList)中。此阶段不移动任何存活对象,因此会产生内存碎片。
收尾:重置(Reset, 并发)。清除完成后,CMS 执行 CMS-concurrent-reset,重置内部的标记位图等数据结构,为下一次并发周期做好准备。
1.5 空闲列表管理与碎片化
并发清除阶段回收的空间通过**空闲列表(FreeList)**进行管理。FreeList 是一个按大小分桶(Tiny、Small、Medium、Large 等)的链表结构,记录着老年代中所有可用的内存块。当需要分配对象时,CMS 从对应大小的桶中查找合适的空闲块,必要时进行分割;当相邻的两个空闲块在物理上连续时,清除过程会将其合并为一个更大的块。
但由于存活对象未被移动,它们像“钉子”一样散落在老年代各处,使得即使总空闲空间足够,也可能无法找到一块足够大的连续空间来满足大对象的分配请求。这种碎片化是 CMS 标记-清除算法的必然代价。
1.6 Concurrent Mode Failure 与 Serial Old 兜底
并发标记和并发清除期间,应用线程仍在分配新对象并晋升到老年代。如果分配速率超过了预留空间(1 - CMSInitiatingOccupancyFraction)所能承受的上限,或者并发清除的速度跟不上分配速度,老年代将在并发周期内被填满。此时,JVM 别无选择,只能:
- 中止 CMS 并发周期:设置
_foregroundGCIsActive标志,通知后台collect_in_background线程停止工作。 - 降级为 Serial Old Full GC:由单线程执行一次完整的标记-整理压缩回收。它将所有存活对象移动到老年代的一端,形成一块连续的大空闲区,彻底消除碎片。
Serial Old 是单线程的,其停顿时间与老年代大小和存活对象数量成正比。在大堆场景下,这次 Full GC 可能导致应用停顿数秒甚至数十秒,完全违背了 CMS 低延迟的初衷。在 GC 日志中,这会表现为 (concurrent mode failure) 标志,紧接着是 [Full GC (Allocation Failure) ...] 的长时间停顿。
1.7 碎片积累与压缩参数的事后补救
即使 CMS 周期成功完成,碎片化也会随着时间推移逐渐累积。当碎片化严重到即使老年代有大量总空闲空间,也无法找到足够大的连续块来满足大对象分配或对象晋升时,同样会触发一次 Full GC。为了应对这种情况,CMS 提供了两个事后压缩参数:
-XX:+UseCMSCompactAtFullCollection(默认 true):当发生 Full GC 时,在标记-清除之后执行一次压缩整理。-XX:CMSFullGCsBeforeCompaction(默认 0):控制经历多少次不压缩的 Full GC 后,才执行一次压缩。设为 0 表示每次 Full GC 都压缩。
这些参数可以在碎片化发生时进行补救,但无法从根本上预防碎片化的产生。
1.8 闭环总结
上述流程构成 CMS 的完整生命周期闭环:分配—晋升—触发—标记—清除—(碎片化→降级)。并发标记通过三色算法与增量更新写屏障保证正确性,并发清除通过空闲列表管理碎片化内存,而预留空间则是并发回收与应用分配之间的缓冲。一旦缓冲被击穿,低延迟的承诺随之崩塌,CMS 只能依赖 Serial Old 进行最古老也最可靠的兜底回收。流程图中的每一个分支节点,均可映射到具体的 JVM 参数和 HotSpot 源码模块,后续各章将逐一对这些核心机制展开深度分析。
第二章 CMS 四阶段流程:初始标记 STW → 并发标记 → 重新标记 STW → 并发清除
本章聚焦 CMS 并发周期内部的四个核心阶段,明确各自的 STW 分布与职责,为后续深入标记算法和碎片化机制打下基础。
2.1 阶段全景与状态机调度
CMS 的整个并发周期由 CMSCollector::collect_in_background 方法统一调度,通过一个状态机将四个阶段串联起来。简化后的状态定义在 concurrentMarkSweepGeneration.cpp 中:
enum CMSPhase {
InitialMarking, // 初始标记
Marking, // 并发标记
Precleaning, // 预清理(可选的中间步骤)
FinalMarking, // 重新标记
Sweeping, // 并发清除
Resizing, // 堆大小调整
Idling // 空闲
};
主循环大致如下:
void CMSCollector::collect_in_background() {
while (!_foregroundGCIsActive) {
switch (_phase) {
case InitialMarking:
initialMark(); // STW 1
break;
case Marking:
markFromRoots(); // 并发标记
break;
case Precleaning:
preclean(); // 可选预清理
break;
case FinalMarking:
finalMark(); // STW 2,即重新标记
break;
case Sweeping:
sweep(); // 并发清除
break;
// ...
}
}
}
CMS 四阶段流程图
flowchart TD
START(["老年代使用率达到阈值"]) --> IM["初始标记 Initial Mark<br/>STW,标记 GC Roots 直接可达对象<br/>常借道 Young GC"]
IM --> CM["并发标记 Concurrent Mark<br/>与应用线程并发,三色标记遍历对象图<br/>增量更新写屏障修复漏标"]
CM --> PC["预清理 Preclean<br/>可选,处理并发标记期间的卡表脏卡<br/>为重新标记缩减工作量"]
PC --> RM["重新标记 Remark<br/>STW,修正并发标记期间引用变更<br/>处理新 GC Roots + 卡表脏卡 + 灰化节点"]
RM --> CS["并发清除 Concurrent Sweep<br/>与应用线程并发,扫描未标记对象<br/>空间回收到空闲列表 Free List"]
CS --> END(["完成,等待下一次触发"])
IM -.->|"日志标识"| L1["(initial-mark)"]
CM -.->|"日志标识"| L2["CMS-concurrent-mark"]
RM -.->|"日志标识"| L3["(CMS Final Remark)"]
CS -.->|"日志标识"| L4["CMS-concurrent-sweep"]
classDef startEnd fill:#fef3c7,stroke:#d97706,color:#92400e;
classDef process fill:#f1f5f9,stroke:#334155,color:#1e293b;
classDef logLabel fill:#dbeafe,stroke:#2563eb,color:#1e3a8a;
class START,END startEnd;
class IM,CM,PC,RM,CS process;
class L1,L2,L3,L4 logLabel;
a) 主旨概括: 该流程图描绘了 CMS 从触发到结束的四个核心阶段以及可选的预清理步骤,清晰标注了各阶段的并发特性、STW 点以及 GC 日志中的标识关键字。
b) 逐元素分解:
- 初始标记 (Initial Mark):第一个 STW 点,仅标记从 GC Roots 直接可达的对象。为缩短停顿,常与 Young GC 的 ParNew 合并进行,日志中可见
(initial-mark)。 - 并发标记 (Concurrent Mark):与业务线程并发,从初始标记的灰色根开始,使用三色标记法遍历对象图。期间应用线程可能修改引用,由写屏障记录变更。
- 预清理 (Preclean):一个可选的并发阶段,试图在重新标记前尽可能多地处理并发标记阶段积攒的脏卡和引用变更,以减少 Remark 的 STW 工作量。
- 重新标记 (Remark):第二个 STW 点,修正并发标记期间的所有引用变化,包括新线程的栈引用、卡表脏卡以及增量更新写屏障记录的灰化节点。
CMSScavengeBeforeRemark参数可在此前触发一次 Young GC 来缩减根集合。 - 并发清除 (Concurrent Sweep):与业务线程并发,遍历整个老年代,将未标记(白色)的对象空间通过
FreeChunk构建空闲列表,供后续分配。不移动任何存活对象。
c) 设计原理映射: CMS 把标记-清除算法改造成并发执行,但标记的“根快照”和引用变化修正无法避免 STW。初始标记耗时极短,因为它只扫描根直接引用;重新标记必须暂停所有线程,以确保对象图的完整性。并发标记和并发清除期间,应用线程可持续运行,这正是 CMS “低停顿”的核心。
d) 工程联系与关键结论: 了解各阶段的 STW 分布,是解读 CMS GC 日志的基础。初始标记停顿通常 < 10ms,重新标记可能数十至数百毫秒,若出现秒级 Full GC,则是降级后的 Serial Old 所致,而非 CMS 本身。 调优的核心目标之一就是降低 Remark 的扫描量,并避免 Concurrent Mode Failure。
2.2 初始标记与 Young GC 的借道
初始标记的实现位于 CMSCollector::initialMark,最终调用 CMSParInitialMarkTask。为了减少独立 STW 的次数,JVM 设计了“借道”机制:当 Young GC 发生时,如果老年代使用率已经达到或超过触发阈值,则 ParNew 在 STW 期间顺便完成初始标记。
// 简化的逻辑:在 ParNew 的回收过程中检查是否需要启动 CMS
if (need_to_start_cms) {
CMSParInitialMarkTask tsk(this);
// 在 Young GC 的 STW 内执行
tsk.work();
}
对应的 GC 日志会合并输出:
[GC (Allocation Failure) [ParNew: 128000K->1024K(128000K), 0.0123456 secs]
(initial-mark) 256000K->129000K(512000K), 0.0234567 secs]
2.3 并发标记中的三色遍历
并发标记由 CMSCollector::markFromRoots 启动,创建 ConcurrentMarkSweepThread 并在其中执行 CMSConcurrentMarkingTask。该任务从初始标记得到的灰色根集合出发,递归地应用三色标记算法(详见第三章)。为了不长时间独占 CPU,并发标记采用“步进”方式,每次处理一定数量对象后让出 CPU,通过 CMSConcMarkingTask 的 do_scan_and_mark 方法循环执行。
2.4 重新标记的 STW 根源
重新标记阶段由 CMSCollector::finalMark 调度,内部并行任务为 CMSParRemarkTask::work。该任务必须处理三类引用变更:
- 新 GC Roots:并发标记期间新创建的线程栈、JNI 句柄等。
- 卡表脏卡:老年代中可能被修改的跨代引用。Young GC 会重置卡表,但 CMS 并发标记期间应用线程对老年代的写操作会使卡表变脏。
- 增量更新灰化节点:写屏障将黑色节点重新标记为灰色,这些灰色节点需在 Remark 中重新扫描其子节点,确保新增引用的白色对象被正确标记。
CMSParRemarkTask::work 的核心逻辑:
void CMSParRemarkTask::work() {
// 1. 重新扫描根集合
ref_processor()->process_discovered_references();
// 2. 处理脏卡(卡表扫描)
_collector->drain_card_table(_bit_map);
// 3. 处理灰化节点的任务队列
_mark_stack.drain();
}
-XX:+CMSScavengeBeforeRemark 可以在 Remark 前强制进行一次 Young GC,将年轻代中的不可达对象提前回收,从而减少那些因跨代引用而带入 Remark 扫描的老年代对象数量。
2.5 并发清除与空闲列表
并发清除的入口是 CMSCollector::sweep,关键实现位于 cms_sweep.cpp 的 SweepMeth::process_chunk 等函数中。清除过程遍历老年代的每个 Chunk,检查标记位图(_markBitMap)。未被标记的对象即视为垃圾,其内存块被加到 FreeList 上。FreeList 是 CMS 管理空闲内存的核心数据结构,按大小分桶(例如 Tiny、Small、Medium、Large),分配时从对应桶中查找合适的块,必要时进行分割或合并相邻空闲块。然而由于无对象移动,即使相邻空闲块可以合并,整体布局依然是碎片化的。
第三章 三色标记法与增量更新:灰/白/黑状态转换 + 写屏障漏标修复
并发标记是 CMS 并发回收正确性的基石,也是其技术复杂度的最高体现。本章深入三色标记法的抽象模型,剖析应用线程并发修改引用时的漏标风险,以及 CMS 独有的增量更新写屏障如何修复这一风险。
3.1 三色抽象与并发标记的挑战
三色标记法将对象图中的节点划分为三种颜色,这是描述标记算法进展状态的经典抽象:
- 白色(White):尚未被标记算法访问到的节点。在并发标记结束时,仍为白色的对象即是不可达的垃圾,将在并发清除阶段被回收。
- 灰色(Gray):已被访问,但其直接引用的子节点尚未全部被扫描处理。灰色节点是标记过程的“工作边界”,存在于标记栈(
_markStack)中。 - 黑色(Black):已被访问,且其所有直接引用的子节点也已完成扫描。黑色节点不会再被重复处理,标记线程将不再关注它们。
在 STW 场景下,标记线程独占 CPU,应用线程冻结,三色标记法可以按部就班地保证正确性。但在 CMS 的并发标记阶段,应用线程仍在并发运行,可以自由地修改对象引用。这打破了串行场景下的三色不变式,导致两种典型错误:
- 漏标(正确性灾难):一个黑色节点修改引用,指向一个白色节点(该白色节点可能本应从其他灰色节点可达,但因引用变更而只能从该黑色节点到达)。由于黑色节点已完成扫描不会被重新处理,该白色节点及其子图将永远漏标,被并发清除错误地回收,导致程序崩溃。
- 浮动垃圾(效率损失):一个原本存活的对象在并发标记期间变为垃圾。由于它可能已被标记为灰色或黑色,本轮 GC 无法识别它为垃圾,只能留待下一次 GC 回收。浮动垃圾不会导致错误,但会占用预留空间。
CMS 的设计核心就是解决漏标问题,同时容忍浮动垃圾。
3.2 增量更新写屏障的修复逻辑
CMS 采用增量更新(Incremental Update)策略来应对漏标。其核心思想是:关注“谁修改了引用”。当应用线程执行引用赋值(如 a.field = b)时,写屏障(Write Barrier)被触发。它检查赋值者 a 是否是一个已标记完成的黑色节点,并且新引用的目标 b 是一个尚未被标记的白色节点。如果条件成立,写屏障执行修复动作:将黑色节点 a 重新标记为灰色,使其重新进入待扫描队列,并发标记线程将重新扫描 a 的所有子节点,从而发现并标记白色节点 b。
写屏障的伪代码逻辑如下,对应 cmsOopClosures.hpp 中的实现:
// 后置写屏障:在引用赋值之后执行
void post_write_barrier(oop a, oop b) {
if (is_black(a) && is_white(b)) {
// 将黑色节点 a 改回灰色
mark_black_to_gray(a); // a -> gray
// 将 a 所在的卡页标记为脏,并加入重新标记任务队列
dirty_card(a);
add_to_remark_stack(a);
}
}
同时,写屏障会标记 a 所在卡页为脏卡,记录到 _modUnionTable(一种卡表变体)。在重新标记(Remark)阶段,GC 线程会扫描这些脏卡,找到所有灰化节点并重新扫描其引用。
增量更新写屏障原理图
flowchart TB
subgraph ConcurrentMark ["并发标记开始"]
B1["黑色节点 B"] -->|"原引用"| W1["白色节点 W1"]
G1["灰色节点 G"] -->|"待扫描"| C["子节点"]
end
subgraph AppModify ["应用线程修改引用"]
direction LR
MODIFY["应用线程执行:<br/>B.field = W2"] --> BARRIER{"写屏障检查:<br/>B 是黑色 && W2 是白色?"}
BARRIER -- "是" --> REPAIR["将 B 改为灰色<br/>mark_black_to_gray"]
end
subgraph MarkRepair ["标记修复"]
REPAIR --> RESCAN["重新扫描 B 的子节点"]
RESCAN --> MARK_W2["发现并标记 W2 为灰色<br/>W2 不再漏标"]
end
classDef nodeStyle fill:#f1f5f9,stroke:#334155,color:#1e293b;
classDef sub1 fill:#f5f3ff,stroke:#c4b5fd,color:#4c1d95;
classDef sub2 fill:#eff6ff,stroke:#93c5fd,color:#1e3a8a;
classDef sub3 fill:#fef3c7,stroke:#fcd34d,color:#92400e;
class B1,W1,G1,C,MODIFY,BARRIER,REPAIR,RESCAN,MARK_W2 nodeStyle;
class ConcurrentMark sub1;
class AppModify sub2;
class MarkRepair sub3;
a) 主旨概括: 该图展示了当黑色节点 B 修改引用指向白色节点 W2 时,CMS 的增量更新写屏障如何将 B 灰化,并通过重新扫描确保 W2 被正确标记,防止漏标。
b) 逐元素分解:
- 黑色节点 B:已经完成所有子节点扫描的对象,正常情况下不应再被访问。
- 白色节点 W2:尚未被标记到的对象,若被黑色节点直接引用则可能漏标。
- 写屏障检查:每次引用赋值都会触发,但不一定执行修复,只有黑色→白色的情况才需要介入。
- 灰化操作:将 B 重新标记为灰色,使其重新进入待扫描队列。Remark 阶段会消耗 CPU 扫描这些灰化节点。
c) 设计原理映射: 增量更新保留了并发标记的正确性,同时将部分扫描工作推迟到 Remark STW 中完成。写屏障带来的 CPU 开销是不可避免的代价。CMS 之所以最终被淘汰,部分原因就在于增量更新在大量引用修改的场景下会导致 Remark 停顿难以控制。
d) 工程联系与关键结论: 增量更新写屏障是现代 JVM 并发标记正确性的基石,但它的开销与引用更新频率正相关。对于写密集型的应用,CMS 的增量更新可能导致 Remark 阶段扫描大量灰化节点,STW 时间显著增长。这也是为什么 G1 转向 SATB(记录旧值)的原因之一。
3.3 增量更新与 G1 SATB 的对比
CMS 的增量更新关注“谁修改了引用”(修改方),将修改者灰化。G1 的 SATB(Snapshot-At-The-Beginning)则关注“被引用方的旧值”,在写前屏障中将旧值记录到 SATB 队列,保证并发标记能感知到初始快照中存活的所有对象。SATB 不会产生漏标,但会制造更多浮动垃圾(旧值对象即使已在中间变为垃圾,仍会被保留)。两者取舍不同:CMS 更倾向于回收更多垃圾,但 Remark 负担重;SATB 牺牲部分即时回收效率,换取 Remark 极短的 STW。
| 对比维度 | CMS 增量更新 | G1 SATB |
|---|---|---|
| 屏障类型 | 后置写屏障(post-write barrier) | 前置写屏障(pre-write barrier) |
| 记录内容 | 修改者(黑色节点),重新灰化 | 被引用者的旧值(记录快照) |
| 漏标风险 | 理论上极低,但需 Remark 扫描灰化节点 | 数学上绝对不漏标 |
| 浮动垃圾 | 少 | 多 |
| Remark 停顿 | 较重,需重新扫描灰化节点及其子图 | 极短,仅处理 SATB 队列 |
| CPU 开销 | 较轻(简单检查与卡标记) | 较重(需读取旧值并入队) |
3.4 写屏障的工程实现
在 HotSpot 中,CMS 的写屏障由解释器和 JIT 编译器协同生成。以 putfield 字节码为例,JIT 编译后会插入一段汇编指令,完成卡表标记和增量更新检查。关键代码路径在 cmsOopClosures.hpp 的 MarkRefsIntoClosure::do_oop() 等方法中。为了减少开销,写屏障使用了批量处理优化:并不是每次引用赋值都立即执行灰化操作,而是先标记卡表为脏,后续由预清理或 Remark 阶段批量扫描。
卡表(Card Table)是一个字节数组,每个字节对应 512 字节的堆内存(一张“卡”)。当老年代中某个卡页内的对象引用发生修改时,写屏障将对应字节置为“脏”。Remark 阶段只需扫描脏卡,而不必扫描整个老年代。增量更新在此基础上增加了对黑色→白色修改的特殊处理,将修改者加入额外的任务队列。
第四章 浮动垃圾与触发阈值:产生机制 + CMSInitiatingOccupancyFraction 的预留策略
浮动垃圾是 CMS 并发标记的必然产物,也是其需要预留老年代空间的根本原因。本章分析浮动垃圾的产生机制、CMSInitiatingOccupancyFraction 的预留策略,以及预留不足时的调优方向。
4.1 浮动垃圾的定义与必然性
浮动垃圾(Floating Garbage)是指在并发标记阶段开始之后,由应用线程新产生或由存活转变为不可达的对象。这些对象在并发标记线程的视角下要么从未被标记(新对象,通常直接分配为黑色以避免漏标),要么已经被标记为存活(中途死亡的灰色/黑色对象)。本轮 GC 无法识别它们是垃圾,只能等待下一次 CMS 周期。
浮动垃圾的产生时序可以概括为:① 并发标记开始时,对象 A 存活;② 并发标记扫描到 A,将其标记为灰色或黑色;③ 应用线程使 A 变为不可达(成为垃圾);④ 并发标记结束,A 仍处于标记位图中,被视为存活;⑤ 并发清除阶段不回收 A,A 成为浮动垃圾。
浮动垃圾产生时序图
flowchart LR
T1["时间点 T1: CMS 并发标记开始"] --> A["对象 A 存活"]
T2["时间点 T2: 并发标记扫描 A"] --> A2["A 被标记为灰色/黑色"]
T3["时间点 T3: 应用线程使 A 变为垃圾"] --> A3["A 不可达,但标记位仍为黑/灰"]
T4["时间点 T4: 并发标记结束"] --> A4["A 仍被视作存活"]
T5["时间点 T5: 并发清除"] --> A5["A 不被回收,成为浮动垃圾"]
A5 --> NEXT["等待下一次 CMS GC 回收"]
classDef processNode fill:#f1f5f9,stroke:#334155,color:#1e293b;
class T1,A,T2,A2,T3,A3,T4,A4,T5,A5,NEXT processNode;
a) 主旨概括: 该时序图直观描述了浮动垃圾从产生到遗留的完整过程,说明了并发标记阶段中的死亡对象无法在本轮回收的根本原因。
b) 逐元素分解:
- 对象 A:在并发标记开始后由存活性变为垃圾。
- 标记状态:由于并发标记线程已经处理过 A 并标记为黑色,不再重新扫描。
- 清除阶段:仅清除标记位图中白色的对象,黑色 A 不会被回收,成为浮动垃圾。
c) 设计原理映射: 浮动垃圾本质上是并发标记“快照”滞后于应用状态的结果。CMS 通过预留老年代空间来吸收这部分无法回收的垃圾以及新分配的对象,保证并发清除期间不至于因无可用内存而失败。
d) 工程联系与关键结论: 浮动垃圾越多,需要的预留空间就越大,CMS 的有效利用率就越低。 若应用在并发标记期间产生大量垃圾或频繁分配大对象,8% 的默认预留可能不足,导致 Concurrent Mode Failure。这也是为何在写密集或大对象分配频繁的场景中,必须调低 CMSInitiatingOccupancyFraction。
4.2 CMSInitiatingOccupancyFraction 的预留策略
CMS 预留了一部分老年代空间,用于容纳浮动垃圾以及在并发清除阶段应用线程新分配的对象。这通过参数 -XX:CMSInitiatingOccupancyFraction 控制,默认值 92。当老年代使用率达到该阈值时,CMS 并发周期被触发,剩余的 8% 空间专门用于“浮动垃圾 + 并发期间新分配”。
为什么是 92%?这是一个经验值,基于以下考量:
- 在大多数应用中,并发标记和清除阶段的总耗时通常在 1~3 秒左右。
- 老年代分配速率(晋升+直接分配)通常在几十 MB/s 量级。
- 8% 的预留空间(例如 2GB 老年代中预留 160MB)可以吸收约 1~2 秒内的正常分配,同时容纳一定量的浮动垃圾。
如果应用分配速率更高,或并发周期更长,这 8% 就不够用了。此时需要调低阈值,如 70~80%,让 CMS 更早触发,预留更大安全垫。但代价是 CMS 周期更加频繁,CPU 消耗增加。开启 -XX:+UseCMSInitiatingOccupancyOnly 可以锁定固定阈值,避免 JVM 自适应调整带来的不确定性。
4.3 浮动垃圾的工程影响与调优
浮动垃圾的直接后果是老年代有效利用率的降低。假设老年代 10GB,阈值 92%,实际可用于“纯存活对象”的空间只有 9.2GB,剩余 0.8GB 必须留给浮动垃圾和并发期间分配。如果应用的存活对象接近 9.2GB,CMS 几乎会不间断地运行,CPU 被大量消耗在并发回收上,吞吐量下降。
调优方向:
- 降低阈值:增加安全垫,减少 Concurrent Mode Failure 风险。
- 增大堆:直接扩大老年代容量,提升绝对预留空间。
- 优化代码:减少在并发标记期间的分配和晋升,例如减少临时大对象、优化缓存策略。
第五章 碎片化防治:空闲列表碎片 + UseCMSCompactAtFullCollection 压缩
碎片化是 CMS 标记-清除算法的直接后果,也是其从巅峰走向衰落的根本原因。本章分析碎片化的产生机制、空闲列表的管理方式,以及 CMS 提供的事后压缩参数。
5.1 标记-清除与空闲列表的碎片必然性
CMS 并发清除阶段,将未标记对象的内存回收到空闲列表(FreeList)中。由于存活对象停留在原地不移动,回收的空闲块被存活对象隔开,形成大小不一、散布各处的碎片。FreeList 按大小分桶(如 _smallLabs、_mediumLabs、_largeLabs 等),分配时采用最佳适应或首次适应算法。相邻的空闲块在清除时会被合并,但仅限于物理相邻的空闲块,无法跨越存活对象。
当需要分配一个较大对象(如较大的数组)时,即使老年代总空闲内存足够,也可能因为找不到足够大的连续块而分配失败。此时 JVM 会先尝试扩展堆(若仍有预留空间)或触发一次 Full GC 来整理碎片。
碎片化与空闲列表示意图
flowchart TB
subgraph BeforeSweep ["清除前:标记-清除结果"]
direction TB
A["存活对象 A"] --> FREE1["空闲 2K"]
FREE1 --> B["存活对象 B"]
B --> FREE2["空闲 8K"]
FREE2 --> C["存活对象 C"]
C --> FREE3["空闲 4K"]
end
subgraph FreeListSub ["空闲列表"]
F1["2K"]
F2["8K"]
F3["4K"]
end
NEED["需要分配 10K 大对象"] -.->|"无 10K 连续块"| FAIL["分配失败 → Full GC"]
classDef nodeStyle fill:#f1f5f9,stroke:#334155,color:#1e293b;
classDef subStyle1 fill:#f5f3ff,stroke:#c4b5fd,color:#4c1d95;
classDef subStyle2 fill:#eff6ff,stroke:#93c5fd,color:#1e3a8a;
class A,FREE1,B,FREE2,C,FREE3,F1,F2,F3,NEED,FAIL nodeStyle;
class BeforeSweep subStyle1;
class FreeListSub subStyle2;
a) 主旨概括: 该示意图展示了 CMS 清除后虽然总空闲为 14K,但碎片化为三个小块,无法满足 10K 的连续内存需求,导致分配失败。
b) 逐元素分解:
- 存活对象 A、B、C:散落在堆中,不会被移动。
- 空闲块:夹在存活对象之间,大小不一。
- 空闲列表:记录这些空闲块,但无法提供大于最大空闲块的连续空间。
c) 设计原理映射: 碎片化是标记-清除算法的固有缺陷。CMS 不选择标记-整理,正是为了避免长 STW。空闲列表分配器(CompactibleFreeListSpace)虽然实现了高效的小块分配,但对连续大块的请求无能为力。
d) 工程联系与关键结论: 碎片化不仅是 CMS 的痛点,也是其最终被 G1 取代的直接原因。G1 通过 Region 级标记-复制,在回收过程中将存活对象移动到新的 Region,天然实现紧凑化,彻底消灭碎片。
5.2 CMS 的碎片化防治参数
CMS 提供了两个参数来在 Full GC 发生时缓解碎片:
-XX:+UseCMSCompactAtFullCollection(默认 true):当发生 Full GC(包括 Concurrent Mode Failure 降级导致的 Serial Old Full GC)时,执行一次标记-整理,移动对象消除碎片。-XX:CMSFullGCsBeforeCompaction(默认 0,即每次 Full GC 都压缩):控制经过多少次不压缩的 Full GC 后,才执行一次压缩。设为 0 可以最积极地整理碎片,但 Full GC 的停顿会更久;设置较大值可减少压缩开销,但碎片积累更快。
在实际调优中,通常保持 UseCMSCompactAtFullCollection 开启,并根据应用内存分配特征调整 CMSFullGCsBeforeCompaction。如果日志显示大对象分配频繁导致 Full GC,可将该值设为 0 以确保每次 Full GC 都能释放连续空间。
5.3 碎片化的工程影响
碎片化最直接的危害是大对象分配失败,触发 Full GC。在线上高并发服务中,偶尔的秒级 Full GC 足以造成接口超时、连接断开等严重问题。此外,碎片化还会导致:
- 分配效率下降:FreeList 查找合适块的时间变长。
- 堆内存利用率下降:总空闲足够但无法利用,造成“虚假满堆”。
- CMS 并发周期效率下降:碎片化使得并发清除需要处理更多更小的空闲块,增加了 FreeList 操作的复杂度。
第六章 Concurrent Mode Failure:降级为 Serial Old Full GC 的完整链路
Concurrent Mode Failure 是 CMS 最严重的故障模式,也是其“以空间换时间”策略的破产宣言。本章详细剖析其触发条件、降级过程、日志特征,以及避免措施。
6.1 触发条件:空间竞速的失败
Concurrent Mode Failure 发生在 CMS 并发回收期间(包括并发标记和并发清除阶段),老年代的剩余可用空间被应用线程的分配速度耗尽,无法继续完成并发回收。触发条件可以概括为两种竞速失败:
- 空间竞速:老年代在并发标记/清除期间被填满,分配新对象时发现无可用空间。
- 时间竞速:并发清除的速度追不上应用线程的分配速率,导致碎片化或空间耗尽。
具体场景包括:
- 并发标记期间发生 Young GC,大量对象晋升填满预留空间。
- 应用分配大量大对象直接进入老年代。
- 浮动垃圾远超预期,占用了大量预留空间。
- 并发清除阶段,分配速率持续高于清除速率。
Concurrent Mode Failure 降级链路图
flowchart TD
A["应用线程正常分配"] --> B["老年代使用率达到 CMSInitiatingOccupancyFraction"]
B --> C["CMS 并发标记开始"]
C --> D["并发标记期间应用继续分配"]
D --> E{"老年代剩余空间不足?"}
E -- "否" --> F["正常完成并发标记"]
F --> G["并发清除"]
G --> H{"清除期间空间不足?"}
H -- "否" --> I["回收成功"]
E -- "是" --> FAIL["Concurrent Mode Failure"]
H -- "是" --> FAIL
FAIL --> KILL["中止 CMS 并发周期"]
KILL --> SOLD["降级为 Serial Old 单线程 Full GC"]
SOLD --> STW["长时间 STW,日志出现 (concurrent mode failure) 和 Full GC"]
classDef normal fill:#f1f5f9,stroke:#334155,color:#1e293b;
classDef decision fill:#ede9fe,stroke:#8b5cf6,color:#4c1d95;
classDef result fill:#fef3c7,stroke:#d97706,color:#92400e;
class A,B,C,D,F,G,KILL,SOLD normal;
class E,H decision;
class I,FAIL,STW result;
a) 主旨概括: 该链路图描述了 CMS 从正常触发到因空间不足而失败降级的完整流程,突出展示了空间竞速导致的全暂停灾难。
b) 逐元素分解:
- 剩余空间不足检测:由
CMSCollector::check_for_foreground_work或分配路径中的expand_and_allocate等函数触发。 - 中止并发周期:
CMSCollector::abortable_preclean或collect_in_background中会检查_concurrent_stop标志。 - 降级 Full GC:实际上是
CMSCollector::collect方法在收到 foreground 请求后直接调用GenCollectedHeap::satisfy_failed_allocation,最终触发 Serial Old 的mark_sweep_phaseX等一系列标记-整理操作。
c) 设计原理映射: CMS 的并发设计依赖预留空间作为“安全垫”。一旦安全垫被消耗殆尽,低延迟承诺立即失效,转由最简单的单线程回收器兜底。这暴露了 CMS 不适合堆使用率长期高位运行的场景。
d) 工程联系与关键结论: 任何一次 Concurrent Mode Failure 都是 CMS 的“死刑宣告”,因为它带来的 Serial Old 停顿常常是秒级甚至数十秒,完全违背了 CMS 的设计初衷。 排查此类问题的第一步总是查看 GC 日志中的 (concurrent mode failure) 和后续的 [Full GC (Allocation Failure)…],然后评估老年代填满的原因。
6.2 降级过程源码分析
当分配失败发生时,JVM 的分配路径调用 GenCollectedHeap::satisfy_failed_allocation,设置 _foregroundGCIsActive 为 true。后台的 collect_in_background 线程检测到该标志后,立即中止当前的并发工作(设置 _concurrent_should_abort = true),然后前台线程执行一次 CMSCollector::collect(foreground 模式)。在该模式中,CMS 会跳过所有并发阶段,直接调用 SerialOldGC 进行单线程的标记-整理 Full GC。
整理过程包括:
- 标记所有存活对象:从 GC Roots 出发,单线程遍历对象图,标记存活对象。
- 计算目标地址:遍历老年代,计算每个存活对象压缩后的新地址。
- 移动对象与更新引用:将所有存活对象向老年代一端移动,更新所有指向这些对象的引用。
- 重置空闲列表:压缩后,老年代的一端是连续存活对象,另一端是连续空闲空间,空闲列表被重置为一块大连续块。
6.3 日志特征与排查
典型的 Concurrent Mode Failure 日志片段:
2024-06-03T10:24:01.234+0800: 10.123: [GC (CMS Initial Mark) ... ]
2024-06-03T10:24:02.456+0800: [CMS-concurrent-mark-start]
2024-06-03T10:24:03.789+0800: [CMS-concurrent-mark: 1.333/1.334 secs]
(concurrent mode failure)
2024-06-03T10:24:04.567+0800: [Full GC (Allocation Failure) [Serial Old: 240000K->230000K(249024K), 5.6789012 secs] ... ]
这个模式表明,并发标记尚未完成,老年代就被填满,直接跳过了预清理和重新标记,降级为 Serial Old Full GC。停顿时间 5.6 秒。
另一种模式是并发标记完成,但在并发清除期间失败:
[CMS-concurrent-mark: 1.234/1.235 secs]
[GC (CMS Final Remark) ... , 0.2345678 secs]
[CMS-concurrent-sweep-start]
(concurrent mode failure)
[Full GC (Allocation Failure) ... ]
这说明清除速度跟不上分配速度,清除中途空间耗尽。
6.4 调优避免措施
- 降低触发阈值:
-XX:CMSInitiatingOccupancyFraction=70,使 CMS 更早启动,预留更大安全垫。 - 增大老年代:
-Xms4g -Xmx4g直接扩大堆,但要留意硬件内存和 GC 频率平衡。 - 优化代码:减少大对象分配,排查内存泄漏,降低对象晋升速率(调整
-XX:MaxTenuringThreshold等)。 - 开启
CMSScavengeBeforeRemark:加速 Remark,缩短并发周期,减少并发期分配量。 - 增加并发线程数:若 CPU 有空闲,增大
ConcGCThreads加快标记和清除速度。 - 升级到 G1 或更高版本收集器:这是根本解决方案,G1 利用 Region 化和 SATB 避免了 CMS 的碎片化与 Concurrent Mode Failure 模式。
第七章 CMS 调优与 GC 日志分析:参数组合、日志解读、何时升级到 G1
本章回归工程实践,总结 CMS 核心参数的协同作用,完整解读 GC 日志,并给出从 CMS 迁移到 G1 的评估框架。
7.1 核心参数协同与日志联合分析
下表总结 CMS 关键参数及其联动关系:
| 参数 | 默认值 | 作用 | 调优思路 |
|---|---|---|---|
-XX:+UseConcMarkSweepGC | false | 启用 CMS 老年代收集器 | 搭配 ParNew 年轻代收集器 |
-XX:CMSInitiatingOccupancyFraction | 92 | 老年代占用阈值触发 CMS | 根据浮动垃圾和分配速率调低 |
-XX:+UseCMSInitiatingOccupancyOnly | false | 仅使用固定阈值,禁用自适应 | 建议设为 true,行为可预测 |
-XX:ConcGCThreads | (ParallelGCThreads+3)/4 | 并发 GC 线程数 | 根据 CPU 核数适当调整,避免抢占业务 |
-XX:+CMSScavengeBeforeRemark | false | Remark 前触发一次 Young GC | 开启可减少 Remark 停顿 |
-XX:+UseCMSCompactAtFullCollection | true | Full GC 时压缩老年代 | 通常保持开启 |
-XX:CMSFullGCsBeforeCompaction | 0 | 多少次 Full GC 后执行压缩 | 0 表示每次压缩;可适当增大减少压缩开销 |
-XX:+PrintGCDetails | false | 输出详细 GC 日志 | 必须开启,分析依据 |
CMS 调优参数组合图
flowchart LR
A["CMSInitiatingOccupancyFraction<br/>老年代触发阈值"] --> B["预留空间大小"]
C["CMSScavengeBeforeRemark<br/>Remark 前 Young GC"] --> D["减少 Remark 扫描量"]
E["UseCMSCompactAtFullCollection<br/>Full GC 时整理"] --> F["缓解碎片化"]
G["CMSFullGCsBeforeCompaction<br/>压缩频率控制"] --> F
H["ConcGCThreads<br/>并发线程数"] --> I["并发标记/清除吞吐"]
J["UseCMSInitiatingOccupancyOnly<br/>固定阈值"] --> A
B --> K{"避免 Concurrent Mode Failure"}
D --> K
F --> K
I --> K
classDef process fill:#f1f5f9,stroke:#334155,color:#1e293b;
classDef decision fill:#ede9fe,stroke:#8b5cf6,color:#4c1d95;
class A,B,C,D,E,F,G,H,I,J process;
class K decision;
a) 主旨概括: 该图展示了 CMS 调优参数间的协同关系,最终共同影响是否会发生 Concurrent Mode Failure。
b) 逐元素分解: 阈值决定安全垫大小;CMSScavengeBeforeRemark 通过缩减根集合间接降低 Remark STW 和碎片风险;压缩参数在 Full GC 不可避免时提供最后的碎片整理;并发线程数则影响并发回收的吞吐能力。
c) 设计原理映射: CMS 的可调参数本质上都是在“频率”、“停顿时间”和“空间安全垫”之间做权衡。没有一套参数能完全消除 CMS 的根本缺陷——碎片化和浮动垃圾。
d) 工程联系与关键结论: 生产调优 CMS 的起点永远是开启 PrintGCDetails 和 PrintGCDateStamps,从日志中量化各阶段耗时、碎片化征兆和 Concurrent Mode Failure 频率,再针对性地调整阈值和压缩策略。 若优化后仍无法满足延迟 SLO,就应启动向 G1 的迁移评估。
7.2 完整 GC 日志解读示例
一条典型的、没有失败的 CMS GC 日志序列(开启 -XX:+PrintGCDetails):
2024-06-03T10:24:01.234+0800: 10.123: [GC (Allocation Failure) [ParNew: 128000K->10240K(128000K), 0.0234567 secs]
(initial-mark) 256000K->138240K(512000K), 0.0456789 secs]
2024-06-03T10:24:02.456+0800: [CMS-concurrent-mark-start]
2024-06-03T10:24:03.789+0800: [CMS-concurrent-mark: 1.333/1.334 secs] [CMS-concurrent-preclean-start]
2024-06-03T10:24:03.890+0800: [CMS-concurrent-preclean: 0.101/0.102 secs]
2024-06-03T10:24:03.890+0800: [GC (CMS Final Remark) [YG occupancy: 80000K (128000K)],
(CMS Final Remark) [Rescan (parallel) 0.1234567 secs] [weak refs processing, 0.0234567 secs]
[class unloading, 0.0345678 secs] [scrub symbol table, 0.0123456 secs] [scrub string table, 0.0056789 secs]
[1 CMS-remark: 302000K(384000K)] 382000K->320000K(512000K), 0.2345678 secs]
2024-06-03T10:24:04.567+0800: [CMS-concurrent-sweep-start]
2024-06-03T10:24:05.234+0800: [CMS-concurrent-sweep: 0.667/0.667 secs]
2024-06-03T10:24:05.234+0800: [CMS-concurrent-reset-start]
2024-06-03T10:24:05.345+0800: [CMS-concurrent-reset: 0.111/0.111 secs]
从这个日志中,我们可以提取:
- 初始标记:与 ParNew 合并,总停顿约 45ms。
- 并发标记耗时 1.33s(CPU time 1.334s)。
- Remark 总停顿 0.234s,其中 parallel rescan 0.123s。
- 并发清除耗时 0.667s。 总并发周期约 3.7s,期间应用持续运行,停顿只有初始标记和 Remark 两次,合计不到 0.3s。这是一次理想的 CMS 周期。
7.3 从 CMS 到 G1:迁移评估与代际演进
随着业务增长和堆扩大,CMS 的痛点会逐渐放大:
- Remark 停顿在写密集场景下可能达到秒级。
- 碎片化导致的 Full GC 风险随堆龄增加而上升。
- 大堆(>8G)下,CMS 的并发周期越长,预留空间越难估算。
CMS 结构性缺陷与 G1 解决方案对比:
| CMS 缺陷 | 缺陷本质 | G1 技术方案 | 解决效果 |
|---|---|---|---|
| 碎片化 | 标记-清除导致老年代空间碎片,触发 Serial Old Full GC | Region 化堆布局 + 复制回收:存活对象复制到其他 Region,原 Region 整体归还 | 根除碎片化,避免因碎片导致的 Full GC |
| 漏标风险 | 增量更新依赖 post-write barrier 记录新值,Remark 需重新扫描灰化节点 | SATB 快照并发标记:pre-write barrier 记录旧值,数学上保证不漏标 | 杜绝漏标,代价是浮动垃圾稍多 |
| 停顿不可预测 | 仅当老年代濒临耗尽时触发,回收动作集中 | Mixed GC + 停顿预测模型:贪心选择高价值 Region,分多次 STW 回收 | 老年代回收可预测、可分摊 |
| 跨代引用扫描 | 全局卡表,Young GC 需扫描老年代脏卡 | RSet 三级粒度:每个 Region 独立 RSet,精确记录跨 Region 引用 | 扫描开销可控 |
| 大对象处理 | 直接在老年代分配,CMS 无法移动,Full GC 时整理代价高 | Humongous Region:大对象在连续 Region 组分配,Cleanup 阶段整体释放 | 避免移动大对象的开销 |
迁移评估清单:
- 业务是否能接受 G1 稍高的 CPU 开销(RSet 维护、SATB 写屏障)。
- 应用吞吐量是否会因 G1 的并发活动而下降(G1 在平衡停顿和吞吐时,有时吞吐量略低于 CMS)。
- 现有 JVM 参数是否便于迁移(G1 接管了许多参数,需清理旧 CMS 参数)。
- 测试环境复现负载,对比 CMS 与 G1 的 GC 行为,验证延迟 SLO。
- 逐步灰度上线,监控 GC 行为和 Full GC 频率。
G1 在设计哲学上是对 CMS 的全面反思:CMS 试图以“不移动对象”换取低停顿,却引发了碎片和停顿不可控的次生灾难;G1 则以“接受写屏障开销”换取“可预测的停顿和零碎片”,最终实现了延迟与吞吐量的更优平衡。 这标志着垃圾收集器从“避免整理”到“主动复制”的范式转移,也是 JDK 14 移除 CMS 的根本原因。
面试高频专题
1. CMS 的四阶段流程是什么?哪些阶段是 STW 的?初始标记和重新标记的 STW 有什么不同?
① 一句话回答:CMS 分为初始标记(STW)、并发标记、重新标记(STW)、并发清除,其中初始标记停顿极短仅标根直接引用,重新标记停顿较长需修正并发期引用变化。
② 详细解释:
CMS 的完整生命周期由 CMSCollector::collect_in_background 方法中的状态机驱动,核心四阶段如下:
- 初始标记(Initial Mark):STW,仅标记从 GC Roots(线程栈、JNI 句柄、静态变量等)直接可达的对象,将其压入
_markStack成为灰色。该阶段通常“借道” Young GC(ParNew)的 STW 时间完成,源码中通过CMSParInitialMarkTask::work()实现,它遍历所有 GC Roots 并调用MarkRefsIntoClosure将直接引用的对象标记为灰色。停顿时间通常在 1-10ms。 - 并发标记(Concurrent Mark):与应用线程并发,由
CMSConcMarkingTask驱动,从灰色标记栈出发,通过do_scan_and_mark()递归应用三色标记法遍历整个对象图。期间增量更新写屏障会记录引用修改。该阶段耗时最长(数百 ms 到数秒),但无 STW。 - 重新标记(Remark):STW,修正并发标记期间的引用变更。由
CMSParRemarkTask::work()并行处理三件事:① 重新扫描根集合;② 遍历卡表脏卡(drain_card_table)处理跨代引用变更;③ 处理增量更新写屏障灰化的节点。源码中CMSParRemarkTask内部包含Rescan、ProcessWeakRefs、ScrubSymbolTable等子阶段。停顿时间数十到数百 ms,是高并发修改场景下的主要瓶颈。 - 并发清除(Concurrent Sweep):与应用线程并发,由
SweepMeth::process_chunk等函数遍历堆内存块,将被标记为白色的对象空间通过FreeChunk加入空闲列表(FreeList)。不移动存活对象。该阶段结束后通过CMS-concurrent-reset重置位图等结构。
两者的 STW 本质区别:初始标记的扫描集极小(仅根直接引用),且对象图深度为1,时间常数级;重新标记需扫描并发期积累的全部变更,其工作量与并发期应用线程的引用更新频率成正比,是 CMS 停顿不可控的根源。
③ 多角度追问:
- 追问 1:如果应用在并发标记期间频繁更新引用,对 Remark 有何影响?→ 增量更新写屏障会将大量黑色节点重新灰化,
_modUnionTable脏卡区域变大,导致 Remark 的 Rescan 子阶段扫描量剧增,停顿可达数百毫秒甚至秒级。 - 追问 2:如何通过参数优化 Remark 停顿?→ 开启
-XX:+CMSScavengeBeforeRemark在 Remark 前强制一次 Young GC,清理年轻代垃圾,从而减少老年代中因跨代引用需扫描的对象数量,通常能缩短 20-50% 的 Remark 时间。 - 追问 3:初始标记能否独立发生而不借道 Young GC?→ 可以,当老年代触发阈值达到但 Young GC 尚未发生时,JVM 会发起一次独立的初始标记 STW,日志显示为
[GC (CMS Initial Mark) …]而不带 ParNew 行。但这会增加一次停顿,故而 JVM 倾向于合并。
④ 加分回答:
初始标记的借道机制在 GenCollectedHeap::do_collection 中实现:当 Young GC(DefNewGeneration::collect)检测到 CMSCollector::shouldConcurrentCollect() 返回 true 时,会在 Young GC 的 STW 内调用 CMSCollector::do_initial_mark()。这种“顺风车”优化是 CMS 低停顿设计的关键细节。Remark 的并行化通过 CMSParRemarkTask 的 work stealing 实现,多线程并发处理脏卡和灰化节点,线程数由 ParallelCMSThreads(通常等于 ParallelGCThreads)控制。
2. 什么是三色标记法?CMS 在并发标记阶段如何应用三色标记法?
① 一句话回答:三色标记法将对象分为白(未访问)、灰(已访问但子节点未扫描)、黑(子节点也扫描完毕)三种状态;CMS 在并发标记中从灰色根出发递归标记,通过 _markBitMap 和 _markStack 维护状态转换。
② 详细解释: 三色抽象是并发标记正确性的基础:
- 白色:未被
_markBitMap标记的对象,标记结束后仍为白色的是垃圾。 - 灰色:自身已被标记,但其引用字段尚未被扫描的对象,存在于
_markStack中。 - 黑色:自身及其所有引用字段都已被扫描,不会被重新处理。
CMS 在并发标记阶段的实现位于 CMSConcMarkingTask::do_scan_and_mark() 方法,基本循环为:
while (!_mark_stack->isEmpty()) {
oop obj = _mark_stack->pop(); // 取出灰色对象
// 遍历 obj 的所有引用字段
obj->oop_iterate(&_marking_closure);
// 将 obj 标记为黑色(在 _markBitMap 中保留标记位)
// 并将发现的白色对象压入 _markStack 变为灰色
}
其中 _marking_closure(如 MarkRefsIntoClosure)会检查每个引用字段指向的对象,若为白色则标记为灰色并压栈。当标记栈为空,且所有线程的工作队列均空时,并发标记完成。为了防止标记栈溢出,CMS 会使用堆上的 _overflow_list 作为后备。
③ 多角度追问:
- 追问 1:并发标记期间新创建的对象如何着色?→ 通常直接分配为黑色或灰色(在 TLAB 分配时标记位图预设),避免成为白色被误清,但它们可能成为浮动垃圾。
- 追问 2:为什么并发标记必须引入写屏障?→ 因为应用线程可能并发地将黑色节点的引用改为指向白色节点,破坏“黑色不应直接引用白色”的三色不变式,导致漏标。
- 追问 3:三色标记栈的内存占用如何控制?→ CMS 使用
CMSMarkStack结构,该栈有固定大小(默认 128K 条目),溢出时通过overflow_list将对象暂存到堆上,以链表形式组织,避免内存无限增长。
④ 加分回答:
三色标记法的效率受对象图拓扑影响很大。对于深度很大的对象图,标记栈可能频繁溢出,触发溢出处理逻辑(CMSCollector::par_push_on_overflow_list),这会增加并发标记的时间。标记线程通过 CMSConcMarkingTask::do_work_stealing() 实现负载均衡,空闲线程会从其他线程的本地任务队列中窃取灰色对象,提升多核利用率。JDK 8 中 CMS 的默认并发标记线程数为 (ParallelGCThreads + 3) / 4。
3. 什么是 CMS 的增量更新(Incremental Update)写屏障?它如何修复并发标记的漏标问题?
① 一句话回答:CMS 增量更新写屏障在黑色节点引用修改指向白色对象时,将黑色节点重新标记为灰色,使其被重新扫描,从而修复因并发修改导致的漏标。
② 详细解释: 漏标发生在以下序列:① 并发标记线程将对象 A 标记为黑色,并扫描完其子节点;② 应用线程修改 A 的引用指向白色对象 B;③ 并发标记线程不会再扫描 A,因此 B 及其子图永远漏标,会被错误清除。
CMS 的增量更新写屏障解决此问题。当应用线程执行 oop_store(obj, field, new_value) 时,HotSpot 的解释器或 JIT 编译代码会插入后置写屏障(Post-Write Barrier)。屏障伪代码对应 cmsOopClosures.hpp 中的 do_oop_work():
if (is_black(obj) && is_white(new_value)) {
mark_black_to_gray(obj); // 将 obj 重新变灰
_collector->add_to_remark_stack(obj); // 加入重新标记任务队列
}
同时,写屏障会标记对象所在卡页为脏(_modUnionTable),以便 Remark 阶段扫描。这样,重新标记期间会重新扫描 A,发现 B 并标记为存活,避免漏标。
③ 多角度追问:
- 追问 1:增量更新写屏障与 G1 的 SATB 写屏障有何根本区别?→ 增量更新记录修改方(谁改了引用),因此需要重新扫描修改者(灰化);SATB 记录被引用方(旧值),在并发标记前逻辑快照中存活的旧对象都不会漏标,Remark 只需处理 SATB 队列。前者 Remark 重,后者浮动垃圾多。
- 追问 2:写屏障对应用性能影响有多大?→ CMS 的写屏障是一种“检查型”屏障,大多数情况仅为判断,不执行额外操作,实测开销通常在 2%-5%。但如果大量黑色对象修改引用指向白色对象,则灰化和入队操作会增加开销。
- 追问 3:为什么 CMS 不使用 SATB?→ CMS 诞生于 JDK 1.4,当时 SATB 尚未成为主流;增量更新与 CMS 的卡表设计结合更直接,且 CMS 倾向于“即时回收”,尽量减少浮动垃圾,以弥补标记-清除碎片的劣势。
④ 加分回答:
CMS 的增量更新依赖于卡表脏化机制,并非精确记录每个引用变更,而是以卡页(512 字节)为粒度。在重新标记阶段,drain_card_table 会遍历所有脏卡,扫描其中的对象,这种粗粒度导致 Remark 工作量可能被放大。G1 的 SATB 通过 per-thread SATB 队列精确记录旧值,Remark 仅需消费队列,更为高效。
4. 什么是浮动垃圾?为什么 CMS 需要预留老年代空间?CMSInitiatingOccupancyFraction 默认值是多少?
① 一句话回答:浮动垃圾是并发标记期间变成垃圾但未被本轮 GC 识别的对象;CMS 预留老年代空间给浮动垃圾和并发期间新分配对象,预留阈值参数 CMSInitiatingOccupancyFraction 默认为 92。
② 详细解释: 浮动垃圾的产生时序:① 并发标记开始时,对象 A 存活并被标记为灰色或黑色;② 标记期间应用线程使 A 变为不可达;③ 并发标记结束,A 仍处于标记位图中,不被清除;④ 本次 GC 无法回收 A,只能等到下一次 CMS 周期。
为解决浮动垃圾及并发清除期间的新对象分配,CMS 必须确保在并发回收期间老年代仍有足够的空闲空间。JVM 通过 CMSInitiatingOccupancyFraction 控制 CMS 触发时机:当老年代使用率超过该值(默认 92%)时启动 CMS 并发周期。剩余 8% 空间用于吸收:
- 浮动垃圾(并发标记期间死亡的对象)
- 并发标记和并发清除期间新晋升到老年代的对象
- 应用线程在老年代直接分配的大对象(如超过
-XX:PretenureSizeThreshold的对象)
若空间预留不足,会导致 Concurrent Mode Failure。
③ 多角度追问:
- 追问 1:能否将阈值设为 100%?→ 不可,必须预留至少能容纳并发周期内最大分配量的空间,否则必然失败。即使 99% 也极度危险。
- 追问 2:浮动垃圾何时会被回收?→ 下一次 CMS GC 或(若空间耗尽)降级后的 Full GC 中回收。
- 追问 3:如何根据应用特征计算合理的
CMSInitiatingOccupancyFraction?→ 通过 GC 日志统计 CMS 周期持续时间 T 和老年代平均分配速率 R,所需预留空间 ≈ R×T + 浮动垃圾均值。再结合老年代总大小换算出百分比,通常 70-80 是安全区间。
④ 加分回答:
JVM 默认还会使用动态调整(未开启 UseCMSInitiatingOccupancyOnly 时),根据历史晋升速率预测下一次 CMS 启动时机。但在流量突增场景,自适应预测往往滞后,导致预留不足。生产环境强烈建议锁定此阈值。另外,-XX:CMSTriggerRatio 和 -XX:CMSTriggerPermRatio 在 JDK 8 已废弃,统一由 CMSInitiatingOccupancyFraction 控制。
5. CMS 的碎片化是如何产生的?为什么 CMS 不整理碎片?(与标记-清除算法的关系)
① 一句话回答:碎片化源于标记-清除算法不移动存活对象,空闲内存以小而不连续的块存在;CMS 不整理碎片是为了避免移动对象带来的长时间 STW。
② 详细解释:
CMS 并发清除阶段,SweepMeth 扫描堆块(Chunk),将未标记对象占用的空间通过 FreeChunk 归还到 FreeList。由于存活对象停留在原地,空闲块被存活对象隔开,形成碎片。FreeList 通过 Dictionary 结构按大小分桶(Small, Medium, Large, Humongous),分配时采用最佳适应或首次适应算法。
碎片化的直接后果是:即使总空闲内存足够,若无连续大块,大对象分配(如大数组)会触发失败。此时 JVM 会先尝试扩展堆(若仍有预留空间)或触发一次 Full GC(通常降级为 Serial Old),通过标记-整理移动对象来创建连续空间。
CMS 设计之初就选择放弃移动对象来换取低停顿。移动对象需要暂停所有应用线程,更新所有指向该对象的引用,停顿时间与存活对象数量成正比,违背 CMS “低延迟”的使命。
③ 多角度追问:
- 追问 1:CMS 的 FreeList 如何管理和合并空闲块?→ 清除时相邻的空闲块会通过
FreeChunk::linkAfter合并,但合并仅限于物理相邻的空闲块,无法跨越存活对象,因此碎片化无法消除。 - 追问 2:为什么碎片化会导致 Full GC 而不仅仅是年轻代 GC?→ 老年代的大对象分配是直接在老年代进行的,年轻代的 ParNew 不负责老年代内存整理,只有 Full GC 才会压缩老年代。
- 追问 3:G1 如何避免碎片化?→ G1 将堆划分成等大的 Region,回收时采用“复制”算法,将存活对象复制到新的 Region,原 Region 清空变为连续可用空间,天然紧凑无碎片。
④ 加分回答:
CMS 的碎片化是标记-清除算法的固有缺陷,本质上是一种“空间换时间”的权衡。UseCMSCompactAtFullCollection 参数在 Full GC 时引入一次 Serial Old 的单线程标记-整理,相当于在碎片化严重时进行一次“大扫除”。这种事后补救的方式是 CMS 最终被 G1 取代的核心原因之一。
6. 什么是 Concurrent Mode Failure?它的触发条件和降级过程是怎样的?
① 一句话回答:Concurrent Mode Failure 是 CMS 并发回收期间老年代被填满,JVM 中止 CMS 周期并降级为 Serial Old 单线程 Full GC 的严重故障。
② 详细解释: 触发条件有两种情况:
- 并发标记期间空间耗尽:应用线程分配 + 晋升的速率超过了预留空间,老年代在并发标记未完成时就满了。
- 并发清除期间分配失败:并发清除的速度追不上分配速率,或者浮动垃圾过多,导致分配时无可用内存。
降级过程在源码中的路径:
- 分配路径(
CollectedHeap::common_mem_allocate_noinit)发现老年代无法分配时,调用GenCollectedHeap::satisfy_failed_allocation。 - 该方法会设置
_foregroundGCIsActive标志,通知后台collect_in_background线程中止并发工作(_concurrent_should_abort = true)。 - 然后直接在前台线程中执行
CMSCollector::collect(foreground 模式),最终调用SerialOldGC进行单线程标记-整理 Full GC。
STW 时间包括标记所有存活对象、移动对象更新引用,单线程操作加上大堆,很容易达到秒级甚至数十秒。
③ 多角度追问:
- 追问 1:日志中如何识别 Concurrent Mode Failure?→ 出现
(concurrent mode failure)以及紧随其后的[Full GC (Allocation Failure) …],且 Full GC 使用的是Serial Old收集器。 - 追问 2:它与 Promotion Failure 有何区别?→ Promotion Failure 是 Young GC 时老年代无足够空间容纳晋升对象而触发 Full GC,可能发生在任何时候;Concurrent Mode Failure 特指 CMS 并发周期内部发生的空间耗尽。
- 追问 3:能否通过增大并发线程数(
ConcGCThreads)来避免?→ 只能部分缓解并发标记/清除速度,但无法解决浮动垃圾和突发性大量分配的根源。更重要的是调低阈值和增大堆。
④ 加分回答:
CMS 的 AbortablePreclean 阶段会持续监测老年代的使用率,如果发现空间即将耗尽,会通过 CMSCollector::abortable_preclean 主动中断预清理,提前进入 Remark 甚至直接 abort 触发 Full GC。这种“止损”机制在日志中体现为预清理时间很短或直接进入 Full GC。
7. CMS 的重新标记(Remark)阶段为什么需要 STW?-XX:+CMSScavengeBeforeRemark 参数如何优化 Remark?
① 一句话回答:Remark 必须 STW 才能原子地修正并发标记期间的引用变化,CMSScavengeBeforeRemark 在 Remark 前执行一次 Young GC,减少年轻代对老年代的引用,从而降低 Remark 扫描量。
② 详细解释: Remark 的工作集包括三部分:
CMS Remark(重新标记)阶段的工作集
1. GC Roots(根集合)
- 包含:栈帧中的局部变量、静态变量、JNI 引用、活跃线程等。
- 原因:并发标记期间,应用程序可能直接修改根引用(例如改变局部变量指向)。根引用的变化不触发任何写屏障,必须重新枚举才能发现新指向的白色对象。
- 动作:遍历所有 GC Roots,将直接引用的对象标记为灰色/黑色。
2. 新生代中的存活对象
- 包含:Eden 区和 Survivor 区中所有存活的对象。
注:若启用
-XX:+CMSScavengeBeforeRemark,Remark 前会先执行一次 Young GC,此时新生代几乎清空,存活对象极少,可大幅减少扫描开销。- 原因:新生代对象可以引用老年代对象。为性能考虑,CMS 关闭了新生代字段赋值的写屏障,因此并发标记期间新生代对象新引用老年代白色对象时,GC 无法感知。Remark 时扫描整个新生代存活对象,可捕获所有这类跨代引用。
- 动作:遍历每个新生代存活对象的所有引用,将引用的老年代对象(或新生代内部对象)标记下来。
3. 脏卡页中的老年代对象
- 包含:
- 全局卡表(Card Table)中当前标记为“脏”的卡页所覆盖的老年代对象。
- Mod-Union Table 中记录的脏卡页信息(在并发标记期间发生的 Young GC 会将卡表脏页合并到 Mod-Union Table 中,防止信息丢失)。
- 原因:并发标记期间,每次对老年代对象字段的赋值(无论指向老年代还是新生代)都会通过写屏障将对应卡页标记为脏。脏卡页记录了“可能被修改过的老年代对象”。Remark 重新扫描这些对象,找出它们新引用的白色对象,这是增量更新策略的核心,可防止因黑色对象新引用白色对象而导致的漏标。
- 动作:遍历每个脏卡页内的所有老年代对象,扫描它们的所有引用,将引用的白色对象标记为灰色/黑色。
总结
Remark 工作集 = GC Roots + 新生代存活对象 + 脏卡页(及 Mod-Union Table)覆盖的老年代对象
三者缺一不可,共同保证并发标记期间所有引用变化(根、新生代跨代、老年代内部)都被覆盖,避免漏标。
这三类变更是与应用线程并发产生的,如果 Remark 不 STW,应用线程会持续产生新的变更,导致修正工作永远无法收敛。因此 Remark 必须暂停所有应用线程,获取一个瞬时一致的对象图快照。
CMSScavengeBeforeRemark 参数的作用:
- 在 Remark 开始前,强制触发一次 ParNew Young GC。这次 Young GC 会回收年轻代中的垃圾对象,并将仍存活的对象保留在年轻代或晋升到老年代。
- 由于年轻代对象减少,从年轻代指向老年代的跨代引用(即 GC Roots 的一部分)大大减少。Remark 扫描老年代时,只需处理那些确实被年轻代存活对象引用的老年代对象,扫描集缩小。
- 该参数通常能缩短 Remark 停顿 20%-50%,但会额外增加一次 Young GC 的停顿(一般很短),需权衡。
③ 多角度追问:
- 追问 1:如果年轻代本身很小,此参数还有效吗?→ 如果年轻代存活对象很少,Young GC 缩减的引用有限,收益可能不明显,甚至额外的 Young GC 停顿得不偿失。可通过 GC 日志对比开启前后的 Remark 时间来判断。
- 追问 2:Remark 内部是否并行化?→ 是的,
CMSParRemarkTask将 Rescan、WeakRefs、ClassUnloading 等阶段并行执行,线程数由ParallelCMSThreads控制,可充分利用多核。 - 追问 3:除了这个参数,还有其他优化 Remark 的方法吗?→ 降低
CMSInitiatingOccupancyFraction可更早触发 CMS,此时年轻代对象较少,间接降低 Remark 负担;开启-XX:+CMSParallelRemarkEnabled(JDK 8 默认开启)确保并行。
④ 加分回答:
CMSParRemarkTask::work 方法中的 drain_card_table 实现是优化的关键。它扫描 _modUnionTable 中的脏卡,对每张脏卡内的对象执行 oop_iterate 重新扫描。如果 CMSScavengeBeforeRemark 开启,年轻代回收后许多跨代引用消失,对应的卡页不再需要扫描,因此 Remark 时间降低。
8. UseCMSCompactAtFullCollection 和 CMSFullGCsBeforeCompaction 参数的作用是什么?如何配合使用?
① 一句话回答:UseCMSCompactAtFullCollection 决定发生 Full GC 时是否压缩老年代消除碎片;CMSFullGCsBeforeCompaction 控制两次压缩之间允许的不压缩 Full GC 次数。
② 详细解释: 当 Full GC 不可避免时(如 Concurrent Mode Failure 或晋升失败触发),CMS 会根据这两个参数决定是否执行标记-整理(Mark-Compact)。
-XX:+UseCMSCompactAtFullCollection(默认开启):在 Full GC 的清理阶段后,会调用CompactibleFreeListSpace::compact()进行一次对象搬迁,将所有存活对象向一端移动,形成连续空闲空间。此过程是单线程的(Serial Old),会显著增加 Full GC 的停顿时间。-XX:CMSFullGCsBeforeCompaction=N(默认 0):表示每 N 次不压缩的 Full GC 之后,下一次 Full GC 才执行压缩。设为 0 表示每次 Full GC 都压缩;设为正数可减少压缩频率。例如 N=5,则第1-5次 Full GC 不压缩(仅标记-清除,不移动对象),第6次压缩。
配合使用策略:
- 如果应用频繁触发 Full GC 且碎片化严重,应设为 0 每次压缩,尽快释放连续空间,但需承受每次 Full GC 的压缩开销。
- 如果 Full GC 频率很低(如数小时一次),可将
CMSFullGCsBeforeCompaction设为 0,利用每次机会彻底整理。 - 如果 Full GC 较为频繁但压缩停顿过长,可适当增大 N,以时间换空间,通过几次不压缩的 Full GC 观察碎片是否自行缓解。
③ 多角度追问:
- 追问 1:不压缩的 Full GC 与 CMS 的并发清除有何不同?→ 不压缩的 Full GC 是 STW 的标记-清除,单线程但速度比 CMS 并发清除快(无并发竞争),但仍只回收空间不移动对象。
- 追问 2:这些参数对 Concurrent Mode Failure 降级产生的 Full GC 生效吗?→ 是的,降级产生的 Full GC 同样遵循这两个参数的设置。
- 追问 3:如何监控碎片化程度以决定压缩频率?→ 可使用
-XX:PrintFLSStatistics=1输出 FreeList 统计,观察最大空闲块大小;或通过 GC 日志中老年代使用率与 Full GC 频率推断。
④ 加分回答:
不压缩的 Full GC 执行的是 CMSCollector::do_compaction_work(false),压缩的则执行 do_compaction_work(true)。前者调用 MarkSweep::collect,后者调用 CompactibleFreeListSpace::compact。由于压缩涉及对象移动和引用更新,停顿时间通常是不压缩的 2-3 倍。
9. CMS 的并发标记线程数如何设置?ConcGCThreads 和 ParallelCMSThreads 分别控制什么?
① 一句话回答:ConcGCThreads 控制 CMS 并发阶段(并发标记、并发清除)的线程数,默认约为 (ParallelGCThreads+3)/4;ParallelCMSThreads 控制 CMS 内部 STW 阶段(如 Remark、初始标记)的并行线程数,默认等于 ParallelGCThreads。
② 详细解释:
- ConcGCThreads:设置并发 GC 线程数,在
CMSConcMarkingTask和SweepMeth中使用。并发线程在后台运行,与应用线程共享 CPU。设得太高会抢占业务 CPU,导致应用吞吐下降和响应延迟;设得太低则并发回收效率低,周期变长,预留空间不足风险增加。经验值:对于 I/O 密集型应用可设为 CPU 核数的 1/4 到 1/2;对于计算密集型应用通常维持默认。 - ParallelCMSThreads:设置 CMS 在 STW 阶段(主要是 Remark 的
CMSParRemarkTask和初始标记的CMSParInitialMarkTask)的并行线程数。默认值为ParallelGCThreads,即与 ParNew 的并行线程数一致。这些阶段需要尽快完成,因此可使用较多的线程并行扫描。
③ 多角度追问:
- 追问 1:在容器化环境(CPU 限制)下如何设置?→ 需根据容器的 CPU 核心限制调整,避免过度并行导致线程切换风暴。可通过
-XX:ActiveProcessorCount=N告诉 JVM 可用的 CPU 数,ConcGCThreads和ParallelGCThreads会基于此计算。 - 追问 2:并发标记线程优先级如何?→ CMS 将其设置为低优先级(
os::set_native_priority(thread, NearMinPriority)),以减少对应用线程的干扰,但在 CPU 繁忙时可能被长时间抢占,延长标记时间。 - 追问 3:这些参数在 G1 中如何对应?→ G1 也有
ConcGCThreads和ParallelGCThreads,含义类似。G1 的并发标记线程也用于 SATB 标记,并行线程用于 Mixed GC 和 Remark。
④ 加分回答:
CMS 的并发线程使用了“工作窃取”算法(CMSConcMarkingTask::do_work_stealing),空闲线程会从其他线程的本地任务队列窃取灰色对象。这使得即使某些线程处理的对象图分支很浅,也能通过窃取保持所有线程忙碌,提升并发标记的整体吞吐。
10. 为什么 JDK 14 移除了 CMS?G1 相比 CMS 有哪些根本性优势?
① 一句话回答:JDK 14 移除 CMS 是因为其碎片化、不可预测的停顿以及维护成本高;G1 通过 Region 级复制、SATB、可预测停顿模型,提供了更稳定的延迟和更好的大堆支持。
② 详细解释: CMS 的核心缺陷在多年的生产实践中暴露无遗:
- 碎片化:标记-清除导致严重内存碎片,需要依赖压缩参数和降级 Full GC 弥补,但 Full GC 停顿秒级,完全背离低延迟目标。
- 停顿不可预测:Remark 时间与并发期引用变更量成正比,写密集场景下可能暴增至秒级。
- 大堆乏力:大堆下并发周期变长,预留空间难以估算,Concurrent Mode Failure 风险上升。
- 维护负担:CMS 源码(
concurrentMarkSweepGeneration.cpp达数万行)复杂,与 G1、ZGC 等新收集器的融合困难,OpenJDK 团队决定在 JDK 14 中彻底移除。
G1 的优势:
- 无碎片:基于 Region 的复制算法,回收时将存活对象移动到新的 Region,原 Region 清空,天然紧凑。
- 可预测停顿:通过设定
-XX:MaxGCPauseMillis,G1 动态选择回收 Region 的数量,将停顿控制在目标范围内。 - SATB 并发标记:Remark 仅需处理 SATB 队列,停顿极短且可预测。
- 大堆优化:Region 化使得 G1 能高效管理数十 GB 甚至上百 GB 的堆,停顿时间不随堆大小线性增长。
③ 多角度追问:
- 追问 1:为什么 JDK 9 将 G1 设为默认而不立即移除 CMS?→ 为了给用户充分的迁移时间,很多企业应用从 JDK 8 升级到 JDK 11+ 需要逐步切换 GC。
- 追问 2:G1 有哪些劣势?→ CPU 开销略高于 CMS(RSet 维护、SATB 屏障),吞吐量在部分批处理场景可能下降 5-10%。
- 追问 3:是否所有场景都应该从 CMS 升级到 G1?→ 对于堆小于 4GB 且延迟要求不高的应用,Parallel GC 可能更合适;G1 最适合 4GB 以上、有明确延迟目标的场景。
④ 加分回答: CMS 的移除标志着并发标记-清除老年代收集器路线的终止。G1 成功后,OpenJDK 又推出了 ZGC 和 Shenandoah,追求亚毫秒级停顿,进一步推动 GC 技术的演进。CMS 的思想(并发标记、写屏障)被继承,但其标记-清除的局限被彻底抛弃。
11. 如何在 GC 日志中识别 CMS 各阶段的耗时?如何通过日志判断碎片化程度和 Full GC 风险?
① 一句话回答:通过 PrintGCDetails 日志中的 (initial-mark)、CMS-concurrent-mark、CMS Final Remark、CMS-concurrent-sweep 等关键字获取各阶段耗时;通过晋升失败、老年代使用率与 Full GC 的关系推断碎片化风险。
② 详细解释: 各阶段耗时识别:
- 初始标记:通常在 Young GC 日志行后出现
(initial-mark),总时间包含在 Young GC 停顿中。 - 并发标记:
[CMS-concurrent-mark: 1.234/1.235 secs],前者为墙上时间,后者为 CPU 时间。 - 预清理:
[CMS-concurrent-preclean: 0.567/0.568 secs],同上。 - 重新标记:以
[GC (CMS Final Remark) …]开始,其中包含Rescan、weak refs、class unloading、scrub等子阶段耗时,最后给出总停顿时间。 - 并发清除:
[CMS-concurrent-sweep: 0.789/0.790 secs]。 - 重置:
[CMS-concurrent-reset: 0.100/0.100 secs]。
碎片化判断线索:
- Full GC 日志中出现
[Full GC (Allocation Failure) …]且老年代使用率并非接近 100%(例如 85%),表明有连续空间不足的情况,高度疑似碎片化。 - 使用
-XX:PrintFLSStatistics=1(开发调试用)会输出FreeList统计,包括按大小分类的空闲块数量、最大空闲块等。如果最大空闲块远小于总空闲量,说明碎片严重。 - 观察指标:如果应用频繁分配大对象,且每次大对象分配后不久就出现 Full GC,也佐证碎片化。
③ 多角度追问:
- 追问 1:如何从 jstat 输出监控老年代碎片化?→ jstat 无法直接显示碎片,但可以结合
jcmd <pid> VM.native_memory和jmap -heap(注意 jmap 在线上需谨慎)观察 Old Gen 的 max free chunk。 - 追问 2:GC 日志中如何预判 Concurrent Mode Failure?→ 在 CMS 周期内,观察并发标记期间老年代使用率的增量。如果老年代使用率在并发标记期间就迅速攀升,或者并发清除期间老年代未明显下降,说明预留空间吃紧。
- 追问 3:工具能否辅助分析?→ GCViewer 可将 CMS 各阶段可视化为时间线,直观显示停顿分布和堆使用趋势;GCEasy 可自动标记潜在风险。
④ 加分回答:
开启 -XX:+PrintHeapAtGC 会在每次 GC 前后打印堆的详细布局,包含各代的大小、使用量、空闲块信息,能更细致地分析碎片化和晋升行为。但此选项输出量极大,仅适合在测试环境中临时使用。
12. CMS 的增量更新与 G1 的 SATB 在并发标记策略上有什么本质区别?它们各自适合什么场景?
① 一句话回答:CMS 增量更新记录“谁修改了引用”(修改方),需重新扫描修改者,Remark 停顿较重;G1 SATB 记录“被引用的旧值”(被引用方),Remark 仅处理 SATB 队列,停顿极短但浮动垃圾更多。
② 详细解释:
-
CMS 增量更新(Incremental Update):
- 屏障类型:后置写屏障。
- 记录内容:修改者(黑色对象)。
- 修复动作:将修改者灰化并加入重新扫描队列。
- Remark 工作:重新扫描灰化节点及其子图 + 脏卡扫描。
- 优点:浮动垃圾较少,回收更彻底。
- 缺点:Remark 停顿不可控,与引用变更频率正相关;卡表粗粒度导致扫描放大。
-
G1 SATB(Snapshot-At-The-Beginning):
- 屏障类型:前置写屏障。
- 记录内容:被引用对象的旧值(旧值压入线程 SATB 队列)。
- 修复动作:并发标记开始时逻辑快照中的存活对象都不会被漏标。
- Remark 工作:仅处理 SATB 队列中的对象,无需大规模扫描。
- 优点:Remark 极短且可预测;可与 Region 复制完美结合。
- 缺点:浮动垃圾较多(并发期间死亡但逻辑快照中存活的对象不会被回收),需更大堆或容忍。
场景选择:
- CMS 适合:写操作不频繁、对象修改少、对碎片化容忍度高且堆较小的老系统。
- G1 适合:写密集、要求可预测低延迟(毫秒级)、中大型堆(>4GB)的场景,现代 Java 应用的默认选择。
③ 多角度追问:
- 追问 1:为什么 SATB 会产生更多浮动垃圾?→ 因为 SATB 保留的是并发标记开始时逻辑快照中的存活对象,即使它们在标记期间死了,根据快照仍视为存活。这些对象只能等到下次 GC 回收。
- 追问 2:增量更新是否能像 SATB 那样 Remark 只用队列处理?→ 理论上可以精确记录修改者,但 CMS 出于性能考虑使用卡表批量化,导致必须整页扫描,无法实现队列化。
- 追问 3:有没有折中方案?→ ZGC 的染色指针和读屏障提供了另一种思路,但这是另一条技术路线。
④ 加分回答: 从学术角度看,增量更新和 SATB 分别是 Dijkstra 的在线更新算法与 Yuasa 的快照算法的工程实现。G1 选择 SATB 的关键原因是它能与 Region 级的复制回收天然适配:Remark 短,且浮动垃圾在接下来的 Mixed GC 中会被复制整理,对整体堆效率影响可控。
13. (故障排查题)线上服务使用 CMS + ParNew,偶尔出现长达 10-30 秒的 Full GC。GC 日志中发现在 Full GC 前出现 (concurrent mode failure) 和 [Full GC (Allocation Failure) ... ]。jstat 显示老年代使用率在 CMS 触发前后从 75% 迅速飙升至 100%。请分析:(a) Concurrent Mode Failure 的触发原因——为什么老年代会迅速填满?(b) 画出 CMS 并发回收与老年代填满的竞速时序图;(c) 如何通过调整 CMSInitiatingOccupancyFraction、增大老年代、或优化大对象分配来解决?(d) 如果调优后仍然频繁降级,是否应该考虑升级到 G1?迁移前需要做哪些评估?
① 一句话回答:老年代迅速填满是因为 CMS 并发周期内应用分配/晋升速率超过预留空间和回收速度;解决方案为降低触发阈值、增大堆、减少大对象;若仍频发则迁移 G1,评估吞吐、CPU 和灰度方案。
② 详细解释:
(a) 触发原因深度分析
从 jstat 的使用率数据(75% 迅速飙至 100%)和日志中的 concurrent mode failure 可以推断,CMS 在 75% 左右触发(表明 CMSInitiatingOccupancyFraction 可能被手动设置为 75),但在并发标记和并发清除期间,老年代分配速率突然飙升。可能的原因包括:
- 晋升风暴(Promotion Burst):在 CMS 周期内发生了一次或多次 Young GC,由于 Eden 或 Survivor 存活对象过多,大量对象晋升到老年代。这可能发生在某次请求处理突发,产生了大量短-中寿命对象。
- 大对象直接分配:应用代码直接分配大对象(如大数组、缓冲区),这些对象跳过了年轻代直接在老年代分配(
PretenureSizeThreshold),如果在 CMS 周期内集中分配,会迅速填满预留空间。 - 浮动垃圾超预期:并发标记期间大量对象死亡变成浮动垃圾,这些垃圾占用老年代空间但无法被回收,加剧空间紧张。
- 并发回收速度不足:
ConcGCThreads设置过低,并发标记和清除耗时过长,期间累积的分配超过了 25% 老年代的预留空间。
由于 CMS 预留空间 = 100% - 75% = 25% 老年代,若老年代总大小为 2GB,则预留 512MB。如果 CMS 周期持续 3 秒,平均分配速率只要超过 170MB/秒就会耗尽。
(b) 竞速时序图
sequenceDiagram
participant App as 应用线程
participant CMS as CMS 后台线程
participant OldGen as 老年代(2GB)
Note over OldGen: 初始使用率 75% (1.5GB)
App->>OldGen: 持续分配对象
OldGen-->>CMS: 使用率达 75%,触发 CMS 周期
CMS->>App: 初始标记 (STW, ~5ms)
CMS->>CMS: 并发标记开始(约 2s)
App->>OldGen: 处理突发请求,大量分配 + 晋升
Note over OldGen: 使用率 75% → 95%
CMS-->>OldGen: 并发标记结束,发现空间紧张
CMS->>CMS: 预清理(短暂)
CMS->>App: 重新标记 (STW, ~150ms)
App->>OldGen: 重新标记期间无分配,但停顿结束后继续
Note over OldGen: 使用率 95% → 99%
CMS->>CMS: 并发清除开始
App->>OldGen: 分配一个新大对象(需 30MB)
OldGen-->>App: 剩余连续空间不足!
App->>CMS: 分配失败,请求 foreground GC
CMS->>CMS: 设置 _concurrent_should_abort,中止并发清除
CMS->>App: 降级为 Serial Old Full GC (STW, 15s)
Note over App,OldGen: 应用停顿,长暂停发生
(c) 解决方案及调优步骤
-
降低触发阈值,增大安全垫:
-XX:CMSInitiatingOccupancyFraction=60 -XX:+UseCMSInitiatingOccupancyOnly将阈值从 75 降至 60,预留 40% 老年代空间,可吸收更长时间或更高速率的分配。缺点是 CMS 周期更频繁,CPU 开销增加。
-
增大堆内存:
-Xms4g -Xmx4g假设从 2GB 堆增加到 4GB,老年代相应增大。预留比例即使不变,绝对空间也翻倍。同时可适当回调
CMSInitiatingOccupancyFraction至 70,平衡频率。 -
优化大对象分配:
- 使用
jmap -histo:live <pid>分析堆中占用最大的对象类型。如果发现大量的大数组、ByteBuffer,检查代码中是否使用了过大的缓冲区或一次性加载过多数据。 - 调整
-XX:PretenureSizeThreshold=10485760(如 10MB),让超此大小的对象走直接分配路径,避免其撑爆年轻代晋升。 - 若大对象是请求级临时对象,可考虑池化或尽早释放引用。
- 使用
-
开启 Remark 前 Young GC:
-XX:+CMSScavengeBeforeRemark虽不能直接解决空间问题,但可加速 Remark 完成,缩短 CMS 周期,间接降低并发期分配量。
-
增加并发线程数(如果 CPU 有空闲):
-XX:ConcGCThreads=4加快并发标记和清除速度,缩短周期。需监控 CPU 利用率,避免与应用争抢。
-
监视效果:开启
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:gc.log,观察调整后 Concurrent Mode Failure 是否消失,Remark 停顿是否可控。
(d) 迁移到 G1 的评估与方案 如果经过上述调优,仍因业务特性(如不可预知的突发流量、固有的高分配率)导致每周甚至每天仍发生秒级 Full GC,那么 CMS 已无法满足需求,迁移至 G1 是根本解决之道。
迁移前需做的评估:
-
兼容性测试:
- 在预发环境使用相同的堆大小配置 G1:
-XX:+UseG1GC -XX:MaxGCPauseMillis=200。 - 进行全链路压测,对比吞吐量(QPS)和延迟(P99)。
- G1 的吞吐量可能略低于 CMS(5-10%),需确认业务指标是否仍达标。
- 在预发环境使用相同的堆大小配置 G1:
-
内存需求评估:
- G1 需要额外内存维护 RSet(Remembered Set),通常占堆的 5%-10%。若原堆为 4GB,G1 可能需 4.2GB-4.4GB 实际可用。可适当增大堆或确认机器内存余量。
- 开启 G1 的 Mixed GC 日志,观察堆使用模型。
-
参数清理与适配:
- CMS 专属参数(如 CMSInitiatingOccupancyFraction、CMSFullGCsBeforeCompaction)在 G1 中无效,需移除,否则 JVM 会忽略或报错。
- 调整 G1 关键参数:
-XX:InitiatingHeapOccupancyPercent=45(类似 CMS 阈值,G1 默认 45%)、-XX:G1ReservePercent=10(预留空间防止晋升失败)。
-
灰度上线计划:
- 先选择非核心服务或小流量实例切换到 G1,观察至少一周。
- 监控 GC 行为:使用
jstat -gcutil或 GC 日志工具,重点关注 Mixed GC 频率、G1 Humongous Allocation 情况、是否有 Full GC(G1 中的 Full GC 也是 Serial 或 Parallel,需避免)。 - 逐步扩大灰度比例,直至全量。
-
回滚预案:
- 保留回滚到 CMS 的参数脚本,一旦 G1 出现严重性能退化,可快速通过重启回退。
补充技术细节: G1 如何处理 CMS 的痛点:
- 碎片化:G1 通过 Mixed GC 复制对象整理 Region,无碎片化风险。
- 浮动垃圾:SATB 会产生更多浮动垃圾,但 Mixed GC 会逐步回收,G1 自适应调整
InitiatingHeapOccupancyPercent和并发标记频率,保持堆健康。 - 突发晋升:G1 具有“疏散失败”防护机制,若晋升时目标 Region 不足,会扩展堆或触发 Full GC,但通过
G1ReservePercent预留空间通常可避免。
③ 多角度追问:
- 追问 1:能不能通过限制晋升速度来解决?→ 可以,调整年轻代参数(
-Xmn、-XX:SurvivorRatio)降低晋升速率,但会提高 Young GC 频率,需整体权衡。 - 追问 2:Concurrent Mode Failure 后的 Full GC 为什么是单线程?→ 降级调用 Serial Old,这是 JVM 的兜底策略,确保在最坏情况下有简单可靠的回收方式。
- 追问 3:有没有实时监控碎片化的 JMX 指标?→
java.lang:type=GarbageCollector,name=ConcurrentMarkSweep提供LastGcInfo含内存池使用信息,但碎片化无直接 Bean。可二次开发通过ManagementFactory.getMemoryPoolMXBeans()分析Usage推测。
④ 加分回答:
CMS 的 AbortablePreclean 机制在 CMSCollector::abortable_preclean 中实现,它会循环检查 _abort_preclean 条件(如老年代剩余空间低于 CMSMaxAbortablePrecleanLoops * 分配速率),一旦判定危险即退出预清理,迅速进入 Remark 或直接 abort。这种“及时止损”是工程折中。迁移到 G1 后,可通过 -XX:+G1PrintHeapRegions 调试 Region 状态,更精确地追踪大对象和碎片化(虽然 G1 无碎片,但巨量 Humongous 对象会退化为串行回收,类似效应)。