浅入浅出 CMS 和 G1,哈哈

本贴最后更新于 1639 天前,其中的信息可能已经时移俗易

在开始介绍 CMS 和 G1 前,我们可以剧透几点:

  • 根据不同分代的特点,收集器可能不同。有些收集器可以同时用于新生代和老年代,而有些时候,则需要分别为新生代或老年代选用合适的收集器。一般来说,新生代收集器的收集频率较高,应选用性能高效的收集器;而老年代收集器收集次数相对较少,对空间较为敏感,应当避免选择基于复制算法的收集器。
  • 在垃圾收集执行的时刻,应用程序需要暂停运行
  • 可以串行收集,也可以并行收集。
  • 如果能做到并发收集(应用程序不必暂停),那绝对是很妙的事情。
  • 如果收集行为可控,那也是很妙的事情。

CMS 和 G1 作为垃圾收集器里的大杀器,是需要好好弄明白的,而且面试中也经常被问到。

希望大家带着下面的问题进行阅读,有目标的阅读,收获更多:

  1. 为什么没有一种牛逼的收集器像银弹一样适配所有场景?
  2. CMS 的优点、缺点、适用场景?
  3. 为什么 CMS 只能用作老年代收集器,而不能应用在新生代的收集?
  4. G1 的优点、缺点、适用场景?

1 CMS 收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。这是因为 CMS 收集器工作时,GC 工作线程与用户线程可以 并发 执行,以此来达到降低收集停顿时间的目的。

CMS 收集器仅作用于老年代的收集,是基于 标记-清除算法 的,它的运作过程分为 4 个步骤:

  • 初始标记(CMS initial mark)
  • 并发标记(CMS concurrent mark)
  • 重新标记(CMS remark)
  • 并发清除(CMS concurrent sweep)

其中,初始标记重新标记 这两个步骤仍然需要 Stop-the-world。初始标记仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快,并发标记阶段就是进行 GC Roots Tracing 的过程,而重新标记阶段则是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始阶段稍长一些,但远比并发标记的时间短。

CMS 以流水线方式拆分了收集周期,将耗时长的操作单元保持与应用线程并发执行。只将那些必需 STW 才能执行的操作单元单独拎出来,控制这些单元在恰当的时机运行,并能保证仅需短暂的时间就可以完成。这样,在整个收集周期内,只有两次短暂的暂停(初始标记和重新标记)达到了近似并发的目的

CMS 收集器优点:并发收集、低停顿。

CMS 收集器缺点

  • CMS 收集器对 CPU 资源非常敏感。
  • CMS 收集器无法处理浮动垃圾(Floating Garbage)。
  • CMS 收集器是基于标记-清除算法,该算法的缺点都有。

CMS 收集器之所以能够做到并发,根本原因在于采用基于“标记-清除”的算法并对算法过程进行了细粒度的分解。前面篇章介绍过标记-清除算法将产生大量的内存碎片这对新生代来说是难以接受的,因此新生代的收集器并未提供 CMS 版本。

另外要补充一点,JVM 在暂停的时候,需要选准一个时机。由于 JVM 系统运行期间的复杂性,不可能做到随时暂停,因此引入了安全点的概念。

安全点(Safepoint)

安全点,即程序执行时并非在所有地方都能停顿下来开始 GC,只有在到达安全点时才能暂停。Safepoint 的选定既不能太少以至于让 GC 等待时间太长,也不能过于频繁以致于过分增大运行时的负荷。

安全点的初始目的并不是让其他线程停下,而是找到一个稳定的执行状态。在这个执行状态下,Java 虚拟机的堆栈不会发生变化。这么一来,垃圾回收器便能够“安全”地执行可达性分析。只要不离开这个安全点,Java 虚拟机便能够在垃圾回收的同时,继续运行这段本地代码。

程序运行时并非在所有地方都能停顿下来开始 GC,只有在到达安全点时才能暂停。安全点的选定基本上是以程序“是否具有让程序长时间执行的特征”为标准进行选定的。“长时间执行”的最明显特征就是指令序列复用,例如方法调用、循环跳转、异常跳转等,所以具有这些功能的指令才会产生 Safepoint。

对于安全点,另一个需要考虑的问题就是如何在 GC 发生时让所有线程(这里不包括执行 JNI 调用的线程)都“跑”到最近的安全点上再停顿下来。

两种解决方案:

  • 抢先式中断(Preemptive Suspension)

    抢先式中断不需要线程的执行代码主动去配合,在 GC 发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点上,就恢复线程,让它“跑”到安全点上。现在几乎没有虚拟机采用这种方式来暂停线程从而响应 GC 事件。

  • 主动式中断(Voluntary Suspension)

    主动式中断的思想是当 GC 需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起。轮询标志的地方和安全点是重合的,另外再加上创建对象需要分配内存的地方。

