再回首CMS垃圾回收

前言

之前学习JVM垃圾回收时,主要是过了一遍垃圾收集算法,比如复制算法,标记-清除算法,标记-整理算法,在此基础上可以增加分代,每代采取不同的回收算法,以提高整体的分配和回收效率。然后过了一遍JVM中的垃圾收集器,比如Serial、Parallel Scavenge、Parallel New、CMS、G1等。

自认为垃圾收集就是根据GC Root标记所有可达的对象,然后把所有没有标记的对象清除就ok了。是不是很简单。事实上垃圾收集也就是这么一回事,但是很多时候说起来简单,做起来却会出现很多问题。这篇文章就是记录我对CMS垃圾收集器的一些疑问并学习的过程。

首先看一下CMS的整体流程(具体每个流程的详情就自行了解吧)

CMS流程

如何进行标记?

最近在看Golang的GC算法实现,里面用到了三色标记法,但是在我的知识库中对三色标记法有这个概念,是的,我只知道这个概念,不知道三色标记法是怎么一个流程,也不知道三色标记法在GC中怎么与运行的。于是就开始了我的探险之旅。

在搜索了一下三色标记法(具体可以看一下文末参考文档中三色标记法与读写屏障了解详情)后,发现现代追踪式(可达性分析)的垃圾回收器几乎都借鉴了三色标记的算法思想,CMS垃圾收集器也不例外。

GC Root有哪些?

我们知道怎么进行标记了,但最初标记的时候需要一些根据才行啊,这些根据就是我们说的GC Root。GC Root有哪些?网上有很多的答案,我的理解就是

  • 当前活跃调用栈中的指向对象的引用
  • 一些不会发生改变的数据所指向的引用

