欢迎来到尧图网

客户服务 关于我们

您的位置:首页 > 健康 > 养生 > 深度学习与总结JVM专辑(七):垃圾回收器—CMS(图文+代码)

深度学习与总结JVM专辑(七):垃圾回收器—CMS(图文+代码)

2025/4/24 4:46:21 来源:https://blog.csdn.net/qq_45852626/article/details/147406951  浏览:    关键词:深度学习与总结JVM专辑(七):垃圾回收器—CMS(图文+代码)

CMS垃圾收集器深度解析教程

    • 1. 前言:为什么需要CMS?
    • 2. CMS 工作原理:一场与时间的赛跑
      • 2.1. 初始标记(Initial Mark)
      • 2.2. 并发标记(Concurrent Mark)
      • 2.3. 重新标记(Remark)
      • 2.4. 并发清除(Concurrent Sweep)
    • 3. CMS 的优势与劣势:权衡的艺术
      • 3.1. 优势 (Pros)
      • 3.2. 劣势 (Cons)
    • 4. "并发" vs "并行":别再傻傻分不清
    • 5. 三色标记法:CMS并发标记的理论基础
    • 6. 写屏障与增量更新:并发标记的救星
      • 6.1. 写屏障 (Write Barrier)
      • 6.2. 增量更新 (Incremental Update)
      • 6.3. 卡表 (Card Table):优化重新标记
    • 7. CMS 的核心痛点详解
      • 7.1. 并发失败 (Concurrent Mode Failure)
      • 7.2. 内存碎片 (Memory Fragmentation)
      • 7.3. 浮动垃圾 (Floating Garbage)
    • 8. CMS 适用场景与被取代的原因
      • 8.1. 何时考虑使用 CMS?
      • 8.2. 为什么 CMS 被废弃和移除?
    • 9. 总结:CMS 的历史价值

1. 前言:为什么需要CMS?

在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通常是更好的选择。

2. CMS 工作原理:一场与时间的赛跑

CMS的核心思想是“并发”,即垃圾收集线程与应用程序线程在大部分时间内可以同时运行。为了实现这个目标,CMS将整个垃圾回收过程精心划分为四个主要阶段,以及一些穿插其中的预处理和收尾工作。

核心算法:标记-清除(Mark-Sweep)

首先要明确,CMS是基于 标记-清除 算法实现的。这意味着它在回收后 不会 对内存空间进行整理,这也是后续我们会讨论到的“内存碎片”问题的根源。

四个主要阶段:

CMS的回收过程主要包含以下四个步骤:

  1. 初始标记(Initial Mark)
  2. 并发标记(Concurrent Mark)
  3. 重新标记(Remark)
  4. 并发清除(Concurrent Sweep)

其中,初始标记重新标记 这两个阶段需要 “Stop The World”(STW),即暂停所有应用程序线程。而 并发标记并发清除 阶段则可以与应用程序线程 并发 执行。

下面我们来详细解析每个阶段的工作:

2.1. 初始标记(Initial Mark)

  • 目标: 标记出所有从 GC Roots 直接 关联到的对象。
  • 执行方式: 需要 STW
  • 耗时: 非常短暂。

这个阶段就像是在繁忙的高速公路上设置了一个极短的检查点。交警(GC线程)需要迅速拦下所有车辆(暂停用户线程),然后快速识别并标记出那些“有明确目的地”(直接被GC Roots引用)的车辆(对象)。GC Roots 包括虚拟机栈中引用的对象、方法区静态属性引用的对象、方法区常量引用的对象、本地方法栈JNI引用的对象等。

由于现代JVM的方法区、虚拟机栈等区域通常不会太大,而且只需要标记GC Roots直接关联的对象,无需深度遍历,因此这个阶段的速度非常快,通常只持续几十毫料。

理解帮助: 为什么需要STW?因为GC Roots集合是不断变化的,如果在标记过程中用户线程还在运行,可能会导致GC Roots增加或减少,从而影响标记的准确性。必须在一个静止的快照上进行操作。

2.2. 并发标记(Concurrent Mark)

  • 目标: 从“初始标记”阶段找到的对象出发,递归遍历整个对象引用链,标记所有存活的对象。
  • 执行方式: 并发执行(GC线程与用户线程同时运行)。
  • 耗时: 较长,是CMS整个回收过程中耗时最长的阶段。

这是CMS最核心、最具特色的阶段。在初始标记完成后,应用程序线程恢复运行。同时,专门的GC线程开始工作,它们沿着初始标记阶段找到的那些“种子对象”,逐步追踪整个对象引用图。就像是在高速公路上,普通车辆(用户线程)在正常行驶,而道路养护车(GC线程)在旁边车道或者利用夜间进行详细的道路状况检查(标记存活对象)。

理解帮助: 并发标记的挑战?这个阶段最大的挑战在于,用户线程仍在运行并可能修改对象的引用关系。比如:

  1. 原本被标记为存活的对象,在标记过程中被用户线程断开了引用,变成了垃圾。
  2. 原本某个对象没有被GC线程访问到(标记为白色),但用户线程突然让一个已经被标记过的对象(黑色)引用了它。