安全区域

指在一段代码片段中,引用关系不会发生变化。在这个区域中任意地方开始 GC 都是安全的。也可以把 Safe Region 看作是被扩展了的 Safepoint。

2 G1 收集器

G1 重新定义了堆空间,打破了原有的分代模型,将堆划分为一个个区域。这么做的目的是在进行收集时不必在全堆范围内进行,这是它最显著的特点。区域划分的好处就是带来了停顿时间可预测的收集模型:用户可以指定收集操作在多长时间内完成。即 G1 提供了接近实时的收集特性。

G1 与 CMS 的特征对比如下:

特征 G1 CMS
并发和分代
最大化释放堆内存
低延时
吞吐量
压实
可预测性
新生代和老年代的物理隔离

G1 具备如下特点:

  • 并行与并发:G1 能充分利用多 CPU、多核环境下的硬件优势,使用多个 CPU 来缩短 Stop-the-world 停顿的时间,部分其他收集器原来需要停顿 Java 线程执行的 GC 操作,G1 收集器仍然可以通过并发的方式让 Java 程序继续运行。
  • 分代收集
  • 空间整合:与 CMS 的标记-清除算法不同,G1 从整体来看是基于标记-整理算法实现的收集器,从局部(两个 Region 之间)上来看是基于“复制”算法实现的。但无论如何,这两种算法都意味着 G1 运作期间不会产生内存空间碎片,收集后能提供规整的可用内存。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次 GC
  • 可预测的停顿:这是 G1 相对于 CMS 的一个优势,降低停顿时间是 G1 和 CMS 共同的关注点。

在 G1 之前的其他收集器进行收集的范围都是整个新生代或者老年代,而 G1 不再是这样。在堆的结构设计时,G1 打破了以往将收集范围固定在新生代或老年代的模式,G1 将堆分成许多相同大小的区域单元,每个单元称为 Region。Region 是一块地址连续的内存空间,G1 模块的组成如下图所示:

G1 堆的 Region 布局.png

G1 收集器将整个 Java 堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分 Region(不需要连续)的集合。Region 的大小是一致的,数值是在 1M 到 32M 字节之间的一个 2 的幂值数,JVM 会尽量划分 2048 个左右、同等大小的 Region,这一点可以参看如下源码。其实这个数字既可以手动调整,G1 也会根据堆大小自动进行调整。

#ifndef SHARE_VM_GC_G1_HEAPREGIONBOUNDS_HPP
#define SHARE_VM_GC_G1_HEAPREGIONBOUNDS_HPP

#include "memory/allocation.hpp"

class HeapRegionBounds : public AllStatic {
private:
  // Minimum region size; we won't go lower than that.
  // We might want to decrease this in the future, to deal with small
  // heaps a bit more efficiently.
  static const size_t MIN_REGION_SIZE = 1024 * 1024;

  // Maximum region size; we don't go higher than that. There's a good
  // reason for having an upper bound. We don't want regions to get too
  // large, otherwise cleanup's effectiveness would decrease as there
  // will be fewer opportunities to find totally empty regions after
  // marking.
  static const size_t MAX_REGION_SIZE = 32 * 1024 * 1024;

  // The automatic region size calculation will try to have around this
  // many regions in the heap (based on the min heap size).
  static const size_t TARGET_REGION_NUMBER = 2048;

public:
  static inline size_t min_size();
  static inline size_t max_size();
  static inline size_t target_number();
};

#endif // SHARE_VM_GC_G1_HEAPREGIONBOUNDS_HPP

G1 收集器之所以能建立可预测的停顿时间模型,是因为它可以有计划地避免在整个 Java 堆中进行全区域的垃圾收集。G1 会通过一个合理的计算模型,计算出每个 Region 的收集成本并量化,这样一来,收集器在给定了“停顿”时间限制的情况下,总是能选择一组恰当的 Regions 作为收集目标,让其收集开销满足这个限制条件,以此达到实时收集的目的。

对于打算从 CMS 或者 ParallelOld 收集器迁移过来的应用,按照官方 的建议,如果发现符合如下特征,可以考虑更换成 G1 收集器以追求更佳性能:

  • 实时数据占用了超过半数的堆空间;
  • 对象分配率或“晋升”的速度变化明显;
  • 期望消除耗时较长的 GC 或停顿(超过 0.5——1 秒)。

