之前学习JVM垃圾回收时,主要是过了一遍垃圾收集算法,比如复制算法,标记-清除算法,标记-整理算法,在此基础上可以增加分代,每代采取不同的回收算法,以提高整体的分配和回收效率。然后过了一遍JVM中的垃圾收集器,比如Serial、Parallel Scavenge、Parallel New、CMS、G1等。
自认为垃圾收集就是根据GC Root标记所有可达的对象,然后把所有没有标记的对象清除就ok了。是不是很简单。事实上垃圾收集也就是这么一回事,但是很多时候说起来简单,做起来却会出现很多问题。这篇文章就是记录我对CMS垃圾收集器的一些疑问并学习的过程。
首先看一下CMS的整体流程(具体每个流程的详情就自行了解吧)
CMS流程
最近在看Golang的GC算法实现,里面用到了三色标记法,但是在我的知识库中对三色标记法有这个概念,是的,我只知道这个概念,不知道三色标记法是怎么一个流程,也不知道三色标记法在GC中怎么与运行的。于是就开始了我的探险之旅。
在搜索了一下三色标记法(具体可以看一下文末参考文档中 三色标记法与读写屏障 了解详情)后,发现现代追踪式(可达性分析)的垃圾回收器几乎都借鉴了三色标记的算法思想,CMS垃圾收集器也不例外。
我们知道怎么进行标记了,但最初标记的时候需要一些根据才行啊,这些根据就是我们收的GC Root。GC Root有哪些?网上有很多的答案,我的理解就是
这里我使用的是引用,而不是对象,因为R大是这样说的(具体的问题见参考文档 java的gc为什么要分代? )
所谓“GC roots”,或者说tracing GC的“根集合”,就是 一组必须活跃的引用 。
例如说,这些引用可能包括:
注意,是一组必须活跃的 引用 ,不是对象。
现在知道了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,我们已同样的思路来看看老年代的gc,老年代的GC Root如何来标记呢?只扫描老年代可以吗?当然是不行的,因为新生代中也可能存在老年代对象的引用,好在新生代并不大,所以老年代GC的时候还需要扫描一遍新生代。
新生代GC的Root
所以老年代GC的GC Root包含2部分
标记作为垃圾回收的第一步,现在知道如何进行标记,接下来就是遍历这些对象,将所有未标记的对象清理就完成GC了。
然而事实上并没有这么简单,如果标记的时候是STW的,那就是这么简单,但是如果标记过程都STW会造成暂停时间过长,给人的感觉就是系统一卡一卡的。
于是就把标记的过程改成并发的进行,也就是CMS中并发标记的过程,然而这就是一切复杂问题的源头。虽然并发标记提升了标记的效率,但是因此却引发了一系列的问题。
因为并发标记时,gc线程和用户线程是并行的,所以在这个过程中会出现下面的情况(需要了解 三色标记法与读写屏障 ):
其实在 三色标记法与读写屏障 文中已经给出了解决方法--添加读写屏障
在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时间。
重新标记阶段需要做一下工作:
遍历新生代对象时,可能很多对象已经是不可达了,但是还是需要扫描。遍历Dirty Card做处理。
这2部分其实就是预处理阶段帮助重新标记减轻压力的地方
具体这个阶段的详情见参考文档 图解CMS垃圾回收机制,你值得拥有
参考文档
欢迎关注我们的微信公众号,每天学习Go知识