这些变化可能会导致标记结果不准确(漏标或错标)。CMS需要后续的“重新标记”阶段来修正这些问题。我们将在后面详细讨论CMS如何解决这些并发问题。

2.3. 重新标记(Remark)

  • 目标: 修正“并发标记”期间,因用户线程修改引用关系而导致标记发生变动的那一部分对象的标记记录。
  • 执行方式: 需要 STW
  • 耗时: 比初始标记长,但远比并发标记短。

并发标记阶段虽然完成了大部分工作,但它是在一个“动态”的环境下进行的。为了确保标记的最终准确性,需要一个短暂的STW阶段来进行“查漏补缺”。这就像道路养护车在并发检查后,再次短暂封闭道路(STW),对那些在检查期间有车辆进出或新出现问题的路段(被用户线程修改过引用的对象及相关区域)进行最后的确认。

这个阶段主要处理两类变化:

  1. 并发标记期间,新加入引用关系的对象。
  2. 并发标记期间,被移除引用关系的对象。

CMS通过一些聪明的机制(如卡表、增量更新,稍后详述)来记录并发标记期间的这些变化,使得重新标记阶段不必重新扫描整个堆,而只需要关注那些“有变动”的小范围区域,从而有效控制了STW的时间。

理解帮助: 为什么重新标记比初始标记慢?因为重新标记需要处理整个并发标记阶段积累的变化信息,扫描范围比初始标记(只看GC Roots直连对象)要大。但相比于重新扫描整个堆,它的效率已经大大提高了。

2.4. 并发清除(Concurrent Sweep)

  • 目标: 清除在标记阶段被判定为“已死亡”(未被标记)的对象,释放它们占用的内存空间。
  • 执行方式: 并发执行(GC线程与用户线程同时运行)。
  • 耗时: 较长,取决于垃圾对象的数量和分布。

在重新标记阶段确保了所有存活对象都被正确标记后,应用程序线程再次恢复运行。GC线程则开始最后的清理工作。它们遍历堆内存,将那些没有被标记(白色)的对象识别为垃圾,并将它们占用的内存回收,加入到空闲内存列表(Free List)中,以备后续分配新对象使用。

这个阶段也是并发的,用户线程可以正常访问那些已被标记为存活的对象,同时GC线程在后台默默地回收垃圾。

整体流程回顾:

CMS GC Cycle
初始标记 Initial Mark
STW, 耗时短
并发标记 Concurrent Mark
并发, 耗时最长
重新标记 Remark
STW, 耗时较短
并发清除 Concurrent Sweep
并发, 耗时较长
F
G
H
I
App Threads

通过将耗时最长的标记和清除阶段设计为并发执行,CMS成功地将大部分GC工作与应用程序运行重叠,从而显著降低了整体的STW时间,实现了其低延迟的目标。

3. CMS 的优势与劣势:权衡的艺术

没有哪种垃圾收集器是完美的,CMS也不例外。它通过牺牲一些其他方面的性能来换取低延迟的特性。理解其优缺点对于判断它是否适合特定应用场景至关重要。

3.1. 优势 (Pros)

  1. 并发收集 (Concurrent Collection): 这是CMS最核心的优势。标记和清除两个主要耗时阶段可以与用户线程并发执行,避免了长时间的应用停顿。
  2. 低延迟 (Low Latency): 由于STW时间被显著缩短(主要由初始标记和重新标记贡献,通常很短),CMS非常适合对响应时间有严格要求的应用,例如:
    • 网站服务器(如Tomcat, Jetty)
    • API网关
    • 实时交易系统
    • 交互式桌面应用

3.2. 劣势 (Cons)