这里我使用的是引用,而不是对象,因为R大是这样说的(具体的问题见参考文档java的gc为什么要分代?

所谓“GC roots”,或者说tracing GC的“根集合”,就是一组必须活跃的引用
例如说,这些引用可能包括:

  • 所有Java线程当前活跃的栈帧里指向GC堆里的对象的引用;换句话说,当前所有正在被调用的方法的引用类型的参数/局部变量/临时值。
  • VM的一些静态数据结构里指向GC堆里的对象的引用,例如说HotSpot VM里的Universe里有很多这样的引用。
  • JNI handles,包括global handles和local handles
  • (看情况)所有当前被加载的Java类
  • (看情况)Java类的引用类型静态变量
  • (看情况)Java类的运行时常量池里的引用类型常量(String或Class类型)
  • (看情况)String常量池(StringTable)里的引用

注意,是一组必须活跃的引用,不是对象。

现在知道了GC Root,但是我们都知道有分代的概念,新生代的gc和老年的代的gc回收的区域是不一样,那么这里的GC Root是不是应该不一样呢?肯定是不一样的。

首先看一下新生代的GC

新生代的区域一般都比较小,而且对象的存活率都比较低,所以按照前面说的GC Root在新生代的区域扫描就行了。但是会有一个问题?老年代存在引用新生代对象的可能啊?如果只扫描新生代的区域,会漏掉被老年代引用的对象,这些对象就会被清除掉,这是不允许的。

如果这样的话,那是不是扫描一下老年代的对象,看是否引用新生代的对象是不是就ok了?嗯这么做肯定是ok的,但是老年代一般很大,而且存活的对象很多,会导致扫描占用很长的时间。那这个问题如何解?JVM是如何避免Minor GC时扫描全堆的?

经过统计信息显示,老年代持有新生代对象引用的情况不足1%,根据这一特性JVM引入了卡表(card table)来实现这一目的。如下图所示:

CardTable

卡表的具体策略是将老年代的空间分成大小为512B的若干张卡(card)。卡表本身是单字节数组,数组中的每个元素对应着一张卡,当发生老年代引用新生代时,虚拟机将该卡对应的卡表元素设置为适当的值。如上图所示,卡表3被标记为脏(卡表还有另外的作用,标识并发标记阶段哪些块被修改过),之后Minor GC时通过扫描卡表就可以很快的识别哪些卡中存在老年代指向新生代的引用。这样虚拟机通过空间换时间的方式,避免了全堆扫描。

所以新年代GC的GC Root包含2部分

  • 新生代中满足GC Root定义的对象
  • 卡表中老年代引用新生代的对象

老年代的GC

前面我们说了新生代的gc,我们已同样的思路来看看老年代的gc,老年代的GC Root如何来标记呢?只扫描老年代可以吗?当然是不行的,因为新生代中也可能存在老年代对象的引用,好在新生代并不大,所以老年代GC的时候还需要扫描一遍新生代。

新生代GC的Root

所以老年代GC的GC Root包含2部分

  • 老生代中满足GC Root定义的对象,如图节点1;
  • 标记年轻代中活着的对象引用到的老年代的对象(指的是年轻代中还存活的引用类型对象,引用指向老年代中的对象)如图节点2、3;

并发标记的好坏?

标记作为垃圾回收的第一步,现在知道如何进行标记,接下来就是遍历这些对象,将所有未标记的对象清理就完成GC了。

然而事实上并没有这么简单,如果标记的时候是STW的,那就是这么简单,但是如果标记过程都STW会造成暂停时间过长,给人的感觉就是系统一卡一卡的。

于是就把标记的过程改成并发的进行,也就是CMS中并发标记的过程,然而这就是一切复杂问题的源头。虽然并发标记提升了标记的效率,但是因此却引发了一系列的问题。

因为并发标记时,gc线程和用户线程是并行的,所以在这个过程中会出现下面的情况(需要了解三色标记法与读写屏障):

  • 新生代晋升到老年代
  • 黑色对象取消对灰色对象的引用(浮动垃圾)
  • 黑色对象新增对白色对象的引用(漏标)

其实在三色标记法与读写屏障文中已经给出了解决方法--添加读写屏障

  • 写屏障 + SATB
  • 写屏障 + 增量更新
  • 读屏障(Load Barrier)

在CMS并发标记阶段,使用 写屏障 + 增量更新 的方法,将上面出现的情况标记为dirty,这样最后再遍历处理一下Dirty集合中的对象就ok了

标记为dirty

重新标记阶段为什么还要扫描新生代?

因为存在跨代引用,但是前面说过这种情况,通过读写屏障的方式标记这些为dirty,只需要扫描老年代和dirty集合就行了啊?哎,看来我还是太年轻,如果只扫描老年代和dirty集合会漏掉一部分,会是哪部分呢?老年代和dirty集合还没有覆盖完吗?

是的,老年代和dirty集合的确没有覆盖完。我们来分析一下。老年代中经过初始标记和并发标记后,只有黑色对象和白色对象了,黑色的就是要留下的,白色的就是要被清除的。黑色对象是怎么来的?根据GC Root找到的,所以只要并发标记过程中,GC Root不发生变化,黑色对象就没有问题(不会漏标),如果在并发标记过程中GC Root发生了变化呢?

当并发标记过程中GC Root增加了,并且这个GC Root还引用了老年代中的对象,此时如果只扫描老年代和dirty集合就会漏标。因此重新标记阶段仍然需要扫描新生代。

预处理阶段都干了啥?

预处理阶段其实有2部分:

  • 预清理阶段
  • 可终止的预处理

这个阶段的目的都是为了减轻后面的重新标记的压力,提前做一点重新标记阶段的工作。一般CMS的GC耗时80%都在remark阶段,所以预处理阶段也是为了减少remark阶段的STW时间。

重新标记阶段需要做一下工作:

  1. 遍历新生代对象,重新标记
  2. 根据GC Roots,重新标记
  3. 遍历老年代的Dirty Card,重新标记(这里的Dirty Card大部分已经在clean阶段处理过)

遍历新生代对象时,可能很多对象已经是不可达了,但是还是需要扫描。遍历Dirty Card做处理。

这2部分其实就是预处理阶段帮助重新标记减轻压力的地方

  • 预清理阶段和可终止的预处理都会扫描Dirty Card做处理
  • 可终止的预处理,尽量进行一次ygc,让不可达的对象被回收掉,remark阶段遍历新生代的对象成本小一点

具体这个阶段的详情见参考文档图解CMS垃圾回收机制,你值得拥有

参考文档