在Java虚拟机(JVM)的众多垃圾收集器(Garbage Collector, GC)中,CMS(Concurrent Mark Sweep)占有特殊的历史地位。虽然它在较新的JDK版本中已被标记为废弃(Deprecated)并最终移除,但理解CMS的设计理念、工作原理以及优缺点,对于深入掌握JVM内存管理、理解后续更先进的GC(如G1、ZGC)的演进思路,仍然具有非常重要的价值。
CMS的核心目标是什么?
简单来说,CMS的设计目标是 获取尽可能短的回收停顿时间。
假如有一个高并发的在线购物网站。在用户浏览商品、下单支付的关键时刻,如果JVM因为执行垃圾回收而突然卡顿(Stop The World, STW)几百毫秒甚至几秒钟,那将是灾难性的,会导致用户流失和交易失败。
CMS正是为了解决这类对 低延迟(Low Latency) 有着苛刻要求的应用场景而诞生的。
它尝试在应用程序运行的同时,并发地执行大部分垃圾回收工作,从而将原本可能很长的STW时间,分解成几次非常短暂的STW停顿,极大地改善了应用的响应性能和用户体验。
注意: CMS已在JDK 9中被标记为废弃,并在JDK 14中被移除。本教程旨在帮助理解其原理,而非推荐在新的项目中使用。对于现代Java应用,G1、ZGC或Shenandoah通常是更好的选择。
CMS的核心思想是“并发”,即垃圾收集线程与应用程序线程在大部分时间内可以同时运行。为了实现这个目标,CMS将整个垃圾回收过程精心划分为四个主要阶段,以及一些穿插其中的预处理和收尾工作。
核心算法:标记-清除(Mark-Sweep)
首先要明确,CMS是基于 标记-清除 算法实现的。这意味着它在回收后 不会 对内存空间进行整理,这也是后续我们会讨论到的“内存碎片”问题的根源。
四个主要阶段:
CMS的回收过程主要包含以下四个步骤:
其中,初始标记 和 重新标记 这两个阶段需要 “Stop The World”(STW),即暂停所有应用程序线程。而 并发标记 和 并发清除 阶段则可以与应用程序线程 并发 执行。
下面我们来详细解析每个阶段的工作:
这个阶段就像是在繁忙的高速公路上设置了一个极短的检查点。交警(GC线程)需要迅速拦下所有车辆(暂停用户线程),然后快速识别并标记出那些“有明确目的地”(直接被GC Roots引用)的车辆(对象)。GC Roots 包括虚拟机栈中引用的对象、方法区静态属性引用的对象、方法区常量引用的对象、本地方法栈JNI引用的对象等。
由于现代JVM的方法区、虚拟机栈等区域通常不会太大,而且只需要标记GC Roots直接关联的对象,无需深度遍历,因此这个阶段的速度非常快,通常只持续几十毫料。
理解帮助: 为什么需要STW?因为GC Roots集合是不断变化的,如果在标记过程中用户线程还在运行,可能会导致GC Roots增加或减少,从而影响标记的准确性。必须在一个静止的快照上进行操作。
这是CMS最核心、最具特色的阶段。在初始标记完成后,应用程序线程恢复运行。同时,专门的GC线程开始工作,它们沿着初始标记阶段找到的那些“种子对象”,逐步追踪整个对象引用图。就像是在高速公路上,普通车辆(用户线程)在正常行驶,而道路养护车(GC线程)在旁边车道或者利用夜间进行详细的道路状况检查(标记存活对象)。
理解帮助: 并发标记的挑战?这个阶段最大的挑战在于,用户线程仍在运行并可能修改对象的引用关系。比如:
这些变化可能会导致标记结果不准确(漏标或错标)。CMS需要后续的“重新标记”阶段来修正这些问题。我们将在后面详细讨论CMS如何解决这些并发问题。
并发标记阶段虽然完成了大部分工作,但它是在一个“动态”的环境下进行的。为了确保标记的最终准确性,需要一个短暂的STW阶段来进行“查漏补缺”。这就像道路养护车在并发检查后,再次短暂封闭道路(STW),对那些在检查期间有车辆进出或新出现问题的路段(被用户线程修改过引用的对象及相关区域)进行最后的确认。
这个阶段主要处理两类变化:
CMS通过一些聪明的机制(如卡表、增量更新,稍后详述)来记录并发标记期间的这些变化,使得重新标记阶段不必重新扫描整个堆,而只需要关注那些“有变动”的小范围区域,从而有效控制了STW的时间。
理解帮助: 为什么重新标记比初始标记慢?因为重新标记需要处理整个并发标记阶段积累的变化信息,扫描范围比初始标记(只看GC Roots直连对象)要大。但相比于重新扫描整个堆,它的效率已经大大提高了。
在重新标记阶段确保了所有存活对象都被正确标记后,应用程序线程再次恢复运行。GC线程则开始最后的清理工作。它们遍历堆内存,将那些没有被标记(白色)的对象识别为垃圾,并将它们占用的内存回收,加入到空闲内存列表(Free List)中,以备后续分配新对象使用。
这个阶段也是并发的,用户线程可以正常访问那些已被标记为存活的对象,同时GC线程在后台默默地回收垃圾。
整体流程回顾:
通过将耗时最长的标记和清除阶段设计为并发执行,CMS成功地将大部分GC工作与应用程序运行重叠,从而显著降低了整体的STW时间,实现了其低延迟的目标。
没有哪种垃圾收集器是完美的,CMS也不例外。它通过牺牲一些其他方面的性能来换取低延迟的特性。理解其优缺点对于判断它是否适合特定应用场景至关重要。
CMS的并发特性和基于标记-清除算法的设计,也带来了几个不容忽视的缺点:
对CPU资源敏感 (CPU Intensive):
(CPU核心数 + 3) / 4
。当CPU核心数较少时(例如少于4个),GC线程可能会占用相当一部分(甚至超过25%)的CPU运算能力,导致用户程序的执行速度变慢,总吞吐量下降。无法处理“浮动垃圾” (Floating Garbage):
-XX:CMSInitiatingOccupancyFraction
控制这个阈值。产生内存碎片 (Memory Fragmentation):
-XX:+UseCMSCompactAtFullCollection
(默认开启): 在不得不进行Full GC时,开启内存整理(压缩)。-XX:CMSFullGCsBeforeCompaction
(默认值为0): 设置在执行多少次不压缩的Full GC之后,进行一次带压缩的Full GC。值为0表示每次Full GC都进行压缩。并发失败风险 (Concurrent Mode Failure):
CMSInitiatingOccupancyFraction
设置过高,预留空间不足。总结:
特性 | 优势 | 劣势 |
---|---|---|
核心 | 并发收集、低延迟 | 对CPU敏感、吞吐量降低 |
算法 | (无直接优势) | 标记-清除导致内存碎片 |
并发执行 | 减少STW时间 | 无法处理浮动垃圾、需要预留空间、可能发生并发失败(Concurrent Mode Failure) |
适用场景 | 对响应时间要求高的应用(Web服务、API等) | CPU资源紧张、内存分配率极高、无法容忍内存碎片的场景 |
选择CMS,就是选择用CPU资源、部分内存空间和一定的复杂性来换取应用响应时间的提升。
在垃圾收集的语境下,“并发”(Concurrent)和“并行”(Parallel)是两个非常重要且容易混淆的概念。理解它们的区别有助于我们把握不同GC的设计哲学。
并行 (Parallel):
并发 (Concurrent):
CMS是哪一种?
CMS的名字 Concurrent Mark Sweep 就明确告诉我们,它是一个 并发 收集器。它的主要工作(并发标记、并发清除)是与用户线程并发执行的。
需要注意:
-XX:+CMSParallelInitialMarkEnabled
和 -XX:+CMSParallelRemarkEnabled
(后者通常默认开启) 来让这两个STW阶段使用多线程执行,进一步缩短停顿时间。总结:
特性 | 并行 (Parallel) | 并发 (Concurrent) |
---|---|---|
线程关系 | 多个 GC线程 协同工作 | GC线程 与 用户线程 同时运行 |
用户线程 | STW (暂停) | 大部分时间 Running (运行) |
目标 | 高吞吐量 (Throughput) | 低延迟 (Latency) |
关注 | 缩短 GC 时间 | 缩短 应用停顿时间 |
代表 | Parallel Scavenge, Parallel Old | CMS, G1, ZGC, Shenandoah |
核心优势 | GC效率高 | 应用停顿少 |
核心代价 | STW时间可能较长 | 可能牺牲吞吐量、增加CPU开销、实现复杂 |
为了在用户线程并发修改对象引用的同时,正确地标记出所有存活对象,CMS(以及G1、ZGC等并发或增量GC)采用了 三色标记(Tri-color Marking) 算法作为理论基础。
三色标记法将垃圾收集器在标记过程中遇到的对象,根据其访问状态,划分为三种颜色:
白色 (White):
灰色 (Gray):
黑色 (Black):
标记过程:
可视化理解:
并发执行带来的问题:
如果三色标记法在严格的STW下单线程执行,是完全正确的。但CMS的并发标记阶段,用户线程和GC线程同时运行,这就可能破坏三色标记法正常工作的前提,导致两种严重错误:
对象消失 (Object Loss) / 漏标 (Missing Mark):
A.ref = null;
)。C.ref = B;
)。浮动垃圾 (Floating Garbage):
CMS必须解决“对象消失”这个致命问题,同时尽量减少“浮动垃圾”。它主要通过 写屏障(Write Barrier) 和 增量更新(Incremental Update) 技术来实现这一点。
为了解决三色标记在并发环境下可能出现的“对象消失”问题,CMS 引入了 写屏障(Write Barrier) 和 增量更新(Incremental Update) 机制。
什么是写屏障?
写屏障 不是 硬件层面的内存屏障(Memory Barrier),而是JVM层面的一种 代码注入技术。当JVM在编译Java代码时,如果发现代码执行的是 引用类型字段的赋值操作(例如 obj.field = someOtherObj;
),它会在这个赋值操作的 前后 插入一些额外的、特殊的处理代码。这些被插入的代码就称为“写屏障”。
写屏障的作用?
它的核心作用是 拦截或记录 用户线程对对象引用关系的修改。就像在每个对象引用赋值的地方安插了一个“监视器”,一旦发生修改,就触发特定的动作,通知GC系统。
写屏障的种类:
obj.field
原本指向的对象。obj.field
现在指向了 someOtherObj
。CMS的选择:
不同的并发GC策略会使用不同的写屏障组合。CMS为了解决漏标问题,主要依赖 写后屏障 配合 增量更新 策略。
伪代码示例(写后屏障):
// 原始代码
// obj.field = newValue;
// JVM 加入写屏障后的伪代码 (Post-Write Barrier)
void setField(Object obj, Field field, Object newValue) {
// <--- 写屏障开始 --->
// 记录下引用变化的信息,供GC后续处理
// 例如,如果 obj 是黑色,newValue 是白色,
// 可能需要将 obj 重新标记为灰色,或者记录下这个 (obj, newValue) 的关系
postWriteBarrier(obj, field, newValue);
// <--- 写屏障结束 --->
// 执行原始的赋值操作
obj.field = newValue;
}
// 写屏障的具体实现 (伪代码)
void postWriteBarrier(Object obj, Field field, Object newValue) {
// 判断是否满足特定条件 (例如:破坏了三色标记的不变性)
if (isBlack(obj) && isWhite(newValue)) {
// 执行增量更新逻辑
incrementalUpdate(obj, newValue);
}
}
增量更新是CMS用来 解决漏标(对象消失) 问题所采用的具体策略。它关注的是 黑色对象指向白色对象 这种情况的发生。
核心思想:
当一个黑色对象 A 新增了对一个白色对象 B 的引用时 (A.ref = B;
),为了防止 B 被漏标,增量更新策略会通过写屏障捕捉到这个事件,并采取措施 记录 下这个变化。
具体做法:
当写屏障检测到 isBlack(A) && isWhite(B)
的情况时,它 不会 立即把 B 变成灰色(因为并发访问灰色集合也可能存在问题),而是将 A 重新标记回灰色,或者更常见的是,将这个 新增的引用关系 (A, B) 记录在一个 专门的、需要额外扫描的列表 中。
为什么叫“增量”更新?
因为它只关注并发标记过程中 新增 的黑色到白色的引用关系。它假设在标记开始时建立的对象图快照是基础,然后只处理后续发生的“增量”变化。
重新标记阶段的作用:
在 重新标记(Remark) 这个STW阶段,GC线程会:
伪代码示例(增量更新逻辑):
// 增量更新记录列表
List<ReferenceChange> incrementalUpdates = new CopyOnWriteArrayList<>(); // 线程安全列表
// 写屏障中的增量更新实现
void incrementalUpdate(Object blackObj, Object whiteObj) {
// 记录下这个新增的引用关系
// 注意:这里只是示意,实际实现会更复杂和高效
incrementalUpdates.add(new ReferenceChange(blackObj, whiteObj));
// 或者,更简单的做法可能是将 blackObj 重新标记为灰色
// markGray(blackObj); // 但CMS主要采用记录方式
}
// 重新标记阶段的处理逻辑 (伪代码)
void remarkPhase() {
stopTheWorld(); // STW
// 处理增量更新记录
for (ReferenceChange change : incrementalUpdates) {
Object source = change.getSource();
Object target = change.getTarget();
if (isBlack(source) && isWhite(target)) {
// 从 source 开始重新扫描,确保 target 及其可达对象被标记
scanObject(source); // 或者直接标记 target 为灰色 scanObject(target)
}
}
incrementalUpdates.clear(); // 清空记录
// ... 其他重新标记逻辑 (如处理卡表) ...
resumeTheWorld(); // 恢复用户线程
}
与SATB的区别(简单提一下):
G1垃圾收集器采用的是另一种叫做 SATB(Snapshot-At-The-Beginning) 的策略。SATB关注的是 删除 的引用。它通过 写前屏障 记录下那些 即将被删除 的从灰色/黑色对象到白色对象的引用。即使这个引用后来真的被用户线程删除了,SATB也会认为这个白色对象在标记开始时的那个“快照”中是存活的,从而在本轮GC中保留它。SATB能更好地处理浮动垃圾,但实现也更复杂。
总结: CMS通过写后屏障捕捉引用赋值操作,利用增量更新策略记录下并发标记期间黑色对象新增对白色对象的引用,最后在重新标记STW阶段统一处理这些记录,从而保证了并发标记的正确性,防止了“对象消失”的致命错误。
虽然增量更新解决了正确性问题,但如果在重新标记阶段需要扫描所有记录下来的对象以及它们引用的对象,开销仍然可能很大。为了进一步 优化重新标记阶段的扫描范围,CMS(以及很多现代GC)引入了 卡表(Card Table) 机制。
换句话说:
卡表(Card Table)是实现增量更新(Incremental Update)策略的一种高效的技术手段,它优化了“记录修改”这个环节。
增量更新的目标: 是为了解决并发标记中“黑色对象引用了新的白色对象,但GC没发现”的问题。它要求GC必须记录下那些在并发标记期间被修改过的、可能指向新对象的“黑色对象”(或更简单地说,记录下发生过引用写入的区域)。
如何记录?
所以,不是说先有了一个“增量更新”的抽象算法,然后卡表来优化它。
而是:
为了实现“增量更新”这个策略(即在并发标记后重新检查被修改过的区域),需要一种记录修改的方法。
卡表提供了一种非常高效、低开销的记录方法。 它用空间换时间(可能标记了一些不需要的区域),但极大地降低了在应用程序运行时(写屏障触发时)的性能损耗。
什么是卡表?
卡表是一个 位图(Bitmap) 或 字节数组,它将整个 堆内存(尤其是老年代)划分成固定大小的 卡页(Card Page)。卡页的大小通常是 2 的幂次方,例如 512 字节。卡表中的每一个元素(一个比特位或一个字节)就对应堆内存中的一个卡页。
卡表的作用?
卡表用来标记哪些卡页可能包含了 指向其他区域(尤其是新生代指向老年代,或者在CMS并发标记中,老年代内部)的引用,或者更简单地说,标记哪些卡页 “变脏”(Dirty) 了。
写屏障与卡表的联动:
当写屏障检测到一次 跨代引用(新生代对象引用老年代对象,这在Young GC时很重要)或者在CMS并发标记中检测到 老年代内部引用发生变化 时,它除了执行增量更新逻辑(如果需要),还会做一个非常快速的操作:将引用发生地所在的那个卡页,在卡表中对应的标记位/字节,设置为“脏”状态。
伪代码示例(写屏障更新卡表):
// 假设 Card Table 是一个字节数组
byte[] cardTable = ...;
final int CARD_SHIFT = 9; // 卡页大小为 2^9 = 512 字节
final byte DIRTY_CARD = 0; // 脏标记
// JVM 加入写屏障后的伪代码 (Post-Write Barrier with Card Table)
void setField(Object obj, Field field, Object newValue) {
// ... 增量更新逻辑 ...
postWriteBarrier(obj, field, newValue);
// <--- 更新卡表 --->
// 计算 obj 对象所在的卡页索引
long objAddress = getAddress(obj);
int cardIndex = (int)(objAddress >>> CARD_SHIFT);
// 将对应的卡表项标记为脏
// 这里用字节数组示例,实际可能是位操作
if (cardTable[cardIndex] != DIRTY_CARD) {
cardTable[cardIndex] = DIRTY_CARD;
}
// <--- 卡表更新结束 --->
// 执行原始的赋值操作
obj.field = newValue;
}
重新标记阶段如何利用卡表?
在 重新标记(Remark) STW阶段,GC线程不再需要扫描整个老年代来查找可能存在的引用变化。它们只需要:
这极大地缩小了重新标记阶段需要扫描的范围,从而显著缩短了STW时间。
总结: 卡表通过空间换时间的方式,用一个额外的位图/字节数组记录了内存区域的“脏”状态。写屏障在修改引用时快速标记对应的卡页,使得重新标记阶段只需扫描脏页,大大提高了效率。
CMS并发标记的完整保障机制:
三色标记(理论基础)+ 写屏障(监测变化)+ 增量更新(处理新增引用,保正确性)+ 卡表(记录脏区,提效率)= CMS并发标记的组合拳。
我们在前面提到了CMS的几个主要缺点,现在我们来更深入地探讨它们,特别是并发失败、内存碎片和浮动垃圾这三大痛点。
这是使用CMS时最需要关注和尽量避免的问题,因为它会导致长时间的STW。
复习:为什么会发生?
CMS的并发回收(标记、清除)需要时间。如果在GC线程完成回收之前,用户线程持续快速地分配内存(包括Young GC晋升的对象和直接在老年代分配的大对象),导致老年代空间不足以容纳新的对象,就会触发并发失败。本质上是 回收速度跟不上分配速度。
导致并发失败的具体场景:
-XX:CMSInitiatingOccupancyFraction
设置过高,或者应用内存增长模式突变,导致CMS启动回收时,剩余空间不足以支撑到并发回收完成。后果:
JVM会停止所有用户线程(STW),然后调用 Serial Old 收集器(一个单线程、标记-整理算法的收集器)来对整个老年代进行垃圾回收,包括内存整理。这个过程非常缓慢,STW时间可能长达数秒甚至更久。
如何调优避免?
调优的核心思路是:让CMS尽早开始回收,或者让回收过程更快,或者减少内存分配压力。
-XX:CMSInitiatingOccupancyFraction=N
的值(N是百分比,例如60-80),让CMS在老年代占用率达到N%时就提前开始回收,预留更多的时间和空间。这是 最常用 的调优手段。需要根据应用的内存增长速率和GC日志来找到一个合适的值。太低会增加GC频率,太高则容易并发失败。-XX:ConcGCThreads=N
适当增加并发标记和并发清除的线程数(如果CPU资源允许),加快回收速度。但线程过多也会增加CPU开销。-XX:+UseCMSCompactAtFullCollection
(默认开启)。-XX:CMSFullGCsBeforeCompaction=N
。如果并发失败频繁且主要是由碎片引起,可以考虑设置为0,让每次后备的Full GC都进行压缩,但这会增加Full GC的STW时间。更理想的是通过其他方式减少大对象的产生或优化对象生命周期。-Xmx
, -Xms
配合调整新生代比例 -XX:NewRatio
或大小 -Xmn
),可以给CMS更多缓冲空间。-XX:+CMSScavengeBeforeRemark
。在重新标记(STW)之前,先进行一次Young GC。这样做的好处是:
监控: 密切关注GC日志,查找 “Concurrent Mode Failure” 或 “promotion failed” 关键字,分析失败前后的内存使用情况和GC活动。
这是CMS采用标记-清除算法带来的先天不足。
复习:为什么会产生?
标记-清除算法只回收死亡对象占用的空间,但不移动存活对象。回收后,内存中会留下许多不连续的小块空闲区域。
影响:
CMS的应对措施:
CMS本身 不直接 解决并发清除阶段的碎片问题。它依赖于:
-XX:+UseCMSCompactAtFullCollection
和 -XX:CMSFullGCsBeforeCompaction
参数,在发生Full GC(包括并发失败后的Full GC)时进行内存整理。但这本身就是一种“亡羊补牢”,且会带来STW。根本性解决:
真正能较好解决碎片问题的GC算法是 标记-复制(Mark-Copy) 和 标记-整理(Mark-Compact)。这也是为什么后续的G1、ZGC等收集器都采用了不同的策略(如G1的分区复制、ZGC的指针染色与重定位)来避免或处理碎片问题。
复习:为什么会产生?
在CMS并发标记阶段之后、并发清除阶段完成之前,如果用户线程使得某个原本被标记为存活的对象变成了垃圾(断开了所有引用),CMS在本轮GC中无法回收它。
影响:
-XX:CMSInitiatingOccupancyFraction
提前触发回收的必要性。能否解决?
CMS的增量更新机制 无法 解决浮动垃圾问题(它主要解决漏标)。SATB策略(如G1使用)能更好地处理浮动垃圾(因为它基于快照,快照中存活的对象即使后来变垃圾了也会保留到本轮结束),但CMS没有采用。
对于CMS来说,浮动垃圾是其并发设计所必须接受的一个副作用。只能通过合理配置 -XX:CMSInitiatingOccupancyFraction
来为其预留足够的空间。
总结: CMS的这三大痛点——并发失败的风险、内存碎片的积累、浮动垃圾的存在——是其设计上的固有局限。现代GC如G1、ZGC等都在尝试用更先进的技术来克服这些问题。
在CMS还盛行的年代(大约在JDK 6、7、8时期),判断是否使用CMS主要基于以下考量:
简单来说: 如果我们的首要目标是 低延迟,并且愿意牺牲一定的 吞吐量 和 内存空间,同时有足够的 CPU资源,那么CMS在当时是一个不错的选择。
随着技术的发展和更优秀替代品的出现,CMS逐渐暴露出的缺点和维护成本使其最终被淘汰。主要原因包括:
-XX:MaxGCPauseMillis
),用户可以设定期望的最大停顿时间。结论: G1在解决了CMS核心痛点(碎片、并发失败可控性、可预测停顿)的同时,提供了相当不错的性能表现,并且配置相对更简单,成为了JDK 9及以后版本的默认垃圾收集器。这使得CMS的历史使命基本完成,被废弃和移除也就顺理成章了。
CMS作为第一款真正意义上的 并发 垃圾收集器,在JVM发展史上具有里程碑式的意义。它首次将“低延迟”作为核心设计目标,并通过创新的并发标记和并发清除技术,极大地改善了对响应时间敏感的应用的用户体验。
虽然CMS因为其固有的设计缺陷(内存碎片、并发失败风险、CPU消耗)以及更优秀的替代者(G1、ZGC等)的出现而被逐渐淘汰,但学习和理解CMS的工作原理仍然非常有价值:
CMS就像一位开创了新道路但自身并非完美的先行人。它证明了并发垃圾收集的可行性,为后续更先进、更完善的垃圾收集器的诞生铺平了道路。