CMS的并发特性和基于标记-清除算法的设计,也带来了几个不容忽视的缺点:

  1. 对CPU资源敏感 (CPU Intensive):

    • 原因: 并发阶段,GC线程需要与用户线程一起抢占CPU资源。默认情况下,CMS启动的回收线程数是 (CPU核心数 + 3) / 4。当CPU核心数较少时(例如少于4个),GC线程可能会占用相当一部分(甚至超过25%)的CPU运算能力,导致用户程序的执行速度变慢,总吞吐量下降
    • 理解帮助: 想象一下,原本专心开车的司机(用户线程)旁边多了一个不断指手画脚、分散注意力的乘客(GC线程),虽然车没有停,但整体开车效率降低了。吞吐量指的是单位时间内用户代码运行时间占总时间的比例。CMS为了低延迟牺牲了吞吐量。
  2. 无法处理“浮动垃圾” (Floating Garbage):

    • 原因: 在并发清除阶段,用户线程还在运行,并且可能会产生新的垃圾对象。然而,这些新产生的垃圾是在标记阶段之后出现的,CMS本次无法识别它们,只能等到下一次GC周期才能回收。这些在本轮GC中无法回收、但实际上已经是垃圾的对象,就被称为“浮动垃圾”。
    • 影响:
      • 降低了内存利用率,部分内存被无效占用。
      • 需要预留一部分堆空间来容纳这些浮动垃圾以及并发运行时用户线程可能继续分配的新对象。这也是为什么CMS不能等到老年代几乎完全满了再启动回收,而是需要在一个较低的阈值(默认68%或92%,取决于JDK版本和配置)就开始回收。参数 -XX:CMSInitiatingOccupancyFraction 控制这个阈值。
    • 理解帮助: 清洁工(GC线程)正在打扫房间(并发清除),但主人(用户线程)还在不断扔新的垃圾。清洁工这次只能清理之前标记好的垃圾,新扔的只能等下次再说了。
  3. 产生内存碎片 (Memory Fragmentation):

    • 原因: CMS基于 标记-清除 算法。该算法只标记、清除,不移动对象。回收后,内存空间会变得不连续,存在大量小的空闲块,这就是内存碎片。
    • 影响:
      • 当应用程序需要分配一个较大的对象时,即使总的空闲内存足够,也可能因为找不到一块足够大的 连续 空间而分配失败。
      • 内存碎片过多最终会提前触发一次 Full GC(通常是使用 Serial Old 或 Parallel Old 进行带压缩的回收),导致更长时间的STW。
    • 缓解措施: CMS提供了两个参数来控制碎片整理:
      • -XX:+UseCMSCompactAtFullCollection (默认开启): 在不得不进行Full GC时,开启内存整理(压缩)。
      • -XX:CMSFullGCsBeforeCompaction (默认值为0): 设置在执行多少次不压缩的Full GC之后,进行一次带压缩的Full GC。值为0表示每次Full GC都进行压缩。
    • 理解帮助: 图书馆管理员(GC)把借走的书(垃圾对象)下架了,但书架上留下了很多零散的空位(碎片)。当需要放一本大部头(大对象)时,虽然总空位数很多,但找不到一个足够宽的连续空位。管理员可以选择在某个时候(Full GC)把所有书重新排列整齐(压缩),但这需要闭馆一段时间(STW)。
  4. 并发失败风险 (Concurrent Mode Failure):

    • 原因: 如果在CMS并发标记或并发清除的过程中,老年代的内存增长速度过快(比如用户线程分配大对象、大量对象从年轻代晋升),导致预留的空间不足以容纳新对象,CMS就会发生“并发失败”。
    • 后果: 一旦发生并发失败,JVM会冻结用户线程(STW),然后启用后备的、单线程的、带压缩的 Serial Old 收集器来重新进行整个老年代的垃圾回收。这会导致一次非常漫长的STW,比CMS正常运行时的短暂停顿要长得多,严重影响应用性能。
    • 触发条件:
      • 老年代空间不足以容纳从Young GC晋升的对象。
      • 并发过程中分配大对象,老年代没有足够连续空间。
      • CMSInitiatingOccupancyFraction 设置过高,预留空间不足。
      • 回收速度跟不上内存分配速度。
    • 理解帮助: 商场(老年代)一边营业(用户线程运行)一边打扫(CMS并发回收)。但突然涌入大量顾客(对象晋升/大对象分配),或者垃圾产生速度太快,清洁工来不及清理,商场空间不够用了。这时不得不紧急关门谢客(STW),请来效率较低但能彻底整理的保洁队(Serial Old)进行大扫除。

总结:

特性优势劣势
核心并发收集、低延迟对CPU敏感、吞吐量降低
算法(无直接优势)标记-清除导致内存碎片
并发执行减少STW时间无法处理浮动垃圾、需要预留空间、可能发生并发失败(Concurrent Mode Failure)
适用场景对响应时间要求高的应用(Web服务、API等)CPU资源紧张、内存分配率极高、无法容忍内存碎片的场景

选择CMS,就是选择用CPU资源、部分内存空间和一定的复杂性来换取应用响应时间的提升。

4. “并发” vs “并行”:别再傻傻分不清

在垃圾收集的语境下,“并发”(Concurrent)和“并行”(Parallel)是两个非常重要且容易混淆的概念。理解它们的区别有助于我们把握不同GC的设计哲学。

  • 并行 (Parallel):

    • 定义:多条垃圾收集器线程 同时工作。
    • 关注点: 缩短 垃圾收集本身 的时间,提高GC的 效率
    • 用户线程状态: 在并行GC执行期间,用户线程仍然处于等待状态(STW)
    • 例子: Parallel Scavenge(新生代)、Parallel Old(老年代)。它们在进行垃圾回收时,会启动多个GC线程协同工作,以加快回收速度,但整个过程应用是暂停的。
    • 目标: 提高 吞吐量 (Throughput)。即让用户代码执行时间占总时间的比例最大化。适合后台计算、数据处理等不需要实时响应的任务。
    • 类比: 多个人(多GC线程)一起快速打扫一个房间(GC过程),打扫期间房间里不允许有人(用户线程STW)。
  • 并发 (Concurrent):

    • 定义:垃圾收集器线程用户线程 同时执行(不一定是严格的同时,可能交替执行)。
    • 关注点: 减少 应用程序的停顿时间
    • 用户线程状态: 在并发GC执行期间的大部分时间里,用户线程可以继续运行
    • 例子: CMS、G1(部分阶段)、ZGC、Shenandoah。它们的核心特点是将耗时操作分散到与用户线程并发执行的阶段。
    • 目标: 降低 延迟 (Latency)。即缩短因GC引起的STW时间。适合交互式应用、Web服务等对响应时间敏感的场景。
    • 类比: 一个人(GC线程)在房间有人活动(用户线程运行)的情况下进行打扫,尽量不影响房间里的人。

