CMS与G1垃圾收集器详解

CMS 收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户交互体验的应用上使用.CMS 收集器是 HotSpot 虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。

  • 作用域:老年代
  • 垃圾收集算法:标记 - 清除算法
  • 线程数:并发线程
  • 特点:以缩短停顿时间为目标

从名字中的Mark Sweep可以看出,CMS 收集器是基于 “标记-清除”算法实现的,它的运作过程相比于前面几种垃圾收集器来说更加复杂一些。整个过程分为四个步骤:

  • 初始标记: 仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快。
  • 并发标记:并发标记阶段从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行
  • 重新标记: 重新标记阶段是为了修正并发标记期间因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录。这个阶段的停顿时间一般会比初始标记阶段的时间稍长,但也远比并发标记阶段的时间短。
  • 并发清除: 清理删除掉标记阶段判断的已经死亡的对象,对未标记的区域做清扫。由于不需要移动存活对象,所以这个阶段也是可以与用户线程并发执行的。

其中初始标记、重新标记这两个步骤仍然需要 “ Stop The World ”,但速度很快。由于在整个过程中耗时最长的并发标记和并发清除阶段中,垃圾收集器线程都可以与用户线程一起工作,所以从总体上 CMS 收集器的内存回收过程是与用户线程一起并发执行的。

CMS 是一款非常优秀的垃圾收集器,有着并发收集(2,4阶段)、低延迟(1,3阶段)的优点。但还远没有完美的程度,至少有三个明显缺点:

  • CMS 对处理器资源非常敏感,在并发阶段,虽然不会造成用户线程停顿,但是却会因为占用一部分线程而导致应用程序变慢,降低总吞吐量。
  • CMS 无法处理 “浮动垃圾” ,有可能出现 Concurrent Mode Failure 失败进而导致另一次完全 Stop The World 的 Full GC 产生。同样也是由于在垃圾收集阶段用户线程还需要持续运行,那就还需要预留足够内存空间提供给用户线程使用,因此CMS收集器不能像其他收集器那样等待老年代几乎完全被填满了再进行收集,而是当堆内存使用率达到某一阈值时,便开始进行回收。若预留的内存无法满足需要,则会出现一次“并发失败”(Concurrent Mode Failure)。

什么是浮动垃圾?由于并发标记和并发清理阶段,用户线程还是在继续运行的,程序自然就还会伴随有新的垃圾对象不断产生,而且这一部分垃圾对象出现在标记过程结束之后,CMS 无法在当次收集中处理掉这些垃圾,所以只能等到下一次垃圾回收时再进行清理。这一部分垃圾就称为浮动垃圾。

  • 由于使用 “标记-清除” 算法,会导致大量空间碎片产生,可能无法找到足够大连续空间来分配当前对象,不得不提前触发一次 Full GC

但CMS只能使用标记-清除算法,因为当并发清除时,如果用整理算法,对象的移动会使得地址被修改,用户线程则无法正确使用该对象。整理算法更适合 “Stop The World” 的场景下使用。

当然也可通过设置参数设置几次GC之后进行碎片整理。


G1 收集器

G1 (Garbage-First) 是一款面向服务端应用的垃圾收集器,主要针对配备多核CPU及大容量内存的机器,以极高概率满足 GC 停顿时间要求的同时,还兼具高吞吐量的性能特征。被视为 JDK1.7 中 HotSpot 虚拟机的一个重要进化特征,在 JDK 9 时正式取代 Parallel Scavenge + Parallel Old 组合成为默认垃圾收集器。

G1出现之前的垃圾收集器,包括CMS在内,垃圾收集的目标范围要么是整个新生代(Minor GC),要么就是整个老年代(Major GC),再要么就是整个 Java 堆(Full GC)。而 G1 跳出了这个框架,它可以面向堆内存的任何部分来组成回收集(Collection Set,CSet)衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是 G1 收集器的 Mixed GC 模式。

G1 是基于 Region 的堆内存布局来进行回收的,G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的 Java 堆划分为多个大小相等的独立区域 Region,每一个 Region 都可以根据需要,扮演新生代的 Eden 空间、Survivor 空间或者老年代空间,收集器能够对不同角色的 Region 采用不同的策略去处理。

Region 中还有一类特殊的 Humongous 区域,专门用来存储大对象。G1 认为只要大小超过了一个 Region 容量一半的对象即可判定为大对象。对于超过了整个 Region 容量的超级大对象,将会存放在 N 个连续的 Humongous Region 中,G1 大多数行为都会把 Humongous Region 作为老年代的一部分来看待。