原文如下:
Applications running today with either the CMS or the ParallelOld garbage collector would benefit switching to G1 if the application has one or more of the following traits.

  • More than 50% of the Java heap is occupied with live data.
  • The rate of object allocation rate or promotion varies significantly.
  • Undesired long garbage collection or compaction pauses (longer than 0.5 to 1 second)

G1 收集的运作过程大致如下:

  • 初始标记(Initial Marking):仅仅只是标记一下 GC Roots 能直接关联到的对象,并且修改 TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的 Region 中创建新对象,这阶段需要 停顿线程,但耗时很短
  • 并发标记(Concurrent Marking):是从 GC Roots 开始堆中对象进行可达性分析,找出存活的对象,这阶段耗时较长,但可与用户程序并发执行。
  • 最终标记(Final Marking):是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程 Remembered Set Logs 里面,最终标记阶段需要把 Remembered Set Logs 的数据合并到 Remembered Set 中,这阶段需要 停顿线程,但是可并行执行
  • 筛选回收(Live Data Counting and Evacuation):首先对各个 Region 的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划。这个阶段也可以做到与用户程序一起并发执行,但是因为只回收一部分 Region,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率。

全局变量和栈中引用的对象是可以列入根集合的,这样在寻找垃圾时,就可以从根集合出发扫描堆空间。在 G1 中,引入了一种新的能加入根集合的类型,就是 记忆集(Remembered Set)。Remembered Sets(也叫 RSets)用来跟踪对象引用。G1 的很多开源都是源自 Remembered Set,例如,它通常约占 Heap 大小的 20% 或更高。并且,我们进行对象复制的时候,因为需要扫描和更改 Card Table 的信息,这个速度影响了复制的速度,进而影响暂停时间。

image.png

卡表(Card Table)

有个场景,老年代的对象可能引用新生代的对象,那标记存活对象的时候,需要扫描老年代中的所有对象。因为该对象拥有对新生代对象的引用,那么这个引用也会被称为 GC Roots。那不是得又做全堆扫描?成本太高了吧。

HotSpot 给出的解决方案是一项叫做 卡表(Card Table)的技术。该技术将整个堆划分为一个个大小为 512 字节的卡,并且维护一个卡表,用来存储每张卡的一个标识位。这个标识位代表对应的卡是否可能存有指向新生代对象的引用。如果可能存在,那么我们就认为这张卡是脏的。

在进行 Minor GC 的时候,我们便可以不用扫描整个老年代,而是在卡表中寻找脏卡,并将脏卡中的对象加入到 Minor GC 的 GC Roots 里。当完成所有脏卡的扫描之后,Java 虚拟机便会将所有脏卡的标识位清零。

想要保证每个可能有指向新生代对象引用的卡都被标记为脏卡,那么 Java 虚拟机需要截获每个引用型实例变量的写操作,并作出对应的写标识位操作。

卡表能用于减少老年代的全堆空间扫描,这能很大的提升 GC 效率

我们可以看下官方文档对 G1 的展望(这段英文描述比较简单,我就不翻译了):

Future:
G1 is planned as the long term replacement for the Concurrent Mark-Sweep Collector (CMS). Comparing G1 with CMS, there are differences that make G1 a better solution. One difference is that G1 is a compacting collector. G1 compacts sufficiently to completely avoid the use of fine-grained free lists for allocation, and instead relies on regions. This considerably simplifies parts of the collector, and mostly eliminates potential fragmentation issues. Also, G1 offers more predictable garbage collection pauses than the CMS collector, and allows users to specify desired pause targets.

3 总结

查了下度娘有关 G1 的文章,绝大部分文章对 G1 的介绍都是停留在 JDK7 或更早期的实现很多结论已经存在较大偏差了,甚至一些过去的 GC 选项已经不再推荐使用。举个例子,JDK9 中 JVM 和 GC 日志进行了重构,如 PrintGCDetails 已经被标记为废弃,而 PrintGCDateStamps 已经被移除,指定它会导致 JVM 无法启动。

本文对 CMS 和 G1 的介绍绝大部分内容也是基于 JDK7,新版本中的内容有一点介绍,倒没做过多介绍(本人对新版本 JVM 还没有深入研究),后面有机会可以再出专门的文章来重点介绍。

4 参考

《深入理解 Java 虚拟机》《HotSpot 实战》《极客时间专栏》

  • JVM

    JVM(Java Virtual Machine)Java 虚拟机是一个微型操作系统,有自己的硬件构架体系,还有相应的指令系统。能够识别 Java 独特的 .class 文件(字节码),能够将这些文件中的信息读取出来,使得 Java 程序只需要生成 Java 虚拟机上的字节码后就能在不同操作系统平台上进行运行。

    180 引用 • 120 回帖
  • CMS
    10 引用 • 42 回帖

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...