CMS是哪一种?

CMS的名字 Concurrent Mark Sweep 就明确告诉我们,它是一个 并发 收集器。它的主要工作(并发标记、并发清除)是与用户线程并发执行的。

需要注意:

  1. CMS的初始标记和重新标记阶段虽然需要STW,但也可以是 并行 的。可以通过 -XX:+CMSParallelInitialMarkEnabled-XX:+CMSParallelRemarkEnabled (后者通常默认开启) 来让这两个STW阶段使用多线程执行,进一步缩短停顿时间。
  2. 现代的垃圾收集器(如G1、ZGC)往往 同时利用了并行和并发 的优势。它们既能在STW阶段并行执行,也能在大部分时间里与用户线程并发执行。

总结:

特性并行 (Parallel)并发 (Concurrent)
线程关系多个 GC线程 协同工作GC线程用户线程 同时运行
用户线程STW (暂停)大部分时间 Running (运行)
目标高吞吐量 (Throughput)低延迟 (Latency)
关注缩短 GC 时间缩短 应用停顿时间
代表Parallel Scavenge, Parallel OldCMS, G1, ZGC, Shenandoah
核心优势GC效率高应用停顿少
核心代价STW时间可能较长可能牺牲吞吐量、增加CPU开销、实现复杂

5. 三色标记法:CMS并发标记的理论基础

为了在用户线程并发修改对象引用的同时,正确地标记出所有存活对象,CMS(以及G1、ZGC等并发或增量GC)采用了 三色标记(Tri-color Marking) 算法作为理论基础。

三色标记法将垃圾收集器在标记过程中遇到的对象,根据其访问状态,划分为三种颜色:

  1. 白色 (White):

    • 含义: 对象尚未被垃圾收集器访问过。
    • 初始状态: 在标记开始时,所有对象都是白色的。
    • 结束状态: 在标记结束后,如果一个对象仍然是白色,意味着它从GC Roots不可达,是垃圾,将被回收。
  2. 灰色 (Gray):

    • 含义: 对象已经被垃圾收集器访问过,但它的直接引用还没有全部处理完毕(即它的“邻居”还没有全部被扫描)。
    • 状态变化: 当一个白色对象被GC Roots直接引用或者被灰色对象引用时,它会变成灰色。当一个灰色对象的所有直接引用都被扫描处理后,它会变成黑色。
    • 作用: 灰色对象是标记过程中的中间状态,代表着“待处理”的任务列表。
  3. 黑色 (Black):

    • 含义: 对象已经被垃圾收集器访问过,并且它的所有直接引用(Field)都已经被扫描处理完毕。
    • 保证: 黑色对象代表它本身是存活的,并且从它出发能直接到达的对象也已经被正确处理了(要么变成灰色待处理,要么已经是黑色)。

标记过程:

  1. 初始: 所有对象都是白色。
  2. 根扫描: 将所有GC Roots直接引用的对象标记为灰色,放入待处理集合。
  3. 遍历:
    • 从灰色集合中取出一个灰色对象。
    • 遍历该灰色对象的所有直接引用:
      • 如果引用指向一个白色对象,将该白色对象标记为灰色,放入待处理集合。
      • 如果引用指向灰色或黑色对象,不做任何处理(因为它们已经被访问或正在处理中)。
    • 将当前处理的灰色对象标记为黑色。
  4. 重复: 重复步骤3,直到灰色集合为空。
  5. 结束: 此时,所有仍然是白色的对象就是不可达的垃圾,可以被回收。所有黑色对象都是存活对象。

可视化理解:

最终状态
处理Obj3, Obj4后
处理Obj2后
处理Obj1后
根扫描后
初始状态
标记
标记
标记
标记
GC Roots
Obj1 黑色
Obj2 黑色
Obj3 黑色
Obj4 黑色
不可达
Obj5 白色
GC Roots
Obj1 黑色
Obj2 黑色
Obj3 黑色
Obj4 黑色
GC Roots
Obj1 黑色
Obj2 黑色
Obj3 灰色
Obj4 灰色
GC Roots
Obj1 黑色
Obj2 灰色
Obj3 灰色
Obj4 白色
GC Roots
Obj1 灰色
Obj2 白色
Obj3 白色
Obj4 白色
GC Roots
Obj1 白色
Obj2 白色
Obj3 白色
Obj4 白色

并发执行带来的问题:

如果三色标记法在严格的STW下单线程执行,是完全正确的。但CMS的并发标记阶段,用户线程和GC线程同时运行,这就可能破坏三色标记法正常工作的前提,导致两种严重错误:

  1. 对象消失 (Object Loss) / 漏标 (Missing Mark):

    • 场景: 一个黑色对象 A,原本引用着一个白色对象 B。在并发标记过程中:
      1. 用户线程 断开 了 A 到 B 的引用 (A.ref = null;)。
      2. 同时,用户线程让一个 灰色 对象 C 新增 了到 B 的引用 (C.ref = B;)。
      3. 但是,GC线程此时已经 扫描完 了 A(A已经是黑色),并且 还没来得及 扫描 C(C还是灰色)。
    • 后果: 当GC线程后续扫描完 C 时,它可能不会再回头看 B(具体取决于实现策略)。最终,对象 B 虽然是存活的(被 C 引用),但没有被任何黑色或灰色对象直接引用扫描到,它仍然是 白色 的,最终被错误地当成垃圾回收了。这是 绝对不能接受 的错误!
    • 发生的条件(同时满足):
      • 赋值器(用户线程)插入了一条或多条从黑色对象到白色对象的新引用。
      • 赋值器删除了所有从灰色对象到该白色对象的直接或间接引用。
  2. 浮动垃圾 (Floating Garbage):

    • 场景: 一个已经被标记为灰色或黑色的对象,在并发标记或并发清除阶段,被用户线程断开了所有引用,变成了垃圾。
    • 后果: 由于它已经被标记为“存活”(非白色),本轮GC不会回收它。它成为了“浮动垃圾”,只能等待下一轮GC。这虽然 不影响正确性,但会 降低内存利用率

CMS必须解决“对象消失”这个致命问题,同时尽量减少“浮动垃圾”。它主要通过 写屏障(Write Barrier)增量更新(Incremental Update) 技术来实现这一点。

6. 写屏障与增量更新:并发标记的救星

为了解决三色标记在并发环境下可能出现的“对象消失”问题,CMS 引入了 写屏障(Write Barrier)增量更新(Incremental Update) 机制。

6.1. 写屏障 (Write Barrier)

什么是写屏障?

写屏障 不是 硬件层面的内存屏障(Memory Barrier),而是JVM层面的一种 代码注入技术。当JVM在编译Java代码时,如果发现代码执行的是 引用类型字段的赋值操作(例如 obj.field = someOtherObj;),它会在这个赋值操作的 前后 插入一些额外的、特殊的处理代码。这些被插入的代码就称为“写屏障”。

写屏障的作用?

它的核心作用是 拦截或记录 用户线程对对象引用关系的修改。就像在每个对象引用赋值的地方安插了一个“监视器”,一旦发生修改,就触发特定的动作,通知GC系统。

写屏障的种类:

  • 写前屏障 (Pre-Write Barrier):赋值发生之前 执行。它通常关注的是“即将失去的引用”,比如记录下 obj.field 原本指向的对象。
  • 写后屏障 (Post-Write Barrier):赋值发生之后 执行。它通常关注的是“新建立的引用”,比如记录下 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);}
}

6.2. 增量更新 (Incremental Update)

增量更新是CMS用来 解决漏标(对象消失) 问题所采用的具体策略。它关注的是 黑色对象指向白色对象 这种情况的发生。

核心思想:

当一个黑色对象 A 新增了对一个白色对象 B 的引用时 (A.ref = B;),为了防止 B 被漏标,增量更新策略会通过写屏障捕捉到这个事件,并采取措施 记录 下这个变化。

具体做法:

当写屏障检测到 isBlack(A) && isWhite(B) 的情况时,它 不会 立即把 B 变成灰色(因为并发访问灰色集合也可能存在问题),而是将 A 重新标记回灰色,或者更常见的是,将这个 新增的引用关系 (A, B) 记录在一个 专门的、需要额外扫描的列表 中。

为什么叫“增量”更新?

因为它只关注并发标记过程中 新增 的黑色到白色的引用关系。它假设在标记开始时建立的对象图快照是基础,然后只处理后续发生的“增量”变化。

重新标记阶段的作用:

重新标记(Remark) 这个STW阶段,GC线程会:

  1. 暂停所有用户线程。
  2. 处理在并发标记期间,由增量更新机制记录下来的所有 引用变化信息(比如那个专门的列表)。
  3. 从这些记录出发,重新扫描受影响的对象,确保所有可达对象最终都被正确标记(变成黑色)。

伪代码示例(增量更新逻辑):

// 增量更新记录列表
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阶段统一处理这些记录,从而保证了并发标记的正确性,防止了“对象消失”的致命错误。

6.3. 卡表 (Card Table):优化重新标记

