CMS概念:
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体验的应用上使用,它是HotSpot虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。从名字中的Mark Sweep这两个词可以看出,CMS收集器是一种"标记-清除"算法实现的。
宏观流程

关键数据:
-
Initial Mark (初始标记):暂停时间通常 < 100ms
-
Remark(重新标记):暂停时间通常 100-500ms(取决于对象变更量)
-
其余并发阶段:完全不暂停
详细七阶段:(3、4合并到并发标记)

CMS 线程模型
java
┌──────────────────────────────────────────────────────────┐
│ Java 应用线程 │
│ Thread-1 Thread-2 Thread-3 ... Thread-N │
├──────────────────────────────────────────────────────────┤
│ CMS 后台线程 │
│ ┌──────────────────────┐ │
│ │ CMS Concurrent Thread│ ← 并发标记 / 并发清除 │
│ └──────────────────────┘ │
├──────────────────────────────────────────────────────────┤
│ GC 任务控制 │
│ CMSCollector ← 协调 STW 阶段与并发阶段切换 │
└──────────────────────────────────────────────────────────┘
CMS 的并发阶段会消耗 CPU 资源,这也是它对 CPU 核数敏感的原因------核数不够时,并发线程和应用线程争抢 CPU,反而拖慢整个系统。
初始标记(Initial Mark)
暂停所有的其他线程(STW),并记录下gc roots直接能引用的对象,速度很快。
目的:找到 GC Roots 直接引用的老年代对象。
标记范围:仅 GC Roots 的直达引用
执行线程:单线程(CMS 线程)
是否 STW:是
耗时:极短,ms 级
GC Roots 包括但不限于:
-
栈帧中的局部变量表引用
-
静态变量(static field)
-
JNI 引用(native 栈中的 global ref)
-
被同步监视器持有的对象引用
-
JVM 内部的 Class 对象、ClassLoader、StringTable 等
为什么只用单线程?这个阶段本身极快(几十毫秒),用多线程反而线程调度的开销占比太大,得不偿失。
java
// 一段代码说明 GC Roots
public class RootDemo {
private static Object staticRef = new Object(); // ← GC Root(静态变量)
public void foo() {
Object localRef = new Object(); // ← GC Root(栈帧局部变量表)
// 此时 localRef 指向的对象是可达的
bar();
// localRef 出作用域后不再是 GC Root
}
private native void nativeMethod(); // JNI 全局引用也是 GC Root
}
并发标记(Concurrent Mark)
并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行。因为用户程序继续运行,可能会有导致已经标记过的对象状态发生改变
目的:从初代标记找到的 GC Roots 出发,遍历整个对象图,标记所有存活对象。
标记范围:GC Roots 可达的整个老年代对象图
执行线程:CMS 并发线程(默认 1/4 核数)
是否 STW:否
耗时:最长,占总周期 80%+
核心挑战:应用线程和标记线程同时运行。标记过程中,应用线程可能:
-
修改了某个对象的引用(黑色对象 → 白色对象的引用被删除)
-
创建了新对象,并将其赋给一个已经被标记过的对象
这会导致漏标------一个存活对象被错误地当作垃圾回收,程序随后访问到已回收的内存,直接 crash。
CMS 使用 **Incremental Update(增量更新)*来解决这个问题
java
// 并发标记期间的 race condition 示例
public class ConcurrentMutator {
private Object a = new Object();
private Object b = new Object();
// 标记线程正在遍历 a → b 的引用...
// 此时,应用线程执行:
public void dangerousMutation() {
a = null; // 断开 a 的引用(标记线程可能已经过了 a)
// 如果 b 没有被其他路径标记到,b 会被误判为垃圾。
// CMS 通过写屏障记录这种变更,在 Remark 阶段重新扫描。
}
}
并发预清理(Concurrent Preclean)
并发处理新生代和老年代间的引用变更,为最终标记做准备
目的:在 Remark 之前尽量多地处理并发标记期间积累的变更,减少 Remark 的 STW 时间。
标记范围:Card Table dirty entries + 新生代
执行线程:CMS 并发线程
是否 STW:否
耗时: 通常短于并发标记,但可以配置多次循环
预清理阶段做两件事:
-
重新扫描被标记为 dirty的卡(Card Table entries)
-
扫描新生代对象(因为新生代对象可能是老年代对象的 GC Root)
可中断的预清理(`CMSPrecleaningEnabled`,默认 true):
java
while (新生代 GC 次数 < CMSMaxAbortablePrecleanLoops
&& 累计用时 < CMSMaxAbortablePrecleanTime) {
处理 dirty cards;
如果新生代即将满 → 主动触发 Young GC;
重新计算剩余工作量;
}
这个设计的精妙之处:它利用可中断预清理等待一次 Young GC 发生,因为 Young GC 后会大幅减少新生代到老年代的引用数量,从而大幅减少 Remark 需要扫描的对象数。
重新标记(Remark 也叫最终标记)
重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录(主要是处理漏标问题),这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短。主要用到三色标记里的增量更新算法(见下面详解)做重新标记。
目的:处理并发预清理阶段遗漏的引用变更,完成最终标记。
标记范围:并发标记 + 并发预清理期间的所有变更
执行线程:多线程 STW(默认 1 个,可通过 ParallelCMSThreads 调大)
是否 STW:是(第二次,也是最后一次大暂停)
耗时: 通常 100ms-500ms
java
Remark 阶段内部拆解:
1. Stop the world(所有线程到达安全点)
2. 处理 Mod Union Table 中的所有 dirty cards
- 重新扫描老年代中被修改的引用
3. 扫描整个新生代(GC Roots 补充)
- 新生代的对象可能引用了老年代对象
4. 重新扫描 Reference 对象(软、弱、虚、Finalizer)
5. 处理 JNI 弱引用
6. 扫描 Symbol Table 和 String Table
7. 恢复世界(Resume the world)
为什么 Remark 比 Initial Mark 长?因为 Initial Mark 只扫描 GC Roots 直达的对象,而 Remark 需要重新扫描并发阶段产生的所有变更------包括整个新生代和老年代的 dirty cards。
java
// -XX:+CMSScavengeBeforeRemark (JDK 6/7 推荐,JDK 8+ 默认)
// 在 Remark 前主动触发一次 Young GC,效果:
//
// 副作用:增加一次 Young GC 暂停
// 收益: 新生代被清空 → Remark 需要扫描的新生代引用大幅减少 → Remark 暂停时间降低
//
// 如果 Young GC 的暂停 + Reduce 后的 Remark 暂停 < 不触发 Young GC 的原始 Remark 暂停
// → 总暂停时间减少 ✓
// 否则 → 总暂停时间增加 ✗(通常不会)
并发清除(Concurrent Sweep 也叫并发清理)
开启用户线程,同时GC线程开始对未标记的区域做清扫。这个阶段如果有新增对象会被标记为黑色不做任何处理(见下面三色标记算法详解)。
目的:将标记为垃圾的对象回收,将其占用的内存标记为空闲。
清除范围:所有未标记的对象
执行线程:CMS 并发线程
是否 STW:否
耗时: 与老年代存活对象占比相关
CMS 使用的不是真正的 "Sweep",而是 "Free List" 管理:
标记-清除 ≠ 标记-整理
CMS 不会移动存活对象。所以它的内存管理不是靠指针碰撞(Bump Pointer),而是靠空闲链表(Free List)------把释放出来的空间挂到一个链表中,下次分配时从链表中找合适的块。
**这导致两个严重问题:
- 碎片化:连续空闲空间被切割成小块
- 分配效率降低:从 Free List 分配比指针碰撞慢**
并发重置(Concurrent Reset)
重置本次GC过程中的标记数据。
目的:重置 CMS 内部数据结构,为下一次 GC 周期做准备。
执行内容:
-
清空 Card Table dirty entries
-
清空 Mod Union Table
-
重置标记位
-
重置 CMS 状态机
这个阶段非常快,通常是毫秒级。之后 CMS 进入 Idle状态,等待下一次触发。
CMS缺点:
1.对CPU资源敏感(会和服务抢资源);
2.无法处理浮动垃圾(在并发标记和并发清理阶段又产生垃圾,这种浮动垃圾只能等到下一次gc再清理了);
3.它使用的回收算法-"标记-清除"算法会导致收集结束时会有大量空间碎片产生,当然通过参数-XX:+UseCMSCompactAtFullCollection可以让jvm在执行完标记清除后再做整理。
4.执行过程中的不确定性,会存在上一次垃圾回收还没执行完,然后垃圾回收又被触发的情况,特别是在并发标记和并发清理阶段会出现,一边回收,系统一边运行,也许没回收完就再次触发full gc,也就是"concurrent mode failure",此时会进入stop the world,用serial old垃圾收集器来回收。
核心数据结构:Card Table 与 Mod Union Table
记忆集与卡表
在新生代做GCRoots可达性扫描过程中可能会碰到跨代引用的对象,这种如果又去对老年代再去扫描效率太低了。为此,在新生代可以引入记录集(Remember Set)的数据结构(记录从非收集区到收集区的指针集合),避免把整个老年代加入GCRoots扫描范围。事实上并不只是新生代、 老年代之间才有跨代引用的问题, 所有涉及部分区域收集(Partial GC) 行为的垃圾收集器, 典型的如G1、 ZGC和Shenandoah收集器, 都会面临相同的问题。垃圾收集场景中,收集器只需通过记忆集判断出某一块非收集区域是否存在指向收集区域的指针即可,无需了解跨代引用指针的全部细节。
hotspot使用一种叫做"卡表"(Cardtable)的方式实现记忆集,也是目前最常用的一种方式。关于卡表与记忆集的关系, 可以类比为Java语言中HashMap与Map的关系。
卡表是使用一个字节数组实现:CARD_TABLE[ ],每个元素对应着其标识的内存区域一块特定大小的内存块,称为"卡页"。hotSpot使用的卡页是2^9大小,即512字节