特点
  • 并行与并发
    • 并行性:G1 能充分利用 CPU、多核环境下的硬件优势,使用多个GC线程同时工作来缩短用户线程 STW 停顿时间。
    • 并发性:部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 Java 程序继续执行。
  • 分代收集:虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但是还是保留了分代的概念。它会区分新生代和老年代,新生代依旧有Eden去、Survivor区,但从堆结构上看,它不要求整个Eden区、新生代或老年代都是连续的,也不再坚持固定大小和固定数量。
  • 空间整合:与 CMS 的“标记-清理”算法不同,G1 从整体来看是基于“标记-整理”算法实现的收集器;从局部上来看是基于“标记-复制”算法实现的,即内存回收是以Region为基本单位,Region之间使用复制算法。这两种算法都能避免内存碎片。
  • 可预测的停顿:这是 G1 相对于 CMS 的另一个大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在 GC 上的时间不得超过 N 毫秒。

G1收集器之所以能建立可预测的停顿时间模型,是因为它可以有计划地避免在整个Java堆中进行全区域的垃圾收集。G1跟踪各个Region里面的垃圾堆积的价值大小(价值即回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先级列表,每次根据允许的收集停顿时间(使用参数 -XX:MaxGCPauseMillis 指定,默认值200毫秒),优先处理回收价值最大的那些Region。这种使用Region划分内存空间以及具有优先级的区域回收方式,保证了G1收集器在有限的时间内获取尽可能高的收集效率。

优点(与CMS相比):

  • 不会产生内存空间碎片(有利于程序长时间运行)

  • 可以指定最大停顿时间

  • 分 Region 的内存布局

  • 按收益动态确定回收集

缺点(与CMS相比):

  • 占用额外的内存空间(每个Region都维护一份卡表)
  • 额外执行负载高(G1与CMS都使用写前屏障更新维护卡表,但G1使用的原始快照需要使用写前屏障来跟踪并发时指针变化情况,负担增大)
运行过程

G1收集器的运作过程大致可划分为以下四个步骤:

  • 初始标记:仅仅只是标记一下 GC Roots 能直接关联到的对象,并修改 TAMS 指针的值,让下一阶段用户线程并发运行时,能够在可用的 Region 中分配对象。这个阶段需要暂停用户线程,但是时间很短。而且这个停顿是借用 Minor GC 的时候同步完成的,所以在这个阶段实际没有额外的停顿。
  • 并发标记:从 GC Root 开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象。当对象图扫描完成后,还要重新处理 SATB 记录下的在并发时有引用变动的对象。
  • 最终标记:对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的 SATB 记录(原始快照,用来记录并发标记中某些对象)。
  • 筛选回收:负责更新 Region 的统计数据,对各个 Region 的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个 Region 构成回收集,然后把决定要回收的那一部分 Region 的存活对象复制到空的 Region 中,再清理掉整个旧 Region 的全部空间。这里的操作涉及存活对象的移动,所以必须要暂停用户线程,由多条收集器线程并行完成。

细节

1、将Java堆分成多个独立Region后,Region里面存在的跨Region引用对象怎么解决?

出现该情况可能是老年代Region引用新生代Region等,为了避免进行Minor GC回收新生代时还要扫描老年代对象,解决方案是使用记忆集避免全堆作为GC Roots扫描

每个Region都有一个对应的记忆集,每次Reference类型数据写操作时,都会产生一个写屏障暂时中断操作;然后检查将要写入的引用指向的对象是否和该Reference类型数据在不同的Region(其他收集器:检查老年代对象是否引用了新生代对象);如果不同,通过卡表把相关引用信息记录到引用指向对象的所在Region对应的记忆集中;

当进行垃圾收集时,在GC根节点的枚举范围加入记忆集;就可以保证不进行全局扫描,也不会有遗漏。

2、G1 使用原始快照(SATB)算法来保证收集线程与于户线程互不干扰地运行,即用户线程改变对象引用关系时,必须保证其不能打破原本的对象图结构

3、垃圾收集对用户线程的影响还体现在回收过程中新创建对象的内存分配上,程序要继续运行就肯定会持续有新对象被创建,G1为每个Region设计了两个名为TAMS(Top at Mark Start) 的指针,把Region中的一部分空间划分出来用于并发过程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上。
G1会默认这个地址上的对象是存活的,如果内存回收跟不上内存分配的速度,那么G1收集器也会被迫冻结用户的线程,导致Full GC而产生长时间 Stop The World


参考资料

《深入理解Java虚拟机 第3版》——周志明