JVM(11)——详解CMS垃圾回收器
CMS (Concurrent Mark-Sweep) 垃圾回收器。它是 JDK 1.4 后期引入,并在 JDK 5 - JDK 8 期间广泛使用的一种以低停顿时间 (Low Pause Time) 为主要目标的老年代垃圾回收器。它是 G1 出现之前解决 Full GC 长停顿问题的主要方案。
一、CMS 的设计目标与定位
-
核心目标:最小化应用停顿时间 (STW - Stop-The-World)。
-
特别关注老年代垃圾回收引起的停顿。
-
旨在避免传统 Serial Old GC(标记-整理)导致的长时间、全局性的停顿,这对交互式应用(如 Web 服务器、GUI 应用)至关重要。
-
-
实现方式:并发 (Concurrent) 收集。
-
将垃圾回收中最耗时的标记 (Marking) 和清除 (Sweeping) 阶段,尽可能与应用线程并发执行,从而大大减少需要 STW 的时间。
-
-
算法:标记-清除 (Mark-Sweep)。
-
标记: 找出所有存活的对象。
-
清除: 回收未被标记(即死亡)的对象占用的空间。
-
注意: 它不进行压缩 (Compaction),这是它产生内存碎片的主要原因。
-
-
分代收集: CMS 主要管理老年代 (Old Generation)。它通常与一个年轻代收集器搭配使用,如 ParNew(并行复制算法,Serial 的多线程版本)或 Serial。
-
定位: 在 G1 成熟之前,CMS 是追求低延迟老年代回收的首选方案,尤其适用于中小型堆(如 4GB - 8GB)、CPU 资源相对充足且能容忍一些内存碎片和额外 CPU 开销的应用。
二、CMS 的工作阶段 (Phases)
CMS 的垃圾回收周期(针对老年代)由多个阶段组成,其中只有部分阶段需要 STW:
-
初始标记 (Initial Mark - STW)
-
目标: 标记从 GC Roots 直接可达 的老年代对象(速度很快)。
-
STW 原因: 需要暂停应用线程以确保在一致性的快照下快速扫描 GC Roots(线程栈、静态变量、JNI 引用等)。
-
优化: 这个阶段通常借道一次 Young GC(Minor GC)来完成。因为 Young GC 本身就需要 STW 并扫描 GC Roots,CMS 可以“搭便车”标记那些从根集直接引用的老年代对象,避免了额外的完整根扫描停顿。所以 CMS 触发的时机往往紧跟在一次 Young GC 之后。
-
-
并发标记 (Concurrent Mark - Concurrent)
-
目标: 从“初始标记”阶段标记的直接可达对象开始,遍历整个老年代对象图,标记所有间接可达的存活对象。
-
并发性: 这是 CMS 减少停顿的关键!这个阶段与应用线程同时运行。应用线程可以继续创建新对象、更新引用关系。
-
挑战 - 浮动垃圾 (Floating Garbage): 因为在标记过程中应用线程还在运行,可能会产生新的垃圾对象(标记阶段结束后才成为垃圾)或者使已标记的对象变成垃圾。同时,应用线程修改对象引用关系可能导致“漏标”或“多标”问题。CMS 使用 增量更新 (Incremental Update) 算法来解决对象引用变化的问题(与 G1 的 SATB 不同)。
-
挑战 - 耗时: 遍历整个老年代对象图,即使并发,也可能花费较长时间,特别是大堆。
-
-
重新标记 (Remark - STW)
-
目标: 修正“并发标记”阶段因应用线程继续运行而导致的标记变动,确保所有在并发标记期间存活的对象都被正确标记。处理在并发阶段新晋升到老年代的对象(如果搭配 ParNew,晋升发生在 Young GC 的 STW 阶段,相对容易处理)。
-
STW 原因: 为了获得一个最终准确的存活对象视图,需要在一个确定的点上暂停所有应用线程。
-
优化: 这个阶段通常比“初始标记”长,但比“并发标记”短得多。JVM 会使用多线程并行处理来加速。可以启用
-XX:+CMSScavengeBeforeRemark
参数,在重新标记前强制触发一次 Young GC,清理掉年轻代的垃圾,减少需要扫描的年轻代对象数量(年轻代对象也可能引用老年代对象),从而缩短 STW 时间。
-
-
并发清除 (Concurrent Sweep - Concurrent)
-
目标: 回收那些在标记阶段被确定为死亡对象所占用的内存空间。
-
并发性: 这个阶段也与应用线程同时运行。应用线程可以继续分配新对象(在空闲列表管理的内存区域)。
-
算法: 使用空闲列表 (Free List) 管理回收后的空间。清除器遍历内存,将连续的死对象空间合并成空闲块,记录在空闲列表中,供后续分配使用。
-
结果: 回收了垃圾内存,但不进行内存整理压缩。这导致了内存碎片问题。
-
-
并发重置 (Concurrent Reset - Concurrent)
-
目标: 为下一次 CMS 周期重置内部数据结构(如标记位图)。
-
并发性: 与应用线程同时运行,无停顿。
-
三、CMS 的核心特性与优势
-
低停顿时间 (Low Pause Time): 这是 CMS 最大的优势。通过将最耗时的标记和清除工作并发执行,显著减少了 STW 的时间(主要集中在初始标记和重新标记阶段),使得老年代回收对应用响应时间的影响大大降低。
-
并发收集 (Concurrent Collection): 真正实现了垃圾回收线程与应用线程在大部分时间并行工作。
-
适用于延迟敏感型应用: 在 G1 成熟之前,是 Web 服务器、交易系统等需要快速响应的应用的首选老年代回收器。
四、CMS 的缺点与挑战
-
内存碎片 (Memory Fragmentation):
-
根本原因: 使用标记-清除算法且不压缩内存。长时间运行后,老年代会由许多存活对象和大小不一、分散的空闲内存块组成。
-
后果:
-
分配失败: 即使老年代总的空闲空间足够,也可能因为找不到足够大的连续空间来分配一个大对象(或晋升对象),从而触发 Full GC (Serial Old GC)。
-
Full GC 时间长: Serial Old GC 是单线程的标记-整理-压缩算法,在大堆上进行压缩会导致非常长的 STW 停顿,违背了使用 CMS 的初衷。
-
-
缓解措施:
-
-XX:+UseCMSCompactAtFullCollection
(默认 true): 在不得不进行 Full GC 时,在 Full GC 后进行内存压缩。 -
-XX:CMSFullGCsBeforeCompaction=n
(默认 0): 设定在多少次不压缩的 Full GC 后,执行一次带压缩的 Full GC。0
表示每次 Full GC 都压缩(推荐)。但这仍然意味着要经历一次长时间的 Full GC。
-
-
-
并发模式失败 (Concurrent Mode Failure):
-
触发条件:
-
老年代空间不足: 在 CMS 并发周期(标记和清除)完成之前,老年代空间就被填满了。这通常发生在:
-
老年代分配/晋升速率过快,超过 CMS 回收速度。
-
浮动垃圾过多,占用了本应回收的空间。
-
并发周期启动太晚(
-XX:CMSInitiatingOccupancyFraction
设置过高)。
-
-
晋升失败 (Promotion Failed): Young GC 后,存活对象需要晋升到老年代,但老年代没有足够的连续空间容纳它们(即使总空间可能够,但碎片导致)。
-
-
后果: JVM 会立即中断 CMS 并发周期,并触发一次 Full GC (Serial Old GC)。这会导致一个计划外的、长时间的 STW 停顿。
-
预防措施:
-
合理设置
-XX:CMSInitiatingOccupancyFraction
:降低老年代空间占用阈值(如从默认 68% 设到 50% 或更低),尽早启动 CMS 并发周期,给并发回收留出足够的时间窗口和空间裕度。 -
必须配合
-XX:+UseCMSInitiatingOccupancyOnly
使用:确保 JVM 仅根据CMSInitiatingOccupancyFraction
的值启动 CMS,而不是自行“自适应”调整(可能导致启动过晚)。 -
增加堆大小或老年代比例 (
-Xmx
,-Xms
,-XX:NewRatio
)。 -
优化应用,减少对象创建和晋升速率、减小对象大小、避免过大的对象。
-
增加 CMS 回收线程数 (
-XX:ConcGCThreads
/-XX:ParallelCMSThreads
, 后者在较新版本已废弃,推荐用ConcGCThreads
)。
-
-
-
对 CPU 资源敏感 (CPU Sensitive):
-
原因: 并发标记和并发清除阶段需要与应用线程争抢 CPU 资源。
-
后果:
-
在 CPU 资源紧张(如 CPU 核数少、负载高)的情况下,并发回收线程会拖慢应用线程的执行速度,导致应用吞吐量下降。
-
并发阶段本身可能因为 CPU 争抢而执行得更慢,增加了并发模式失败的风险。
-
-
建议: CMS 更适合 CPU 资源相对富余(核数较多或负载不高)的机器。
-
-
浮动垃圾 (Floating Garbage): 如前所述,并发过程中产生的垃圾只能在下一次 GC 回收。需要预留足够空间容纳这些浮动垃圾。
-
元空间/永久代触发 Full GC: CMS 不管理元空间 (Metaspace, JDK 8+) 或永久代 (PermGen, JDK 7-)。如果元空间/永久代空间不足,会触发 Full GC。
-
JDK 9+ 中已弃用 (Deprecated),JDK 14+ 中已移除 (Removed):
-
弃用原因: G1 作为更现代、设计更优的回收器(同样追求低延迟,且解决了碎片问题)已成为默认选择。CMS 的维护成本高,且其架构难以适应更新的 Java 特性和硬件发展(如非常大的堆)。
-
后果: 在新版本 JDK (>=14) 中无法再使用 CMS。仍在使用的应用应尽快迁移到 G1 或其他回收器(如 ZGC, Shenandoah)。
-
五、关键配置参数
-
启用 CMS:
-
-XX:+UseConcMarkSweepGC
(JDK 8 及之前)
-
-
设置年轻代收集器 (通常自动选择):
-
搭配 ParNew:
-XX:+UseParNewGC
(通常启用 CMS 会自动启用)
-
-
触发 CMS 的堆占用阈值 (最重要!):
-
-XX:CMSInitiatingOccupancyFraction=<percent>
(e.g., 70): 当老年代空间占用达到此百分比时,启动 CMS 并发收集周期。建议设低一些(如 50-70)以预防并发模式失败。
-
-
强制使用阈值触发 (必须配!):
-
-XX:+UseCMSInitiatingOccupancyOnly
: 强制 JVM 只使用CMSInitiatingOccupancyFraction
的值作为触发条件,禁用 JVM 的自适应调整。
-
-
重新标记前进行 Young GC:
-
-XX:+CMSScavengeBeforeRemark
: 在重新标记阶段前强制触发一次 Young GC,减少需要扫描的年轻代对象,有效缩短重新标记 STW 时间(强烈推荐启用)。
-
-
CMS 线程数:
-
-XX:ConcGCThreads=<n>
/-XX:ParallelCMSThreads=<n>
(后者较旧): 设置并发阶段(标记、清除)使用的线程数。默认为(ParallelGCThreads + 3) / 4
。可根据 CPU 核数调整。
-
-
Full GC 后压缩:
-
-XX:+UseCMSCompactAtFullCollection
(默认 true): Full GC 后进行压缩。 -
-XX:CMSFullGCsBeforeCompaction=<n>
(默认 0): 执行 n 次不压缩的 Full GC 后,执行一次带压缩的 Full GC。0
表示每次都压缩。
-
六、何时使用(或曾经使用)CMS?
-
历史场景 (JDK 8 及之前):
-
应用对老年代回收停顿时间非常敏感。
-
应用运行在中小型堆(如 4GB - 8GB)上。
-
机器有富余的 CPU 资源(核数较多,负载不高)。
-
应用能够容忍一定程度的内存碎片或通过配置降低了 Full GC 风险。
-
需要避免 Serial Old GC 的长停顿。
-
-
当前状态:
-
JDK 9+:已弃用 (Deprecated)。
-
JDK 14+:已移除 (Removed)。
-
强烈建议所有仍在使用 CMS 的应用迁移到 G1(目前默认且成熟)或探索新一代超低延迟回收器 ZGC / Shenandoah(尤其超大堆和极致低延迟需求)。
-
七、总结
CMS 垃圾回收器是 JVM 垃圾回收发展史上一个重要的里程碑,它率先通过并发标记清除的方式显著降低了老年代回收的停顿时间,满足了当时众多对延迟敏感型 Java 应用的需求。其核心价值在于并发性带来的低 STW 停顿。
然而,CMS 的固有缺陷也非常明显:
-
标记-清除算法导致内存碎片, 最终可能引发长时间的 Serial Old Full GC。
-
对并发模式失败 (Concurrent Mode Failure) 非常敏感, 需要精细调优(尤其是
CMSInitiatingOccupancyFraction
和UseCMSInitiatingOccupancyOnly
)。 -
并发阶段占用 CPU 资源,影响吞吐量。
-
无法管理元空间/永久代。
-
已被现代 JDK (>=14) 彻底移除。