虽然增量更新解决了正确性问题,但如果在重新标记阶段需要扫描所有记录下来的对象以及它们引用的对象,开销仍然可能很大。为了进一步 优化重新标记阶段的扫描范围,CMS(以及很多现代GC)引入了 卡表(Card Table) 机制。
换句话说:
卡表(Card Table)是实现增量更新(Incremental Update)策略的一种高效的技术手段,它优化了“记录修改”这个环节。
增量更新的目标: 是为了解决并发标记中“黑色对象引用了新的白色对象,但GC没发现”的问题。它要求GC必须记录下那些在并发标记期间被修改过的、可能指向新对象的“黑色对象”(或更简单地说,记录下发生过引用写入的区域)。
如何记录

  • 最精确但可能最慢的方式: 记录下每一个发生这种“黑指向白”赋值操作的对象地址。这需要在写屏障里做很多判断和记录,开销可能很大。
  • 卡表的方式(更优化的方式): 不记录精确的对象,而是记录一个粗粒度的区域(卡页)。只要卡页内的任何一个对象的引用字段被修改了(通常简化为只要有引用写入就标记),就把这个卡页标记为“脏”。
    卡表的优化体现在哪里
  • 写屏障开销小: 标记一个字节(卡表项)非常快,比精确记录对象和判断颜色/代等复杂逻辑要高效得多,对应用程序的吞吐量影响更小。
  • 空间效率高: 只需要 HeapSize / CardSize 的额外空间,比存储大量对象指针要节省得多。

所以,不是说先有了一个“增量更新”的抽象算法,然后卡表来优化它。
而是:
为了实现“增量更新”这个策略(即在并发标记后重新检查被修改过的区域),需要一种记录修改的方法。
卡表提供了一种非常高效、低开销的记录方法。 它用空间换时间(可能标记了一些不需要的区域),但极大地降低了在应用程序运行时(写屏障触发时)的性能损耗。

什么是卡表?

