CMS 与 G1 的沉浮——解密并发收集器背后的“浮动垃圾”之战

在 Java 应用的调优过程中,我们经常会面临这样的灵魂拷问:为什么 CMS 会因为“浮动垃圾”而频繁崩溃?为什么同样产生浮动垃圾,G1 却似乎能稳坐泰山?这背后不仅仅是内存布局差异的较量,更是两种截然不同的并发标记思想——增量更新(Incremental Update)原始快照(SATB) 的深度对决。

本文将带你拨开 JVM GC 的迷雾,深入解读 CMS 与 G1 产生浮动垃圾的底层机制。


一、破除迷思:“浮动垃圾”究竟是什么?

首先要明确一个概念:“浮动垃圾”(Floating Garbage)与“内存碎片”(Memory Fragmentation)是两码事。

在垃圾回收的并发阶段,因为垃圾回收线程和用户线程是交替运行的,这就会导致一种不可控的现象:在本次 GC 开始前还是存活的对象,在并发期间,用户线程将其引用断开了(让它变成了垃圾)。
由于各种算法的限制,当前的 GC 收集器无法在本次周期内将它回收掉,只能被迫让它多活一个周期,留给下一次 GC 去处理。这部分明明已经死亡,却能在本次 GC 中“蒙混过关”的对象,就被称为浮动垃圾

只要你使用并发收集器(无论是 CMS 还是 G1),“浮动垃圾”就是系统为换取低延迟而必须支付的代价。


二、CMS 的痛苦根源:增量更新与无奈的妥协

CMS (Concurrent Mark Sweep) 是以获取最短停顿时间为目标的垃圾收集器。它在并发标记阶段采用的核心算法是增量更新(Incremental Update)

1. 增量更新:关注“新增”的引用

在并发标记过程中,如果不加以干预,用户线程可能会把一个未被扫描的对象(白色)挂载到一个已经被扫描完的对象(黑色)下面,导致这个白色对象被误杀。

CMS 解决这个问题的策略是关注“新增”:一旦有新的引用关系建立,CMS 的写屏障就会记录下来,把那个已经被扫描过的黑色对象重新标记为灰色。在随后的最终标记(Remark)阶段,CMS 会暂停用户线程(STW),重新顺着这些灰色对象向下深挖扫描。

2. 为什么会产生浮动垃圾?

CMS 关注“新增”,但对“删除”却漠不关心。如果并发期间,某个对象被用户线程断开了所有引用,CMS 根本不管。由于这个对象在此前可能已经被标记为灰色或黑色(当作存活对象),本轮 GC 就会把它当成存活对象处理。这就是 CMS 产生浮动垃圾的原因。

3. 压死骆驼的最后一根稻草:碎片叠加

CMS 最致命的问题在于,它是一个基于一整块老年代的标记-清除(Mark-Sweep)回收器
大量清理不掉的“浮动垃圾”占据着空间,更要命的是,标记清除还会留下大大小小的内存碎片。当“浮动垃圾 + 内存碎片”导致老年代没有连续的内存来容纳新晋升的大对象时,CMS 就会发生灾难性的 Concurrent Mode Failure,直接退化为极其缓慢的单线程 Serial Old 收集器。


三、G1 的破局之道:原始快照与“宁可错杀一千”的决绝

既然 CMS 的最终标记阶段因为需要重新顺着引用树深度扫描而耗时巨大,G1 (Garbage-First) 决定换一条路。它采用了 原始快照(SATB, Snapshot-At-The-Beginning) 算法。

1. SATB:关注“删除”的引用

G1 不管什么“新增引用”,它认为这太繁琐。它的核心理念是:“在 GC 开始那一刻,所有活着的对象,在本次 GC 中都必须活下来!”

所以,G1 的前置写屏障关注的是“删除”。当用户线程试图断开一个对象的引用时,不管这个对象去哪了,也不管它是不是变成了垃圾,G1 都会把这个“旧引用”塞进一个叫 SATB Buffer 的小本本里。

2. 为什么 G1 产生的浮动垃圾往往比 CMS 更多?

G1 产生浮动垃圾不仅不可避免,甚至还更加“疯狂”。主要原因有两点:

  1. 强行救活旧引用:在最终标记阶段,G1 根本不去深度追踪,而是直接把 SATB Buffer 里面记录的那些旧引用对象,强行标记为存活(涂黑),甚至还会把它们下面挂着的对象一起保住。哪怕这个引用刚刚在并发阶段被断开(已经变成垃圾),G1 也不管,“只要你在快照里活着,我就不杀你”。
  2. 隐式存活的新生对象:在并发期间新分配的对象,G1 是怎么处理的?非常简单粗暴——G1 在每个 Region 设置了 TAMS (Top-at-Mark-Start) 指针,只要是在并发阶段新分配在 TAMS 之上的对象,统统视为存活,本次绝对不回收。

由于这种极其保守的策略,G1 在一次 GC 周期中往往会制造出大量的浮动垃圾。


四、巅峰对决:为什么 G1 能在浮动垃圾中“笑傲江湖”?

如果 G1 产生的浮动垃圾比 CMS 还要多,为什么它却没有像 CMS 那样动不动就崩溃呢?

这得益于 G1 精妙的架构设计,它把浮动垃圾带来的负面影响化解于无形:

  1. 化整为零的 Region 架构
    G1 把内存划分成一个个相等的 Region。浮动垃圾不会像 CMS 那样堵死一整块连续内存。到了下一次 GC 时,这些浮动垃圾“露馅”了,G1 会优先挑选那些垃圾最多的 Region 进行回收(Garbage First 策略),使得回收效率极高。
  2. 标记-复制,告别碎片
    G1 的回收本质是把存活对象转移(Evacuation)到空闲 Region 中。虽然它把浮动垃圾当成了活对象搬运了一次,浪费了一点空间和性能,但整个过程不会产生任何内存碎片。这意味着几乎不会出现因为“找不到连续空间”而退化为 Full GC 的惨剧。
  3. 极短的停顿时间(Remark)
    因为 SATB 只需要简单地把记录在案的旧对象涂黑,而不需要像 CMS 那样重新顺藤摸瓜深度扫描,这使得 G1 在最终标记阶段的速度远远快于 CMS,停顿时间(STW)高度可控。

五、总结:技术的取舍之道

  • CMS(增量更新):关注新增,无视删除。导致最终标记阶段活儿太重(需深度扫描),又因为内存碎片和整块老年代的限制,面对浮动垃圾常常力不从心。
  • G1(原始快照 SATB):关注删除,无视新增。宁可制造更多的浮动垃圾也要保住“快照”,依靠优秀的 Region 设计和复制整理机制,将浮动垃圾的影响降到最低,换来了极具优势的可预测停顿时间。

技术的演进从来都不是消除所有代价,而是在约束下找到更优雅的平衡。G1 与 CMS 的新老交替,正是这一哲学在 JVM 领域的最佳写照。