一个卡页中可包含多个对象,只要有一个对象的字段存在跨代指针,其对应的卡表的元素标识就变成1,表示该元素变脏,否则为0.
GC时,只要筛选本收集区的卡表中变脏的元素加入GCRoots里。
卡表的维护
卡表变脏上面已经说了,但是需要知道如何让卡表变脏,即发生引用字段赋值时,如何更新卡表对应的标识为1。
Hotspot使用写屏障维护卡表状态。
为什么需要 Card Table
分代 GC 面临一个经典问题------跨代引用(Cross-Generation Reference):
java
老年代对象 A ──引用──► 新生代对象 B
│
▼
════════════════
Young GC 来了
════════════════
问题:Young GC 只扫描新生代。如果不扫描老年代中指向新生代的引用,
对象 B 虽然是活的,但会被当成垃圾回收。
解决:不可能每次 Young GC 都扫描整个老年代(太慢)。
→ Card Table:用空间换时间。
Card Table 结构
java
Card Table 是一个字节数组,每个字节对应堆中 512 字节的一段空间(一张"卡")。
堆内存:
╔════╤════╤════╤════╤════╤════╤════╤════╗
║ C0 │ C1 │ C2 │ C3 │ C4 │ C5 │ C6 │ C7 ║ 每卡 512B
╚════╧════╧════╧════╧════╧════╧════╧════╝
Card Table: [0] [1] [0] [0] [1] [0] [0] [0]
↑ ↑
dirty dirty
当引用赋值操作修改了 C0 或 C4 范围内的引用时:
- 写屏障将 Card Table[0] / Card Table[4] 标记为 dirty
- Young GC 只需扫描 dirty cards 对应的老年代区域
- 不需要扫描整个老年代
Mod Union Table
CMS 引入了一个额外的结构 ~~Mod Union Table~~ 来解决一个问题:
并发标记期间,Card Table 的 dirty 标记可能被多次覆盖。
场景:
-
并发标记开始
-
应用线程修改 obj.x = newValue → Card[K] = dirty
-
并发标记线程扫描到 Card[K],将其重置为 clean
-
应用线程再次修改 obj.y = another → Card[K] = dirty
-
到这步,Card[K] 是 dirty,但 CMS 已经扫过了------它不会再回来检查!
Mod Union Table 就是用来"记住"哪些卡在并发标记期间曾被标记为 dirty,确保 Remark 阶段不会遗漏。
java
Core Mechanism:
时刻 Card Table Mod Union Table
──────────────────────────────────────────────
T0 初始 [clean,clean] [clean,clean]
T1 变异 [dirty,clean] [clean,clean]
T2 CMS 扫描 [clean,clean] [dirty,clean] ← 记录"这一轮看到过 dirty"
T3 再次变异 [dirty,clean] [dirty,clean]
T4 Remark 扫描所有 Mod Union = dirty 的卡
CMS参数
CMS的相关核心参数
1.-XX:+UseConcMarkSweepGC:启用cms
2.-XX:ConcGCThreads:并发的GC线程数
3.-XX:+UseCMSCompactAtFullCollection:FullGC之后做压缩整理(减少碎片)
4.-XX:CMSFullGCsBeforeCompaction:多少次FullGC之后压缩一次,默认是0,代表每次FullGC后都会压缩一次
5.-XX:CMSInitiatingOccupancyFraction:当老年代使用达到该比例时会触发FullGC(默认是92,这是百分比)
6.-XX:+UseCMSInitiatingOccupancyOnly:只使用设定的回收阈值(-XX:CMSInitiatingOccupancyFraction设定的值),如果不指定,
JVM仅在第一次使用设定值,后续则会自动调整
7.-XX:+CMSScavengeBeforeRemark:在CMS GC前启动一次minor gc,降低CMS GC标记阶段(也会对年轻代一起做标记,如果在minor gc就干掉了很多对垃圾对象,标记阶段就会减少一些标记时间)时的开销,一般CMS的GC耗时80%都在标记阶段
8.-XX:+CMSParallellnitialMarkEnabled:表示在初始标记的时候多线程执行,缩短STW
9.-XX:+CMSParallelRemarkEnabled:在重新标记的时候多线程执行,缩短STW;
垃圾收集底层算法实现
三色标记
在并发标记的过程中,因为标记期间应用线程还在继续跑,对象间的引用可能发生变化,多标和漏标的情况就有可能发生。漏标的问题主要引入了三色标记算法来解决。
三色标记算法是把GC roots可达性分析遍历对象过程中遇到的对象, 按照"是否访问过"这个条件标记成以下三种颜色:
黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象。
灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。
白色:表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始的阶段,所有的对象都是白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。