卡表是一个 位图(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线程不再需要扫描整个老年代来查找可能存在的引用变化。它们只需要:

  1. 扫描卡表,找到所有被标记为“脏”的卡页。
  2. 扫描那些“脏”卡页内的对象,查找它们是否有指向白色对象的引用,并进行相应的标记处理(结合增量更新记录的信息)。

这极大地缩小了重新标记阶段需要扫描的范围,从而显著缩短了STW时间。

总结: 卡表通过空间换时间的方式,用一个额外的位图/字节数组记录了内存区域的“脏”状态。写屏障在修改引用时快速标记对应的卡页,使得重新标记阶段只需扫描脏页,大大提高了效率。

CMS并发标记的完整保障机制:

三色标记(理论基础)+ 写屏障(监测变化)+ 增量更新(处理新增引用,保正确性)+ 卡表(记录脏区,提效率)= CMS并发标记的组合拳。

7. CMS 的核心痛点详解

我们在前面提到了CMS的几个主要缺点,现在我们来更深入地探讨它们,特别是并发失败、内存碎片和浮动垃圾这三大痛点。

7.1. 并发失败 (Concurrent Mode Failure)

这是使用CMS时最需要关注和尽量避免的问题,因为它会导致长时间的STW。

复习:为什么会发生?

CMS的并发回收(标记、清除)需要时间。如果在GC线程完成回收之前,用户线程持续快速地分配内存(包括Young GC晋升的对象和直接在老年代分配的大对象),导致老年代空间不足以容纳新的对象,就会触发并发失败。本质上是 回收速度跟不上分配速度

导致并发失败的具体场景:

  1. Young GC 晋升失败: Young GC后,存活对象需要晋升到老年代,但此时老年代剩余空间(即使CMS正在并发回收)不足以容纳这些对象。
  2. 并发分配大对象失败: 用户线程尝试在老年代直接分配一个大对象,但由于内存碎片或者并发回收尚未释放足够连续空间,导致分配失败。
  3. 预留空间不足: -XX:CMSInitiatingOccupancyFraction 设置过高,或者应用内存增长模式突变,导致CMS启动回收时,剩余空间不足以支撑到并发回收完成。

后果:

JVM会停止所有用户线程(STW),然后调用 Serial Old 收集器(一个单线程、标记-整理算法的收集器)来对整个老年代进行垃圾回收,包括内存整理。这个过程非常缓慢,STW时间可能长达数秒甚至更久。

如何调优避免?

调优的核心思路是:让CMS尽早开始回收,或者让回收过程更快,或者减少内存分配压力。

  1. 降低触发阈值: 调低 -XX:CMSInitiatingOccupancyFraction=N 的值(N是百分比,例如60-80),让CMS在老年代占用率达到N%时就提前开始回收,预留更多的时间和空间。这是 最常用 的调优手段。需要根据应用的内存增长速率和GC日志来找到一个合适的值。太低会增加GC频率,太高则容易并发失败。
  2. 增加并发回收线程数: 通过 -XX:ConcGCThreads=N 适当增加并发标记和并发清除的线程数(如果CPU资源允许),加快回收速度。但线程过多也会增加CPU开销。
  3. 减少内存碎片:
    • 开启Full GC时的压缩:-XX:+UseCMSCompactAtFullCollection (默认开启)。
    • 调整压缩频率:-XX:CMSFullGCsBeforeCompaction=N。如果并发失败频繁且主要是由碎片引起,可以考虑设置为0,让每次后备的Full GC都进行压缩,但这会增加Full GC的STW时间。更理想的是通过其他方式减少大对象的产生或优化对象生命周期。
  4. 优化应用内存使用:
    • 减少大对象的分配。
    • 优化对象生命周期,避免大量对象集中晋升到老年代。
    • 检查是否存在内存泄漏。
  5. 增大老年代空间: 如果物理内存允许,直接增大老年代的总大小 (-Xmx, -Xms 配合调整新生代比例 -XX:NewRatio 或大小 -Xmn),可以给CMS更多缓冲空间。
  6. 在Remark前触发Young GC: 使用 -XX:+CMSScavengeBeforeRemark。在重新标记(STW)之前,先进行一次Young GC。这样做的好处是:
    • 减少老年代的对象数量(一些短期对象被回收)。
    • 减少重新标记阶段需要扫描的新生代对象(因为引用关系更少了)。
    • 可以略微缩短Remark的STW时间,并可能减少并发阶段的引用变化。

监控: 密切关注GC日志,查找 “Concurrent Mode Failure” 或 “promotion failed” 关键字,分析失败前后的内存使用情况和GC活动。

7.2. 内存碎片 (Memory Fragmentation)

这是CMS采用标记-清除算法带来的先天不足。

复习:为什么会产生?

标记-清除算法只回收死亡对象占用的空间,但不移动存活对象。回收后,内存中会留下许多不连续的小块空闲区域。

影响:

  • 大对象分配困难: 最直接的影响是,当需要分配一个较大的连续内存块时(比如一个大数组或大对象),即使总的空闲内存很多,也可能找不到足够大的连续空间,导致分配失败。
  • 提前触发Full GC: 当碎片严重到无法满足正常分配(尤其是大对象分配)时,即使老年代整体占用率不高,JVM也可能被迫触发一次带压缩的Full GC(使用Serial Old或配置了压缩的CMS Full GC),导致长时间STW。

CMS的应对措施:

CMS本身 不直接 解决并发清除阶段的碎片问题。它依赖于:

  1. Full GC时的整理: 通过 -XX:+UseCMSCompactAtFullCollection-XX:CMSFullGCsBeforeCompaction 参数,在发生Full GC(包括并发失败后的Full GC)时进行内存整理。但这本身就是一种“亡羊补牢”,且会带来STW。
  2. 寄希望于分配策略: JVM的内存分配器(如TLAB - Thread Local Allocation Buffer)会尽量在现有的小块碎片中进行分配,但这无法根本解决大对象分配问题。

根本性解决:

真正能较好解决碎片问题的GC算法是 标记-复制(Mark-Copy)标记-整理(Mark-Compact)。这也是为什么后续的G1、ZGC等收集器都采用了不同的策略(如G1的分区复制、ZGC的指针染色与重定位)来避免或处理碎片问题。

7.3. 浮动垃圾 (Floating Garbage)

复习:为什么会产生?

在CMS并发标记阶段之后、并发清除阶段完成之前,如果用户线程使得某个原本被标记为存活的对象变成了垃圾(断开了所有引用),CMS在本轮GC中无法回收它。

影响:

  • 内存利用率下降: 这部分垃圾对象继续占用内存,直到下一次GC才能被回收。
  • 需要预留空间: CMS需要预留一部分空间来容纳这些潜在的浮动垃圾,进一步增加了 -XX:CMSInitiatingOccupancyFraction 提前触发回收的必要性。
  • 可能增加GC频率: 如果浮动垃圾积累过多,可能导致老年代更快达到触发阈值。

能否解决?

CMS的增量更新机制 无法 解决浮动垃圾问题(它主要解决漏标)。SATB策略(如G1使用)能更好地处理浮动垃圾(因为它基于快照,快照中存活的对象即使后来变垃圾了也会保留到本轮结束),但CMS没有采用。

对于CMS来说,浮动垃圾是其并发设计所必须接受的一个副作用。只能通过合理配置 -XX:CMSInitiatingOccupancyFraction 来为其预留足够的空间。

总结: CMS的这三大痛点——并发失败的风险、内存碎片的积累、浮动垃圾的存在——是其设计上的固有局限。现代GC如G1、ZGC等都在尝试用更先进的技术来克服这些问题。

8. CMS 适用场景与被取代的原因

8.1. 何时考虑使用 CMS?

在CMS还盛行的年代(大约在JDK 6、7、8时期),判断是否使用CMS主要基于以下考量:

  1. 应用对延迟的敏感度极高: 这是选择CMS的最主要原因。如果你的应用无法容忍几十毫秒以上的STW停顿,例如:
    • 需要快速响应用户请求的Web服务器。
    • 实时交易系统、金融报价系统。
    • DNS服务器、电信网关。
    • 对交互体验要求高的桌面应用。
  2. 服务器CPU资源充足: CMS并发阶段需要额外的CPU资源。如果服务器是多核(例如4核及以上),能够承受GC线程带来的额外开销而不至于严重影响应用吞吐量。
  3. 内存分配速率不是极端快: 如果应用内存分配速率非常惊人,导致CMS回收速度跟不上,频繁触发Concurrent Mode Failure,那么CMS可能不是好的选择。
  4. 对内存碎片有一定容忍度: 如果应用主要是分配中小对象,或者大对象分配不频繁,或者能够接受偶尔由碎片整理带来的Full GC停顿,那么碎片问题可能不构成主要障碍。
  5. 堆内存大小适中: CMS在处理超大堆(几十GB甚至上百GB)时,其并发标记和清除时间会相应变长,重新标记阶段的STW也可能变得不可忽视。虽然可以通过调优缓解,但对于非常大的堆,G1通常表现更好。

简单来说: 如果我们的首要目标是 低延迟,并且愿意牺牲一定的 吞吐量内存空间,同时有足够的 CPU资源,那么CMS在当时是一个不错的选择。

8.2. 为什么 CMS 被废弃和移除?

随着技术的发展和更优秀替代品的出现,CMS逐渐暴露出的缺点和维护成本使其最终被淘汰。主要原因包括:

  1. 标记-清除算法的固有缺陷(内存碎片): 内存碎片问题是CMS的硬伤,长期运行可能导致性能瓶颈或频繁的Full GC,需要复杂的调优和碎片整理策略,而碎片整理本身又会带来STW。
  2. 并发失败问题难以根治: Concurrent Mode Failure的风险始终存在,一旦发生,其带来的长STW惩罚非常严重,使得CMS的低延迟优势变得不稳定。调优复杂且依赖经验。
  3. 对CPU资源消耗较大: 在CPU资源本就紧张的场景下,CMS对吞吐量的影响比较明显。
  4. 浮动垃圾导致内存利用率低: 需要预留较多内存,实际可用堆空间小于预期。
  5. 实现复杂,维护成本高: CMS内部涉及大量复杂的并发控制和同步机制,对于JVM开发团队来说,维护和持续优化它的成本很高。
  6. G1等更优秀的替代品出现:
    • G1 (Garbage-First) 收集器 的出现是CMS被取代的关键因素。G1的设计目标之一就是取代CMS,它:
      • 采用了区域化(Region) 的堆内存布局,化整为零。
      • 引入了可预测的停顿时间模型 (-XX:MaxGCPauseMillis),用户可以设定期望的最大停顿时间。
      • 使用了标记-复制(在Young GC和Mixed GC的部分阶段)和标记-整理(在Full GC时)算法,从根本上解决了内存碎片问题。
      • 通过优先回收价值最高(垃圾最多)的区域 (Garbage First) 来提高回收效率。
      • 兼具并发并行特性。
    • 后续的 ZGCShenandoah 更是将低延迟做到了极致(目标停顿在毫秒甚至亚毫秒级),虽然它们的应用场景和成熟度还在发展中。

结论: G1在解决了CMS核心痛点(碎片、并发失败可控性、可预测停顿)的同时,提供了相当不错的性能表现,并且配置相对更简单,成为了JDK 9及以后版本的默认垃圾收集器。这使得CMS的历史使命基本完成,被废弃和移除也就顺理成章了。

9. 总结:CMS 的历史价值

CMS作为第一款真正意义上的 并发 垃圾收集器,在JVM发展史上具有里程碑式的意义。它首次将“低延迟”作为核心设计目标,并通过创新的并发标记和并发清除技术,极大地改善了对响应时间敏感的应用的用户体验。

虽然CMS因为其固有的设计缺陷(内存碎片、并发失败风险、CPU消耗)以及更优秀的替代者(G1、ZGC等)的出现而被逐渐淘汰,但学习和理解CMS的工作原理仍然非常有价值:

  • 理解GC的演进: CMS是理解G1、ZGC等现代并发GC设计思想的重要基础。很多现代GC的技术,如三色标记、写屏障、卡表等,都是在CMS或更早的GC探索中逐步发展和完善起来的。
  • 深入JVM内存管理: 掌握CMS有助于更深入地理解JVM如何管理内存、如何平衡吞吐量与延迟、并发GC面临的挑战以及解决这些挑战的技术手段。
  • 遗留系统维护: 在一些尚未升级JDK版本的旧系统中,可能仍然在使用CMS。理解其原理有助于对这些系统进行问题排查和性能调优。

CMS就像一位开创了新道路但自身并非完美的先行人。它证明了并发垃圾收集的可行性,为后续更先进、更完善的垃圾收集器的诞生铺平了道路。

版权声明:

本网仅为发布的内容提供存储空间,不对发表、转载的内容提供任何形式的保证。凡本网注明“来源:XXX网络”的作品,均转载自其它媒体,著作权归作者所有,商业转载请联系作者获得授权,非商业转载请注明出处。

我们尊重并感谢每一位作者,均已注明文章来源和作者。如因作品内容、版权或其它问题,请及时与我们联系,联系邮箱:809451989@qq.com,投稿邮箱:809451989@qq.com

热搜词