CMS 垃圾收集器深度解析
CMS 垃圾收集器深度解析
- 引言:为什么我们需要关注 CMS?
- 第一章:GC 基础与 JVM 内存结构回顾
- 1.1 JVM 运行时数据区
- 1.2 什么是垃圾?如何判断对象是否存活?
- 1.3 垃圾回收算法
- 1.4 分代收集理论
- 第二章:CMS 核心原理与工作流程
- 2.1 CMS 的设计目标与定位
- 2.2 CMS 的工作流程
- 第三章:CMS 核心机制深度剖析 [难点]
- 3.1 三色标记法 (Tri-color Marking)
- 3.2 写屏障 (Write Barrier) 与 增量更新 (Incremental Update)
- 3.3 卡表 (Card Table)
- 第四章:CMS 的挑战与缺点
- 4.1 CPU 资源敏感
- 4.2 无法处理浮动垃圾 (Floating Garbage)
- 4.3 内存碎片问题
- 4.4 并发失败 (Concurrent Mode Failure)
- 第五章:CMS 实战:调优与日志分析
- 5.1 关键 JVM 参数详解
- 5.2 GC 日志分析
- 5.3 常见问题与调优案例
- 第六章:CMS 的替代者与未来
- 6.1 CMS 为何被废弃?
- 6.2 G1 收集器:CMS 的继任者
- 6.3 ZGC 与 Shenandoah:追求极致低延迟
- 第七章:总结与展望
引言:为什么我们需要关注 CMS?
在 Java 虚拟机里,垃圾收集(Garbage Collection, GC)是一个永恒的话题。
它像一个默默无闻的扫地僧,自动管理着内存,让开发者能够专注于业务逻辑,而不必像 C/C++ 程序员那样手动 malloc
和 free
。
然而,这个“自动”的过程并非没有代价,不了解 GC 的工作原理,可能会在某些场景下遭遇性能瓶颈,甚至服务中断。
CMS(Concurrent Mark Sweep)收集器,是 HotSpot 虚拟机中第一款真正意义上的并发收集器。它的出现,标志着 GC 技术向着降低应用停顿时间迈出了重要一步。虽然在最新的 JDK 版本中,CMS 已被更先进的 G1、ZGC、Shenandoah 等收集器逐步取代,但理解 CMS 的设计思想、工作原理、优缺点以及调优方法,对于我们理解现代 GC 技术的发展脉络、排查老系统中的 GC 问题、甚至为新系统选择和调优 GC 策略,都具有非常重要的意义。
第一章:GC 基础与 JVM 内存结构回顾
在深入 CMS 之前,我们需要先回顾一下 JVM 的内存结构以及垃圾回收的基础知识。这是理解所有 GC 算法和收集器的基石。
1.1 JVM 运行时数据区
根据《Java 虚拟机规范》的规定,JVM 在执行 Java 程序时会把它所管理的内存划分为若干个不同的数据区域。这些区域各有用途,有的随着虚拟机进程的启动而存在,有的则依赖用户线程的启动和结束而建立和销毁。
±----------------------------------------------------+
| JVM 内存区域 |
±----------------------------------------------------+
| 线程共享区域 | 线程私有区域 |
±-------------------------±------------------------+
| Java 堆 (Heap) | 程序计数器 (PC Reg) |
| 方法区 (Method Area) | Java 虚拟机栈 (Stack) |
| (元空间 Metaspace in >=8)| 本地方法栈 (Native Stack)|
±-------------------------±------------------------+
- 线程私有区域:
- 程序计数器 (Program Counter Register): 当前线程所执行的字节码的行号指示器。它是唯一一个在 Java 虚拟机规范中没有规定任何
OutOfMemoryError
情况的区域。 - Java 虚拟机栈 (Java Virtual Machine Stack): 每个方法执行时,JVM 都会同步创建一个栈帧(Stack Frame),用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每个方法从调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。如果线程请求的栈深度大于虚拟机所允许的深度,将抛出
StackOverflowError
异常;如果 JVM 栈容量可以动态扩展,当栈扩展时无法申请到足够的内存会抛出OutOfMemoryError
异常。 - 本地方法栈 (Native Method Stack): 与虚拟机栈类似,但它为虚拟机使用到的 Native 方法服务。也会抛出
StackOverflowError
和OutOfMemoryError
。
- 程序计数器 (Program Counter Register): 当前线程所执行的字节码的行号指示器。它是唯一一个在 Java 虚拟机规范中没有规定任何
- 线程共享区域:
- Java 堆 (Java Heap): JVM 所管理的内存中最大的一块。被所有线程共享,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。Java 堆是垃圾收集器管理的主要区域,因此也被称作“GC 堆”。堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,JVM 将抛出
OutOfMemoryError
异常。 - 方法区 (Method Area): 用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。虽然《Java 虚拟机规范》把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做“非堆”(Non-Heap),目的是与 Java 堆区分开来。
- 永久代 (Permanent Generation, PermGen): 在 JDK 7 及以前,HotSpot 使用永久代来实现方法区。容易发生
OutOfMemoryError: PermGen space
。 - 元空间 (Metaspace): 从 JDK 8 开始,HotSpot 使用元空间代替永久代。元空间使用的是本地内存(Native Memory),而非 JVM 堆内存。默认情况下,元空间的大小仅受本地内存限制。可以通过
-XX:MetaspaceSize
和-XX:MaxMetaspaceSize
设置。
- 永久代 (Permanent Generation, PermGen): 在 JDK 7 及以前,HotSpot 使用永久代来实现方法区。容易发生
- Java 堆 (Java Heap): JVM 所管理的内存中最大的一块。被所有线程共享,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。Java 堆是垃圾收集器管理的主要区域,因此也被称作“GC 堆”。堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,JVM 将抛出
CMS 主要工作的区域是 Java 堆中的老年代。
1.2 什么是垃圾?如何判断对象是否存活?
GC 的首要任务是确定哪些对象是“垃圾”(不再被任何存活的对象引用的对象)。Java 虚拟机使用**可达性分析(Reachability Analysis)**算法来判定对象是否存活。
- 基本思路: 通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain)。如果某个对象到 GC Roots 间没有任何引用链相连(用图论的话来说,就是从 GC Roots 到这个对象不可达),则证明此对象是不可能再被使用的。
- GC Roots 对象: 在 Java 技术体系中,固定可作为 GC Roots 的对象包括以下几种:
- 在虚拟机栈(栈帧中的本地变量表)中引用的对象(例如:方法参数、局部变量等)。
- 在方法区中类静态属性引用的对象(例如:
public static Object instance = new Object();
中的instance
)。 - 在方法区中常量引用的对象(例如:字符串常量池里的引用)。
- 在本地方法栈中 JNI(即通常所说的 Native 方法)引用的对象。
- Java 虚拟机内部的引用(如基本数据类型对应的 Class 对象,一些常驻的异常对象等)。
- 所有被同步锁(
synchronized
关键字)持有的对象。 - 反映 Java 虚拟机内部情况的 JMXBean、JVMTI 中注册的回调、本地代码缓存等。
[理解帮助] 想象一下 GC Roots 就是你生活中必须用到的一些核心物品(比如你的家、你的工位)。从这些核心物品出发,能直接或间接找到的东西(比如家里的电视、工位上的电脑)就是存活的。那些找不到的、失联的东西(比如你遗落在某个角落很久没用过的旧物件)就可能被当作垃圾处理掉。
1.3 垃圾回收算法
确定了哪些是垃圾之后,就需要进行回收。常见的垃圾回收算法有:
-
标记-清除 (Mark-Sweep):
- 原理: 分为“标记”和“清除”两个阶段。首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来标记存活的对象,统一回收未被标记的对象。
- 优点: 实现简单,不需要移动对象。
- 缺点:
- 执行效率不稳定: 如果 Java 堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低。
- 内存碎片化: 标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。CMS 收集器主要基于此算法。
[ 标记前 ] [ ObjA | Free | ObjB | Free | ObjC ] [ 标记后 ] [ ObjA(Live) | Free | ObjB(Dead) | Free | ObjC(Live) ] [ 清除后 ] [ ObjA(Live) | Free | Free | Free | ObjC(Live) ] <-- 产生碎片
-
标记-复制 (Mark-Copy):
- 原理: 将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
- 优点: 实现简单,运行高效,不会产生内存碎片。
- 缺点: 将可用内存缩小为了原来的一半,空间浪费较大。如果存活对象很多,复制开销会很大。常用于新生代回收(如 Serial、ParNew、Parallel Scavenge)。
[ 使用区 From ] [ ObjA(Live) | ObjB(Dead) | ObjC(Live) | Free ] [ 空闲区 To ] [ ] [ 复制后 ] [ ] [ 清空 From ] [ ObjA(Live) | ObjC(Live) | Free ] <-- 切换 To 为使用区
-
标记-整理 (Mark-Compact):
- 原理: 标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。
- 优点: 解决了内存碎片问题,不需要像复制算法那样预留空间。
- 缺点: 移动对象需要更新引用,效率相对较低,需要暂停用户线程(Stop-The-World, STW)。常用于老年代回收(如 Serial Old、Parallel Old)。
[ 标记前 ] [ ObjA | Free | ObjB | Free | ObjC ] [ 标记后 ] [ ObjA(Live) | Free | ObjB(Dead) | Free | ObjC(Live) ] [ 整理后 ] [ ObjA(Live) | ObjC(Live) | Free ] <-- 无碎片
1.4 分代收集理论
当前商业虚拟机的垃圾收集器,大多数都遵循了“分代收集”(Generational Collection)的理论进行设计
。其核心思想是根据对象存活周期的不同将内存划分为几块。一般是把 Java 堆分为新生代(Young Generation)和老年代(Old Generation),这样就可以根据各个年代的特点采用最适当的收集算法。
- 理论基础 (弱分代假说 Weak Generational Hypothesis):
- 绝大多数对象都是朝生夕灭的。
- 熬过越多次垃圾收集过程的对象就越难以消亡。
- 新生代 (Young Generation):
- 特点: 大量对象创建后很快消亡,只有少量对象能存活。
- 回收算法: 通常选用标记-复制算法。因为每次回收只需复制少量存活对象,效率高。
- 内部划分: 通常划分为一个 Eden 区和两个 Survivor 区(From 和 To,默认比例 8:1:1)。对象优先在 Eden 区分配。当 Eden 区满时触发 Minor GC (或 Young GC)。存活的对象会被复制到其中一个 Survivor 区,并增加年龄计数。当年龄达到一定阈值(默认 15)或 Survivor 区空间不足时,对象会晋升到老年代。
- 老年代 (Old Generation):
- 特点: 对象存活率高,没有额外空间进行分配担保。
- 回收算法: 通常选用标记-清除或标记-整理算法。回收频率相对较低。发生在老年代的 GC 称为 Major GC 或 Full GC (Full GC 通常还包括新生代和方法区的回收)。CMS 主要工作在老年代。
跨代引用: 新生代的对象可能会引用老年代的对象,反之亦然。如果发生 Young GC 时需要扫描整个老年代来查找对新生代的引用,效率会很低。为了解决这个问题,引入了**卡表(Card Table)**的技术。卡表是一个位图,用于标记老年代的某一块内存区域(卡页,Card Page)是否存在指向新生代对象的引用。进行 Young GC 时,只需扫描卡表中被标记为“脏”(Dirty)的卡页对应的老年代区域即可,大大减少了扫描范围。我们将在后面详细讨论 CMS 中的卡表应用。
第二章:CMS 核心原理与工作流程
了解了 GC 基础后,我们终于可以深入 CMS 了。
2.1 CMS 的设计目标与定位
CMS(Concurrent Mark Sweep)收集器是 HotSpot 虚拟机中第一款以获取最短回收停顿时间为目标的老年代收集器。在注重服务器响应速度、希望系统停顿时间尽可能短的应用场景下,CMS 发挥着重要作用,例如网站的后端服务、API 网关、实时交易系统等。
它的核心设计哲学是:尽可能让垃圾收集线程与用户线程并发执行,从而减少因 GC 导致的应用程序暂停(Stop-The-World, STW)。
但是,需要明确的是,CMS 并非没有 STW,它只是尽量缩短了 STW 的时间。它通过将原本需要在 STW 中完成的大部分标记工作,以及清除工作,分离出来并发执行,从而实现了低停顿的目标。
与并行(Parallel)收集器的区别:
- 并发 (Concurrent): 指垃圾收集器线程与用户线程同时执行(不一定是物理上的同一时刻,可能交替执行)。关注点是减少停顿时间,提升用户体验。CMS 是典型的并发收集器。
- 并行 (Parallel): 指多条垃圾收集线程同时工作,但此时用户线程仍然处于等待状态 (STW)。关注点是提高 GC 效率,缩短 GC 总耗时,提升吞吐量。例如 Parallel Scavenge(新生代)、Parallel Old(老年代)。
CMS 通过并发执行降低了停顿时间,但由于 GC 线程需要和用户线程争抢 CPU 资源,可能会降低系统的总吞吐量(用户代码执行时间 / (用户代码执行时间 + GC 时间))。
2.2 CMS 的工作流程
CMS 的整个回收过程主要分为以下四个阶段:
-
初始标记 (Initial Mark):
- 任务: 标记 GC Roots 能直接关联到的对象。
- 特点: 需要 Stop-The-World (STW)。
- 耗时: 速度很快,因为只标记直接关联的对象,工作量小。
- [理解帮助] 就像快速给所有班级的班长(GC Roots)打上标记,告诉大家“我要从你们开始找人了”。
-
并发标记 (Concurrent Mark):
- 任务: 从初始标记阶段找到的 GC Roots 开始,递归遍历整个对象引用图,标记所有可达的(存活的)对象。
- 特点: 与用户线程并发执行,不需要 STW。这是 CMS 最核心、耗时最长的阶段。
- 挑战: 由于用户线程仍在运行,对象引用关系可能发生变化,导致标记结果可能不准确(后面详述)。
- [理解帮助] GC 线程开始顺着班长找到学习委员,再找到课代表… 一路找下去,标记所有能找到的同学。同时,其他同学(用户线程)还在自由活动,可能会互相传递纸条(修改引用)。
-
重新标记 (Remark):
- 任务: 修正并发标记期间,因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录。主要是处理那些在并发标记过程中,引用关系发生变化的对象(例如:原本未标记的对象被存活对象引用了,或者原本标记为存活的对象断开了引用)。
- 特点: 需要 Stop-The-World (STW)。
- 耗时: 通常比初始标记稍长,但远比并发标记短。这个阶段的效率很大程度上依赖于并发标记期间引用变化的多少。CMS 通过卡表和增量更新等机制来优化这个阶段。
- [理解帮助] 为了确保没有遗漏,需要再次暂停所有人(STW),然后根据并发标记期间记录的“纸条传递”信息(写屏障记录的变化),快速检查并修正标记。比如,之前没被标记的小明,在并发标记时收到了班长传来的纸条,现在就要把他补标记上。
-
并发清除 (Concurrent Sweep):
- 任务: 清除在标记阶段判断的已死亡对象,回收它们占用的内存空间。
- 特点: 与用户线程并发执行,不需要 STW。
- 挑战:
- 由于基于标记-清除算法,会产生内存碎片。
- 并发清除阶段用户线程可能产生新的垃圾,称为浮动垃圾 (Floating Garbage),这些垃圾只能等到下一次 GC 才能回收。
- [理解帮助] GC 线程开始清理那些没有被标记的同学的位置(回收内存)。同时,其他同学(用户线程)仍然在活动,可能会产生新的垃圾(比如扔掉的废纸),但这次清理顾不上了,只能下次再说。
总结流程图:
CMS 通过将耗时最长的标记和清除过程并发化,显著降低了 STW 时间,从而实现了低延迟的目标。但并发执行也带来了新的挑战,需要额外的机制来保证正确性和效率,这正是我们接下来要深入探讨的。
第三章:CMS 核心机制深度剖析 [难点]
并发标记和并发清除是 CMS 实现低延迟的关键,但也引入了复杂性。本章将深入探讨 CMS 如何通过三色标记法、写屏障、卡表等机制来解决并发带来的问题。
3.1 三色标记法 (Tri-color Marking)
为了在并发标记过程中追踪对象的可达性,CMS(以及 G1、ZGC 等现代并发收集器)使用了三色标记法。它将 GC 过程中遇到的对象逻辑上分为三种颜色:
- 白色 (White): 表示对象尚未被垃圾收集器访问过。在标记阶段开始时,所有对象都是白色的。如果在标记阶段结束时,对象仍然是白色,则表示该对象是不可达的(垃圾)。
- 灰色 (Gray): 表示对象已经被垃圾收集器访问过,但这个对象至少还有一个引用没有被扫描过。灰色是白色到黑色过渡的中间状态。GC 需要继续处理灰色对象,扫描它们的引用。
- 黑色 (Black): 表示对象已经被垃圾收集器访问过,且该对象的所有引用都已经被扫描完毕。黑色对象代表它自身和它直接或间接引用的对象都是存活的。黑色对象不会被再次扫描(除非被写屏障特殊处理)。
标记过程:
- 初始标记: 将所有 GC Roots 直接引用的对象标记为灰色,放入待处理队列。其他所有对象初始为白色。
- 并发标记:
- 从待处理队列中取出一个灰色对象。
- 扫描该灰色对象的所有引用:
- 如果引用指向一个白色对象,则将该白色对象标记为灰色,放入待处理队列。
- 如果引用指向灰色或黑色对象,则不做处理(因为它们已经被访问或正在被访问)。
- 将当前处理的灰色对象标记为黑色,表示其所有引用已扫描完毕。
- 重复以上步骤,直到待处理队列为空。
- 标记结束: 此时,所有黑色对象都是存活对象,所有白色对象都是垃圾对象。
并发标记的问题:
如果在并发标记过程中,用户线程修改了对象间的引用关系,可能会破坏三色标记的正确性,导致两种严重后果:
-
对象消失 (Object Loss) / 漏标: 本应存活的对象被错误地标记为白色垃圾。
- 发生条件 (同时满足):
- 用户线程新增了一条从黑色对象到白色对象的引用。
- 用户线程删除了所有从灰色对象到该白色对象的直接或间接引用。
- 后果: 由于黑色对象不会被重新扫描,新增的引用无法被发现;同时灰色对象不再引用该白色对象,导致该白色对象在标记结束后仍然是白色,最终被当作垃圾回收。这是绝对不允许发生的。
// 对象消失示例 (并发标记过程中) 灰色对象 G -> 白色对象 W 黑色对象 B// 用户线程操作: 1. B -> W (黑色指向白色,GC 不会再扫描 B) 2. G -x-> W (灰色不再指向白色)// 结果: W 没有被其他灰色对象引用,也不会被 B 发现,最终 W 保持白色,被回收。
- 发生条件 (同时满足):
-
浮动垃圾 (Floating Garbage) / 多标: 本应是垃圾的对象被错误地标记为黑色存活对象。
- 发生条件:
- 用户线程删除了从黑色对象或灰色对象到某个不再被其他存活对象引用的对象的引用。
- 后果: 由于该对象在被删除引用之前可能已经被标记为灰色或黑色,GC 线程不会再将其变回白色。这个本应是垃圾的对象在本轮 GC 中存活了下来,成为了“浮动垃圾”。
- 影响: 虽然浮动垃圾不会导致程序错误,但会占用内存,降低了本次 GC 的效果,需要等到下一次 GC 才能回收。CMS 需要预留一部分空间来容纳浮动垃圾。
- 发生条件:
CMS 如何解决并发标记问题?
CMS 主要关注解决“对象消失”问题(因为这是致命的),它采用了增量更新 (Incremental Update) 的策略,并结合写屏障 (Write Barrier) 和卡表 (Card Table) 技术来实现。对于“浮动垃圾”,CMS 允许其存在,通过预留空间和后续 GC 来处理。
3.2 写屏障 (Write Barrier) 与 增量更新 (Incremental Update)
写屏障并不是一个物理屏障,而是 JVM 在执行对象引用赋值操作时,自动插入的一小段代码(通常由 JIT 编译器在编译时插入)。它的作用是拦截引用的写入操作,并在写入前后执行一些额外的处理,以便 GC 线程能够感知到并发期间发生的引用变化。
[理解帮助] 想象你在图书馆整理书架(并发标记),有很多读者(用户线程)同时在借书、还书、把书从一个架子挪到另一个架子(修改引用)。为了不错过任何重要的书(存活对象),你在每个书架前都安装了一个摄像头(写屏障),每当有人动书架上的书时,摄像头就会记录下这个动作。
CMS 采用的是增量更新 (Incremental Update) 策略来解决“对象消失”问题。它的核心思想是:当一个黑色对象新增了指向白色对象的引用时,必须记录下这个变化,以便在后续的重新标记阶段进行处理。
增量更新的实现 (通过写后屏障 Post-Write Barrier):
当执行 object.field = new_value;
这样的引用赋值操作时,写屏障(这里是写后屏障)会介入:
- 检查: 判断
object
是否是黑色,以及new_value
是否是白色。 - 记录: 如果
object
是黑色且new_value
是白色,则将object
或new_value
(具体取决于实现细节,通常是将object
重新标记为灰色或记录下这条引用关系)记录下来。
记录方式通常有两种:
- 将被引用的白色对象直接标记为灰色: 简单直接,但如果一个黑色对象频繁引用不同的白色对象,会导致该黑色对象及其引用链被反复扫描。
- 将被引用的白色对象或新增引用的黑色对象放入一个特定的待处理列表: 在重新标记阶段,统一处理这个列表中的对象。这种方式更常见,可以减少重复扫描。
[源码示意] 以下是一个高度简化的写屏障伪代码,用于理解增量更新的核心逻辑(实际 OpenJDK 实现要复杂得多,涉及汇编和 C++ 代码):
// 伪代码: CMS 增量更新写屏障 (写后)
void postWriteBarrier(Object object, Object fieldOffset, Object newValue) {// 1. 检查颜色 (简化表示)if (isBlack(object) && isWhite(newValue)) {// 2. 记录变化 - 方式一: 将黑色对象变灰 (效率较低)// markGray(object);// 2. 记录变化 - 方式二: 记录引用关系或对象到特定列表 (更常见)// gc_log.add(new ReferenceChange(object, fieldOffset, newValue)); // 记录引用// 或// dirty_card_queue.add(addressOf(object)); // 将 object 所在卡页标记为脏 (结合卡表)// 或// remark_list.add(object); // 将 object 加入重新标记列表System.out.println("[Write Barrier] Detected: Black object " + object + " now points to White object " + newValue + ". Recording change.");}// 原始赋值操作// object.field = newValue; // 实际赋值由原始指令完成,写屏障只是附加操作
}// JIT 编译器在编译引用赋值语句时,会插入类似下方调用
// void compiled_assignment(Object target, Object field, Object value) {
// target.field = value; // 原始赋值
// postWriteBarrier(target, field, value); // 插入写屏障调用
// }
通过写屏障和增量更新,CMS 确保了即使在并发标记阶段发生“黑色指向白色”的情况,这些变化也会被记录下来。然后在重新标记 (Remark) 阶段,GC 线程会 STW,并处理所有被写屏障记录下来的变化(例如,重新扫描那些被记录的黑色对象或处理待处理列表),确保所有应该存活的对象最终都被正确标记。
3.3 卡表 (Card Table)
卡表并非 CMS 独有,它是 HotSpot JVM 中用于解决跨代引用问题的一种重要数据结构,在 CMS 的重新标记阶段也扮演着关键角色。
-
结构: 卡表是一个字节数组 (
byte[]
),它将整个老年代的内存空间逻辑上划分为固定大小的卡页 (Card Page)(通常为 512 字节)。卡表中的每个元素(通常是 1 个字节)对应一个卡页,用于标记该卡页的状态。[ 老年代 Heap Memory ] +----------+----------+----------+----------+ ... +----------+ | CardPage 0| CardPage 1| CardPage 2| CardPage 3| ... | CardPage N| (e.g., 512 Bytes each) +----------+----------+----------+----------+ ... +----------+[ 卡表 Card Table ] +------+------+------+------+ ... +------+ |Byte 0|Byte 1|Byte 2|Byte 3| ... |Byte N| (1 Byte per Card Page) +------+------+------+------+ ... +------+^ ^ ^ ^ ^| | | | |maps to CardPage 0-N
-
作用:
- 跨代引用 (主要): 在 Young GC 时,快速识别老年代中哪些区域可能存在指向新生代对象的引用。当老年代对象引用新生代对象时,对应的卡表项会被标记为“脏”(Dirty,通常值为 0)。Young GC 时只需扫描脏卡对应的老年代区域,无需扫描整个老年代。
- CMS 重新标记优化: 在 CMS 的并发标记阶段,如果用户线程修改了老年代内部对象的引用(特别是黑色指向白色),写屏障不仅执行增量更新逻辑,同时也会将发生引用修改的对象所在的卡页标记为脏。这样,在重新标记阶段,就不需要扫描整个老年代来查找并发期间的变化,只需要扫描卡表中标记为“脏”的卡页即可,大大缩减了 Remark 阶段的扫描范围,从而缩短了 STW 时间。
-
写屏障与卡表更新: 写屏障在执行引用赋值后,会计算出被修改引用的对象所在的内存地址,并找到对应的卡页,然后将卡表中该卡页对应的字节标记为“脏”。
[源码示意] 简化的卡表标记逻辑(通常在写屏障内调用):
// 伪代码: 更新卡表
byte[] cardTable = ...; // JVM 维护的卡表
final int CARD_SHIFT = 9; // 假设卡页大小为 2^9 = 512 字节
final byte DIRTY_CARD_VALUE = 0; // 脏卡标记值void markCardDirty(Object object) {long objectAddress = getAddress(object); // 获取对象的内存地址int cardIndex = (int)(objectAddress >>> CARD_SHIFT); // 计算地址对应的卡表索引if (cardTable[cardIndex] != DIRTY_CARD_VALUE) { // 避免重复标记cardTable[cardIndex] = DIRTY_CARD_VALUE;System.out.println("[Card Table] Marked card at index " + cardIndex + " as dirty for object " + object);// 可能还会将这个脏卡加入一个队列,供 GC 线程处理// dirty_card_queue.add(cardIndex);}
}// 写屏障中调用:
// void postWriteBarrier(Object object, Object fieldOffset, Object newValue) {
// ... // 增量更新逻辑
// markCardDirty(object); // 将修改对象所在的卡页标记为脏
// ...
// }
总结: 三色标记法是并发标记的基础理论,写屏障是感知并发引用变化的技术手段,增量更新是保证标记正确性的核心策略(防止对象消失),而卡表则是优化 Remark 阶段扫描范围、提高效率的关键数据结构。它们共同协作,支撑了 CMS 的并发标记过程。
第四章:CMS 的挑战与缺点
尽管 CMS 在降低停顿时间方面取得了显著成就,但它并非完美,其设计也带来了一些固有的缺点和挑战。
4.1 CPU 资源敏感
CMS 的并发标记和并发清除阶段虽然不暂停用户线程,但 GC 线程仍然需要消耗 CPU 资源来执行任务。在 CPU 资源本就紧张的情况下,GC 线程与用户线程的争抢会导致应用程序的整体吞吐量下降。
- 影响: 默认情况下,CMS 启动的并发 GC 线程数是
(CPU核心数 + 3) / 4
。如果 CPU 核心数较少(例如 1 或 2 个),CMS 占用的 CPU 比例可能高达 50% 或 33%,对用户程序性能影响显著。即使在多核服务器上,GC 线程也会分走一部分计算能力。 - 参数: 可以通过
-XX:ConcGCThreads
或-XX:ParallelCMSThreads
(较老版本) 参数手动设置并发 GC 的线程数。但减少线程数会延长 GC 周期,增加并发失败的风险。
4.2 无法处理浮动垃圾 (Floating Garbage)
正如前面提到的,由于并发清除阶段用户线程仍在运行,此时产生的垃圾(即在并发标记结束后,原本存活的对象变成了垃圾)无法在本轮 GC 中被回收,这些垃圾被称为“浮动垃圾”。
- 影响: 浮动垃圾会占用老年代空间,降低了单次 GC 的效果。如果浮动垃圾积累过多,可能导致老年代空间提前耗尽,触发更频繁的 GC,甚至引发并发失败。
- 应对: CMS 必须预留一部分空间来容纳并发阶段可能产生的浮动垃圾。这也是为什么 CMS 不能等到老年代几乎完全满了再启动回收。通过
-XX:CMSInitiatingOccupancyFraction
参数可以设置老年代空间使用率的阈值,当达到这个阈值时就启动 CMS 回收(JDK 6 默认是 92%,但这是一个比较危险的值,实际应用中通常需要调低)。设置得过高,预留空间不足,容易并发失败;设置得过低,GC 过于频繁,影响性能。
4.3 内存碎片问题
CMS 是基于标记-清除 (Mark-Sweep) 算法实现的,这意味着它在回收完垃圾对象后,并不会对存活的对象进行移动和整理,而是直接将回收掉的空间标记为空闲。
-
影响: 长期运行后,老年代会产生大量不连续的内存碎片。虽然总的可用空间可能还很多,但找不到一块足够大的连续空间来分配给一个大对象(例如一个大数组、大字符串或第三方库的大型数据结构)。当需要分配大对象而找不到连续空间时,即使老年代的整体使用率还不高,JVM 也不得不提前触发一次Full GC(通常是使用 Serial Old 进行带压缩的回收),这会导致非常长时间的 STW,严重影响应用响应。这是 CMS 最致命的缺陷之一。
[ 清除后 ] [ ObjA | Free(small) | ObjC | Free(small) | ObjD | Free(large) ] // 此时想分配一个中等大小的对象,可能找不到合适的 Free 块,即使总 Free 空间足够
-
应对: CMS 提供了一些参数来缓解碎片问题,但治标不治本:
-XX:+UseCMSCompactAtFullCollection
(默认开启): 在进行 Full GC 时(通常是并发失败后触发的 Full GC),对老年代进行碎片整理。但这会增加 Full GC 的停顿时间。-XX:CMSFullGCsBeforeCompaction
(默认值 0): 设置在执行多少次不进行压缩的 Full GC 后,下一次进入 Full GC 前会先进行碎片整理。值为 0 表示每次 Full GC 都进行整理。适当调大此值可以减少整理带来的停顿,但会容忍更长时间的碎片存在。
4.4 并发失败 (Concurrent Mode Failure)
如果在 CMS 并发标记或并发清除的过程中,老年代的内存增长速度过快,导致预留的空间不足以容纳新晋升到老年代的对象(可能是 Young GC 后晋升的,也可能是直接在老年代分配的大对象),或者 CMS 的回收速度跟不上内存分配速度,就会发生并发失败 (Concurrent Mode Failure)。
- 后果: 一旦发生并发失败,JVM 不得不暂停所有用户线程 (STW),并切换到后备的、单线程的、带整理的Serial Old收集器来对整个老年代进行一次Full GC。这次 Full GC 的停顿时间通常会非常长,可能是 CMS 单次 Remark 停顿时间的数倍甚至数十倍,对生产环境影响极大。
- 触发场景:
- 老年代空间不足以容纳 Young GC 晋升的对象: 并发回收还未完成,但一次 Young GC 后需要晋升大量对象到老年代,而老年代的剩余空间(包括预留空间)不够了。
- 并发过程中需要分配大对象,但碎片导致无法分配: 即使总空间够,但碎片导致找不到连续空间。
- CMS 回收速度跟不上应用程序分配内存的速度:
-XX:CMSInitiatingOccupancyFraction
设置得过高,或者应用内存分配速率确实超过了 CMS 的处理能力。
- 应对:
- 降低触发阈值: 调低
-XX:CMSInitiatingOccupancyFraction
,让 CMS 更早启动,预留更多空间。 - 增加老年代空间:
-Xmx
,-Xmn
合理分配堆大小和新生代/老年代比例。 - 增加并发线程数: 适当调高
-XX:ConcGCThreads
加快回收速度(但会增加 CPU 消耗)。 - 优化应用内存使用: 减少大对象分配,避免内存泄漏,降低对象晋升速率。
- 开启 Remark 前 YGC:
-XX:+CMSScavengeBeforeRemark
可以在 Remark 前进行一次 Young GC,减少 Remark 阶段需要处理的对象数,间接加快 CMS 周期,但会增加一次短暂的 YGC 停顿。
- 降低触发阈值: 调低
理解这些缺点和挑战,是进行 CMS 调优和问题排查的基础。
第五章:CMS 实战:调优与日志分析
理论学习之后,我们需要掌握如何在实际项目中应用和调优 CMS。
5.1 关键 JVM 参数详解
以下是 CMS 常用的一些 JVM 参数及其说明:
-
启用 CMS:
-XX:+UseConcMarkSweepGC
: 在老年代启用 CMS 收集器。启用后,新生代会自动选择 ParNew(Parallel New)收集器配合。
-
触发时机:
-XX:CMSInitiatingOccupancyFraction=<value>
: 设置老年代使用率达到多少百分比时触发 CMS 回收。取值范围 0-100。默认值随 JDK 版本变化(JDK 6 为 92%,后续版本可能有调整)。这是最重要的 CMS 调优参数之一。 通常需要根据应用实际的内存增长速率和 GC 日志来调整,建议设置在 60-80 之间,以预留足够空间应对浮动垃圾和晋升。-XX:+UseCMSInitiatingOccupancyOnly
: (通常建议开启) 让 JVM 始终根据-XX:CMSInitiatingOccupancyFraction
的值来触发 CMS GC,而不是基于动态计算的启发式策略。
-
并发与并行线程:
-XX:ConcGCThreads=<value>
: 设置并发 GC 阶段(Concurrent Mark, Concurrent Sweep)使用的线程数。默认值是(ParallelGCThreads + 3) / 4
,其中ParallelGCThreads
是并行 GC 线程数(通常等于 CPU 核心数)。适当增加可以加快并发阶段速度,但会增加 CPU 消耗。-XX:+CMSParallelRemarkEnabled
: (默认开启) 启用并行的重新标记(Remark)阶段。Remark 是 STW 阶段,并行化可以缩短其停顿时间。-XX:ParallelGCThreads=<value>
: (影响 ConcGCThreads 默认值和 Remark 并行度) 设置并行 GC 操作(如 Young GC、CMS Remark)的线程数,通常设置为 CPU 核心数。
-
碎片整理:
-XX:+UseCMSCompactAtFullCollection
: (默认开启) 在执行 Full GC(通常是 Concurrent Mode Failure 后)时,对老年代进行碎片整理。-XX:CMSFullGCsBeforeCompaction=<value>
: (默认 0) 设置在执行多少次不带压缩的 Full GC 后,才执行一次带压缩的 Full GC。值为 0 表示每次都压缩。如果 Full GC 频繁且停顿时间长,可以适当增大此值,但要监控碎片情况。
-
优化选项:
-XX:+CMSScavengeBeforeRemark
: (默认关闭) 在执行 CMS Remark 阶段之前,强制进行一次 Young GC。好处是可以减少 Remark 阶段需要扫描的对象(特别是从新生代指向老年代的引用),可能缩短 Remark 停顿时间,并降低并发失败风险。坏处是增加了一次额外的 Young GC 停顿。需要根据实际情况权衡。-XX:+CMSClassUnloadingEnabled
: (默认开启) 允许 CMS 在 GC 过程中回收方法区/元空间中不再使用的类信息。如果应用动态加载类较多,开启此选项有助于防止 Metaspace OOM。-XX:CMSMaxAbortablePrecleanTime=<value>
: (默认 5000ms) CMS 有一个预清理(Preclean)阶段,在 Remark 前执行,尝试在并发情况下处理一些工作以减少 Remark 的停顿。此参数控制可中断预清理阶段的最长时间。
参数设置建议:
- 没有“万能”的参数配置,必须结合应用特点和GC 日志进行调优。
- 优先调整
-XX:CMSInitiatingOccupancyFraction
,找到一个既能避免 Concurrent Mode Failure 又不会导致 GC 太频繁的值。 - 监控 Remark 阶段的停顿时间,如果过长,考虑
-XX:+CMSScavengeBeforeRemark
是否有帮助,或检查是否有大量跨代引用(卡表是否太多脏卡)。 - 密切关注 Full GC(尤其是 Concurrent Mode Failure 导致的)的频率和耗时,这是 CMS 最需要避免的情况。分析原因,是阈值太高?并发线程不足?还是内存碎片?
5.2 GC 日志分析
GC 日志是进行 GC 调优和问题诊断的最重要依据。
开启 GC 日志:
建议使用以下参数(JDK 8 及之前):
-XX:+PrintGCDetails # 打印详细 GC 信息
-XX:+PrintGCDateStamps # 打印 GC 发生时的时间戳 (日期格式)
-XX:+PrintGCTimeStamps # 打印 GC 发生时的时间戳 (相对于 JVM 启动的时间)
-XX:+PrintHeapAtGC # 在 GC 前后打印堆信息 (可选,用于分析内存分布)
-XX:+PrintTenuringDistribution # 打印对象年龄分布 (有助于判断晋升阈值是否合理)
-XX:+PrintReferenceGC # 打印引用类型 (软、弱、虚、终结) 的回收信息 (可选)
-XX:+PrintGCApplicationStoppedTime # 打印 GC 导致的应用程序停顿时间
-Xloggc:<file-path> # 将 GC 日志输出到指定文件
-XX:+UseGCLogFileRotation # 启用 GC 日志滚动
-XX:NumberOfGCLogFiles=<n> # 保留 n 个滚动日志文件
-XX:GCLogFileSize=<size> # 每个日志文件的最大大小 (e.g., 100M)
对于 JDK 9 及以上版本,推荐使用统一日志框架 -Xlog
:
-Xlog:gc*:file=<file-path>:time,level,tags:filecount=<n>,filesize=<size>
例如,记录详细 GC 日志到文件,带时间戳:
-Xlog:gc=debug:file=gc.log:time:filecount=5,filesize=50m
解读 CMS GC 日志:
CMS 的 GC 日志通常包含以下几个关键阶段的信息:
# 初始标记 (Initial Mark) - STW
[GC (CMS Initial Mark) [1 CMS-initial-mark: 614848K(1048576K)] 635248K(1441792K), 0.0027870 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]# 并发标记 (Concurrent Mark) - 并发
[CMS-concurrent-mark-start]
[CMS-concurrent-mark: 0.185/0.185 secs] [Times: user=0.25 sys=0.00, real=0.19 secs]# 并发预清理 (Concurrent Preclean) - 并发 (可能包含 Abortable Preclean)
[CMS-concurrent-preclean-start]
[CMS-concurrent-preclean: 0.016/0.016 secs] [Times: user=0.02 sys=0.00, real=0.02 secs]# 可中断预清理 (Abortable Preclean) - 并发 (在满足条件时提前终止)
[CMS-concurrent-abortable-preclean-start]CMS: abort preclean due to time XXX ms
[CMS-concurrent-abortable-preclean: 0.008/5.000 secs] [Times: user=0.01 sys=0.00, real=0.01 secs]# 重新标记 (Remark) - STW
[GC (CMS Final Remark) [YG occupancy: 104857 K (393216 K)] [Rescan (parallel) , 0.0180300 secs] [weak refs processing, 0.0000570 secs] [class unloading, 0.0034190 secs] [scrub symbol table, 0.0026700 secs] [scrub string table, 0.0004360 secs] [1 CMS-remark: 614848K(1048576K)] 719705K(1441792K), 0.0253040 secs] [Times: user=0.09 sys=0.00, real=0.03 secs]# 并发清除 (Concurrent Sweep) - 并发
[CMS-concurrent-sweep-start]
[CMS-concurrent-sweep: 0.070/0.070 secs] [Times: user=0.08 sys=0.00, real=0.07 secs]# 并发重置 (Concurrent Reset) - 并发
[CMS-concurrent-reset-start]
[CMS-concurrent-reset: 0.003/0.003 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
关键关注点:
-
STW 时间: 重点关注
CMS Initial Mark
和CMS Final Remark
的real
时间(实际停顿时间)。如果 Remark 时间过长,需要分析原因(卡表脏卡多?引用处理耗时?类卸载耗时?)。 -
并发阶段耗时: 关注
CMS-concurrent-mark
,CMS-concurrent-sweep
的耗时。如果这些阶段耗时很长,说明 GC 周期长,可能增加并发失败风险。 -
堆使用情况:
[1 CMS-initial-mark: 614848K(1048576K)]
表示老年代 (CMS generation) 在标记开始时已使用 614848K,总容量 1048576K。719705K(1441792K)
表示整个堆(新生代+老年代)的使用情况。通过观察 GC 前后的堆使用变化,可以判断回收效果。 -
触发原因: CMS GC 的触发通常是因为达到了
CMSInitiatingOccupancyFraction
阈值。 -
并发失败日志: 如果发生 Concurrent Mode Failure,日志中会明确提示,并紧跟着一次 Full GC 的日志。
(concurrent mode failure): 1048576K->1048576K(1048576K), 1.5359480 secs] 1441792K->1152482K(1441792K), [CMS Perm : 65536K->65530K(65536K)], 1.5360730 secs] [Times: user=1.53 sys=0.00, real=1.54 secs] # 紧接着会看到 Full GC (System.gc() 或者 CMS Old Gen full) 使用 Serial Old 的日志 [Full GC (System.gc()) [CMS: 1152482K->1024K(1048576K), 0.5893310 secs] 1257339K->1024K(1441792K), [CMS Perm : 65530K->65530K(65536K)], 0.5894340 secs] [Times: user=0.59 sys=0.00, real=0.59 secs]
看到
(concurrent mode failure)
就意味着发生了严重问题,停顿时间(real=1.54 secs
)会很长,必须分析原因并调优。
日志分析工具:
手动分析 GC 日志比较低效且易出错,可以使用一些工具:
- GCViewer: 开源的 GC 日志分析工具,可以将日志可视化,展示各项指标。
- GCEasy.io: 在线的 GC 日志分析服务(有免费和付费版本)。
- JProfiler, YourKit, VisualVM (配合插件): 集成的性能分析工具通常也包含 GC 日志分析功能。
5.3 常见问题与调优案例
案例 1: Remark 阶段停顿时间过长
- 现象: GC 日志显示
CMS Final Remark
的real
时间远超预期(例如几百毫秒甚至秒级)。 - 分析:
- 检查
[Rescan (parallel)
部分耗时。如果长,可能是并发标记期间引用变化太多,导致脏卡过多,扫描耗时。 - 检查
[weak refs processing]
,[class unloading]
等部分耗时。如果长,可能是大量软/弱/虚引用需要处理,或需要卸载大量类。 - 考虑
-XX:+CMSScavengeBeforeRemark
是否开启。如果未开启,尝试开启,看能否通过减少新生代对象降低 Remark 扫描量。
- 检查
- 调优:
- 尝试开启
-XX:+CMSScavengeBeforeRemark
。 - 如果类卸载耗时,检查 Metaspace 大小是否合适,是否有类加载器泄漏。
- 如果 Rescan 耗时,检查应用是否存在短时间内大量修改老年代对象引用的行为。
- 确保
-XX:+CMSParallelRemarkEnabled
开启,并检查-XX:ParallelGCThreads
设置是否合理。
- 尝试开启
案例 2: 频繁发生 Concurrent Mode Failure
- 现象: GC 日志中频繁出现
(concurrent mode failure)
,导致长时间 Full GC 停顿。 - 分析:
- 检查触发 CMF 时老年代的使用率。如果接近 100%,说明预留空间不足或回收速度跟不上。
- 检查 Young GC 日志,看是否有大量对象晋升到老年代。
- 检查 Full GC 日志中
CMS: ... -> ...
回收后的空间。如果回收了大量空间,说明确实是回收速度问题;如果回收空间不多,可能是碎片问题导致无法分配。
- 调优:
- 降低阈值: 调低
-XX:CMSInitiatingOccupancyFraction
(例如从 80 调到 70 或 65),让 CMS 更早启动。这是最常用的手段。 - 增加堆/老年代空间: 如果物理内存允许,增大
-Xmx
。调整-Xmn
或-XX:NewRatio
来改变新生代/老年代比例(通常老年代占比更大)。 - 增加并发线程: 适当增加
-XX:ConcGCThreads
(注意 CPU 消耗)。 - 优化应用: 减少大对象分配,排查内存泄漏,降低对象创建和晋升速率。
- 碎片整理: 如果是碎片导致 CMF,确保
-XX:+UseCMSCompactAtFullCollection
开启,调整-XX:CMSFullGCsBeforeCompaction
的值(例如设为 1 或 2,在几次非压缩 Full GC 后执行一次压缩)。但注意这会增加 Full GC 停顿。根本解决碎片问题需要考虑 G1 或其他收集器。
- 降低阈值: 调低
案例 3: CPU 使用率过高
- 现象: 应用运行时 CPU 使用率较高,通过
top
或perf
等工具发现 GC 线程(如CMS-Mark
或CMS-Sweep
)占用了显著 CPU。 - 分析: 这是 CMS 并发执行的固有代价。
- 调优:
- 减少并发线程: 降低
-XX:ConcGCThreads
的值(例如减少 1-2 个)。但这会延长 GC 周期,可能增加 CMF 风险,需要权衡。 - 开启增量模式 (不推荐):
-XX:+CMSIncrementalMode
(i-CMS)。这是一种让并发 GC 线程与用户线程交替执行的模式,尝试减少对单核或少核 CPU 的影响,但效果通常不佳,且已被废弃。 - 升级硬件: 增加 CPU 核心数。
- 考虑其他收集器: 如果吞吐量是主要瓶颈,可以考虑 Parallel Scavenge + Parallel Old 组合。如果既要低延迟又要高吞吐,考虑 G1、ZGC、Shenandoah。
- 减少并发线程: 降低
CMS 调优是一个需要耐心和细致观察的过程,没有银弹。务必基于实际的应用负载和详细的 GC 日志进行分析和调整。
第六章:CMS 的替代者与未来
CMS 作为第一款并发收集器,具有里程碑意义,但其固有的缺陷(尤其是内存碎片和并发失败)限制了它在现代应用(特别是超大堆内存场景)中的表现。随着 GC 技术的发展,更优秀的收集器应运而生。
6.1 CMS 为何被废弃?
从 JDK 9 开始,CMS 被标记为废弃(Deprecated),并在 JDK 14 中被正式移除。主要原因包括:
- 复杂的实现与高昂的维护成本: CMS 的并发机制、写屏障、多种特殊处理逻辑使其内部实现异常复杂,给 OpenJDK 团队带来了沉重的维护负担。
- 内存碎片问题难以根治: 标记-清除算法天然导致内存碎片,CMS 提供的整理机制只是缓解而非解决,且代价高昂(STW)。
- 并发失败问题: Concurrent Mode Failure 导致的长时间 Full GC 停顿是生产环境中的噩梦,虽然可以通过调优降低频率,但难以完全避免。
- 对超大堆支持不佳: 随着内存越来越大,CMS 的全堆扫描(即使有优化)和碎片问题会更加突出。
- 更好的替代方案出现: G1 收集器的成熟和普及,以及后续 ZGC、Shenandoah 的发展,提供了更优的低延迟、高吞吐、可预测停顿时间的解决方案。
6.2 G1 收集器:CMS 的继任者
G1(Garbage-First)收集器在 JDK 7 中引入,在 JDK 9 中成为默认的垃圾收集器,旨在取代 CMS。它在设计上克服了 CMS 的许多缺点:
- 基于区域的内存布局 (Region): G1 不再将堆划分为严格的新生代和老年代,而是划分为多个大小相等的独立区域(Region)。每个 Region 可以扮演 Eden、Survivor 或 Old 的角色。这种设计使得 GC 的范围不再局限于整个代,而是可以按需回收一部分 Region。
- 可预测的停顿时间模型: G1 的核心目标之一是建立“停顿时间模型”(Pause Time Model)。用户可以通过
-XX:MaxGCPauseMillis=<N>
参数设定期望的最大 GC 停顿时间(例如 200ms)。G1 会根据这个目标,尽力选择回收价值最高(垃圾最多、回收耗时最少)的若干个 Region 进行回收(这也是 Garbage-First 名称的由来),从而在控制停顿时间的同时,获得较高的回收效率。 - 空间整合与低碎片: G1 从整体来看是基于“标记-整理”(Mark-Compact)算法实现的收集器,从局部(两个 Region 之间)上来看是基于“标记-复制”(Mark-Copy)算法实现的。这意味着 G1 在回收过程中会进行内存整理,从根本上解决了 CMS 的内存碎片问题。
- 兼顾吞吐量与延迟: G1 通过并发标记、并行复制/整理等技术,试图在低延迟和高吞吐量之间取得更好的平衡。
- 更高效的跨代引用处理: G1 同样使用卡表,但其 Region 的设计使得跨代引用的处理更加高效(只需要处理 RSet,记录了哪些 Region 指向当前 Region)。
G1 相比 CMS 的主要改进总结:
特性 | CMS (Concurrent Mark Sweep) | G1 (Garbage-First) |
---|---|---|
回收算法 | 标记-清除 | 标记-整理 (整体) / 标记-复制 (局部) |
内存布局 | 传统分代 (Young/Old) | 基于 Region 的分区 |
内存碎片 | 严重 | 基本消除 |
停顿时间 | 较低,但 Full GC 时长且不可控 | 可预测 (通过 -XX:MaxGCPauseMillis ) |
并发失败 | 存在 Concurrent Mode Failure | 基本不存在 (除非堆完全耗尽) |
适用场景 | 低延迟,中小堆 (JDK 8 及以前常用) | 低延迟、高吞吐,大堆内存 (现代应用首选) |
默认状态 | JDK 9 废弃, JDK 14 移除 | JDK 9+ 默认 |
虽然 G1 在很多方面优于 CMS,但它也更复杂,参数更多,调优思路也不同。对于仍然在使用 JDK 8 或更早版本且对低延迟有较高要求的应用,CMS 仍然是一个可选方案(但强烈建议评估升级或迁移到 G1)。
6.3 ZGC 与 Shenandoah:追求极致低延迟
在 G1 之后,JVM 社区继续探索更低延迟的 GC 算法,诞生了 ZGC (Z Garbage Collector) 和 Shenandoah。它们的目标是将 GC 停顿时间控制在10 毫秒以内,甚至亚毫秒级别,并且停顿时间不随堆大小或存活对象数量增加而显著增长。
它们都采用了更先进的技术,如着色指针 (Colored Pointers) 和读屏障 (Read Barrier),将几乎所有的 GC 工作(包括对象移动/整理)都并发执行,从而实现了极低的 STW 时间。
- ZGC: Oracle 开发,JDK 11 实验性引入,JDK 15 正式可用。
- Shenandoah: Red Hat 开发,率先在 OpenJDK 12 中引入。
这些收集器代表了当前 JVM GC 技术的顶尖水平,适用于对延迟极其敏感、且拥有超大堆内存(TB 级别)的应用场景。但它们也需要较新的 JDK 版本支持,且自身仍在快速发展和优化中。
第七章:总结与展望
CMS 垃圾收集器,作为 HotSpot 虚拟机中并发收集的先行者,以其创新的并发标记和并发清除机制,显著降低了老年代回收的停顿时间,满足了许多对响应速度敏感的应用场景的需求。
我们深入探讨了它的工作原理:从初始标记的短暂暂停,到并发标记的后台追踪,再到重新标记的并发修正,最后是并发清除的垃圾回收。
我们也聊了其核心机制:
- 三色标记法如何追踪对象存活状态
- 写屏障和增量更新如何解决并发修改带来的“对象消失”问题
- 卡表如何优化跨代引用和 Remark 阶段的扫描。
然而,CMS 并非银弹。我们也必须正视它的局限性:
对 CPU 资源的敏感、无法处理浮动垃圾带来的空间预留需求、标记-清除算法导致的内存碎片问题,以及最令人头疼的 Concurrent Mode Failure 风险。
理解这些挑战是进行有效调优和问题排查的关键。
我们学习了如何通过分析 GC 日志,结合关键 JVM 参数(如 CMSInitiatingOccupancyFraction
, ConcGCThreads
, UseCMSCompactAtFullCollection
等)来优化 CMS 的性能,应对 Remark 停顿过长、并发失败频发等问题。
尽管 CMS 因其固有的复杂性和缺陷,已被更先进的 G1、ZGC、Shenandoah 等收集器逐步取代,并在新版 JDK 中移除,但学习和理解 CMS 仍然具有重要价值。它不仅能帮助我们维护和优化仍在使用旧版 JDK 的系统,更重要的是,CMS 中蕴含的并发 GC 设计思想、面临的挑战以及解决思路(如写屏障、增量更新),为后续更先进的收集器的发展奠定了基础。
理解 CMS,就是理解 JVM GC 技术演进的重要一环。
未来已来,但历史弥新。理解过去,方能更好地拥抱未来。
PS:玩黑神话学到的(笑)
Happy coding!