⚪ 白色(White):尚未被标记线程访问过
GC 结束时,白色对象 = 垃圾
⚫ 灰色(Gray):已被访问,但其引用字段尚未全部扫描完
标记线程当前的"工作前线"
⚫ 黑色(Black):已被访问,且所有引用字段都已扫描完
标记线程已经"过了"这个对象
标记过程:
扫描引用
⚪ (白) ────► ⚫ (灰) ────► ⚫ (黑)
未访问 访问中 扫描完成
初始状态:所有对象白色,GC Roots 直接标记为灰色
结束状态:没有灰色对象,剩余白色对象 = 垃圾
多标-浮动垃圾
在并发标记过程中,如果由于方法运行结束导致部分局部变量(gcroot)被销毁,这个gcroot引用的对象之前又被扫描过(被标记为非垃圾对象),那么本轮GC不会回收这部分内存。这部分本应该回收但是没有回收到的内存,被称之为"浮动垃圾"。浮动垃圾并不会影响垃圾回收的正确性,只是需要等到下一轮垃圾回收中才被清除。
另外,针对并发标记(还有并发清理)开始后产生的新对象,通常的做法是直接全部当成黑色,本轮不会进行清除。这部分对象期间可能也会变为垃圾,这也算是浮动垃圾的一部分。
漏标-读写屏障
漏标会导致被引用的对象被当成垃圾误删除,这是严重bug,必须解决,有两种解决方案:增量更新(Incremental Update) 和原始快照(Snapshot At The Beginning,SATB) 。
增量更新 就是当黑色对象插入新的指向白色对象的引用关系时, 就将这个新插入的引用记录下来, 等并发扫描结束之后, 再将这些记录过的引用关系中的黑色对象为根, 重新扫描一次。 这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后, 它就变回灰色对象了。
原始快照 就是当灰色对象要删除指向白色对象的引用关系时, 就将这个要删除的引用记录下来, 在并发扫描结束之后, 再将这些记录过的引用关系中的灰色对象为根, 重新扫描一次,这样就能扫描到白色的对象,将白色对象直接标记为黑色(目的就是让这种对象在本轮gc清理中能存活下来,待下一轮gc的时候重新扫描,这个对象也有可能是浮动垃圾)
以上无论是对引用关系记录的插入还是删除, 虚拟机的记录操作都是通过写屏障实现的。
写屏障
给某个对象的成员变量赋值时,其底层代码大概长这样:
java
/**
* @param field某对象的成员变量,如a.b.d
* @param new_value新值,如null
*/
void oop_field_store(oop* field, oop new_value) {
*field = new_value; //赋值操作
}
所谓的写屏障,其实就是指在赋值操作前后,加入一些处理(可以参考AOP的概念):
java
void oop_field_store(oop* field, oop new_value) {
pre_write_barrier(field);//写屏障-写前操作
*field = new_value;
post_write_barrier(field, value);//写屏障-写后操作
}
写屏障实现SATB
当对象B的成员变量的引用发生变化时,比如引用消失(a.b.d = null),我们可以利用写屏障,将B原来成员变量的引用对象D记录下来:
java
void pre_write_barrier(oop* field) {
oop old_value = *field;//获取旧值
remark_set.add(old_value); //记录原来的引用对象
}
写屏障实现增量更新
当对象A的成员变量的引用发生变化时,比如新增引用(a.d = d),我们可以利用写屏障,将A新的成员变量引用对象D记录下来:
java
void post_write_barrier(oop* field, oop new_value) {
remark_set.add(new_value);//记录新引用的对象
}
读屏障
java
oop oop_field_load(oop* field) {
pre_load_barrier(field); //读屏障-读取前操作
return *field;
}
读屏障是直接针对第一步:D d = a.b.d,当读取成员变量时,一律记录下来:
java
void pre_load_barrier(oop* field) {
oop old_value = *field;
remark_set.add(old_value); //记录读取到的对象
}
现代追踪式(可达性分析)的垃圾回收器几乎都借鉴了三色标记的算法思想,尽管实现的方式不尽相同:比如白色/黑色集合一般都不会出现(但是有其他体现颜色的地方)、灰色集合可以通过栈/队列/缓存日志等方式进行实现、遍历方式可以是广度/深度遍历等等。
对于读写屏障,以Java HotSpot VM为例,其并发标记时对漏标的处理方案如下:
CMS:写屏障+增量更新
G1,Shenandoah:写屏障+ SATB
ZGC:读屏障
工程实现中,读写屏障还有其他功能,比如写屏障可以用于记录跨代/区引用的变化,读屏障可以用于支持移动对象的并发执行等。功能之外,还有性能的考虑,所以对于选择哪种,每款垃圾回收器都有自己的想法。
为什么G1用SATB?CMS用增量更新?
个人理解:SATB相对增量更新效率会高(当然SATB可能造成更多的浮动垃圾),因为不需要在重新标记阶段再次深度扫描被删除引用对象,而CMS对增量引用的根对象会做深度扫描,G1因为很多对象都位于不同的region,CMS就一块老年代区域,重新深度扫描对象的话G1的代价会比CMS高,所以G1选择SATB不深度扫描对象,只是简单标记,等到下一轮GC再深度扫描。
以上均为个人观点!
以上均为个人观点!
以上均为个人观点!