本文来说下关于JVM 的三色标记算法。
文章目录
三色标记算法概述
在说 JVM 的三色标记算法之前,我们先来说下 JVM 对于常见对象存活判定算法与垃圾收集算法。常见对象存活判定算法有引用计数算法和可达性分析算法。引用计数法会产生循环引用问题,JVM 默认是通过可达性分析算法来判断对象是否存活的。而那些垃圾收集算法:标记-清除、标记-复制、标记-整理算法以及在此基础上的分代收集算法(新生代/老年代),每代采取不同的回收算法,以提高整体的分配和回收效率。
这些垃圾收集算法首先做的都是通过可达性分析算法来判定对象是否存活,首先肯定是先进行标记,这个也是理所当然的,你不先标记找到垃圾,怎么进行垃圾回收?可达性分析算法是通过一系列的 “GC roots” 对象作为根节点搜索,如果在 “GC roots” 和一个对象之间没有可达路径,则称该对象是不可达的。
迄今为止,所有垃圾收集器在根节点枚举这一步骤时都是必须暂停用户线程的,因此毫无疑问会面临 ”Stop The World“ 的困扰。啊?啥是 ”Stop The World“,也就是我们平时说的 STW,其实就是根节点枚举过程中必须在一个能保障一致性的快照中进行,说白了就相当于持久化的快照一样,在某个时间点这个过程像被冻结了。如果根节点枚举过程中整个根节点集合对象引用关系还在变化,那垃圾回收分析的结果也不会准确,所以这就导致垃圾收集过程中必须停顿所有用户线程。
想要解决或者降低用户线程的停顿,三色标记算法就登场了。为了让大家了解为啥要有三色标记算法的存在,进行了一定的铺垫,希望大家可以理解三色标记算法的来龙去脉,这个算法不仅在Java的hotspot虚拟机中被使用到,在其他高级语言,比如Go语言中也被使用到。
引用计数&可达性分析
要进行垃圾回收GC,那么我们首先就要决定到底怎么判断对象是否存活?一般来说有两种方式。
引用计数,给对象添加一个计数器,每当有地方引用它计数器就+1,反之引用失效时就-1,那么计数器值为0的对象就是可以回收的对象,但是有一个问题就是循环引用的话无法解决。
对于现在的虚拟机来说,主要用的算法是可达性分析算法。
首先定义GC ROOTS根对象集合,通过GC ROOTS向下搜索,搜索的过程走过的路径称作引用链,如果某个对象到GC ROOTS没有任何引用链,那么就是对象不可达,是可以被回收的对象。
不可达对象需要进行两次标记,第一次发现没有引用链相连,会被第一次标记,如果需要执行finalize()方法,之后这个对象会被放进队列中等待执行finalize(),如果在finalize()中成功和引用链上的其他对象关联,就会被移出可回收对象集合。(但是不建议使用finalize()方法)。
分代收集
有了如何判断对象存活的基础,接下来的问题就是怎么进行垃圾收集GC,现在商用的虚拟机基本上都是分代收集的实现,它的实现建立于两个假说:
- 绝大多数对象都是朝生夕死的
- 熬过越多次垃圾回收的对象越难死亡
基于这两个假说,就产生了现在我们常见的年轻代和老年代。因为分代了,所以GC也就分代了。
年轻代用于存放那些死的快的对象,年轻代GC我们称之为MinorGC,每次年轻代内存不够我们就触发MinorGC,以后还有存活的对象我们就根据经历过MinorGC次数和动态年龄判断来决定是否晋升老年代。
老年代则存放老不死的对象,这里GC称之为OldGC,现在也有很多人把他叫做FullGC,实际上这并不准确,FullGC应该泛指年轻代和老年代的的GC。
按照我们上文所说的使用可达性分析算法来判断对象的存活,那么假如我们进行MinorGC,会不会有对象被老年代引用着?进行OldGC会不会又有对象被年轻代引用着?
如果是的话,那我们进行MinorGC的时候不光要管GC Roots,还有再去遍历老年代,这个性能问题就很大了。
因此,又来了一个假说。。
- 跨代引用相对于同代引用来说仅占极少数。
由此就产生了一个新的解决方案,我们不用去扫描整个老年代了,只要在年轻代建立一个数据结构,叫做记忆集Remembered Set,他把老年代划分为N个区域,标志出哪个区域会存在跨代引用。
以后在进行MinorGC的时候,只要把这些包含了跨代引用的内存区域加入GC Roots一起扫描就行了。
什么是卡表
说完这些,才到了第一个话题:卡表。
卡表实际上就是记忆集的一种实现方式,如果说记忆集是接口的话,那么卡表就是他的实现类。
对于HotSpot虚拟机来说,卡表的实现方式就是一个字节数组。
CARD_TABLE [this address >> 9] = 0;
这段代码代表着卡表标记的的逻辑。实际上卡表就是映射了一块块的内存地址,这些内存地址块称为卡页,从代码可以看出每个卡页的大小就是2^9=512字节。
如果转换为16进制,数组的0,1号元素就映射为0x0000~0x01FF(0-511)、0x0200~0x03FF(512-1023)内存地址的卡页。
只要一个卡页内的对象存在一个或者多个跨代对象指针,就将该位置的卡表数组元素修改为1,表示这个位置为脏,没有则为0。在GC的时候,就直接把值为1对应的卡页对象指针加入GC Roots一起扫描即可。有了卡表,我们就不需要去在发生MinorGC的时候扫描整个老年代了,性能得到了极大的提升。
卡表的问题
写屏障
卡表的数组元素要修改成1,也就是脏的状态,对于HotSpot来说是通过写屏障来实现的,实际上就是在其他分代引用了当前分代的对象时候,在对引用进行赋值的时候进行更新,更新的方式类似AOP的切面思想。
void oop_field_store(oop* field, oop new_value) {
// 引用字段赋值操作
*field = new_value;
// 写后屏障,在这里完成卡表状态更新
post_write_barrier(field, new_value);
}
写屏障带来的问题就是额外的性能开销,不过这个问题不大,还能接受。
伪共享
另外存在的问题就是我之前文章写过的,伪共享问题。缓存行通常来说都是64字节,一个卡表元素1个字节,占用的卡页内存大小就是64*512=32KB的大小。如果多线程刚好更新刚好处于这32KB范围内的对象,那么就会对性能产生影响。
怎么解决伪共享问题?JDK7之后新增了一个参数-XX:+UseCondCardMark,他代表是否开启卡表更新的判断,没有被标记过才标记为脏。
if (CARD_TABLE [this address >> 9] != 0)
CARD_TABLE [this address >> 9] = 0;
三色标记算法
卡表解决了跨代收集和根节点枚举的性能问题。而有了这些措施实际上枚举根节点这个过程造成的STW停顿已经属于可控范围。另外还存在一个问题就是接下来从GC Roots开始遍历,怎么才能高效的标记这些对象,这就是三色标记法的作用了。因为如果堆内的对象越多,那么显然标记产生的停顿时间就越长。
以现在我们熟知的CMS或者G1来举例,GC的前两个步骤如下:
- 初始标记:标记GC ROOT能关联到的对象,这一步需要STW,但是停顿的时间很短。
- 并发标记:从GCRoots的直接关联对象开始遍历整个对象图的过程,这个时间会比较长,但是现在是可以和用户线程并发执行的,这个效率的问题就是三色标记关注的问题。
在三色标记法中,把从GC Roots开始遍历的对象标记为以下三种颜色:
白色,尚未访问过。
灰色,本对象已访问过,而且本对象 引用到 的其他对象 也全部访问过了。
黑色,本对象已访问过,但是本对象 引用到 的其他对象 尚未全部访问完。全部访问后会转换为黑色。
基本算法
事先约定:
根据可达性分析算法,从 GC Roots 开始进行遍历访问。
初始状态,所有的对象都是白色的,只有 GC Roots 是黑色的。
初始标记阶段,GC Roots 标记直接关联对象置为灰色。
并发标记阶段,扫描整个引用链。
- 没有子节点的话,将本节点变为黑色。
- 有子节点的话,则当前节点变为黑色,子节点变为灰色。
重复并发标记阶段,直至灰色对象没有其它子节点引用时结束。
扫描完成
此时黑色对象就是存活的对象,白色对象就是已消亡可回收的对象。即(A、D、E、F、G)可达也就是存活对象,(B、C、H)不可达可回收的对象。
三色标记算法缺陷
不知道你是否还记得我们前言说的,所有垃圾收集器在根节点枚举这一步骤时都是必须暂停用户线程的,产生 STW,这对实时性要求高的系统来说,这种需要长时间挂起用户线程是不可接受的。想要解决或者降低用户线程的停顿的问题,我们才引入了三色标记算法。
三色标记算法也存在缺陷,在并发标记阶段的时候,因为用户线程与 GC 线程同时运行,有可能会产生多标或者漏标。
多标
假设已经遍历到 E(变为灰色了),此时应用执行了 objD.fieldE = null (D > E 的引用断开) 。
D > E 的引用断开之后,E、F、G 三个对象不可达,应该要被回收的。然而因为 E 已经变为灰色了,其仍会被当作存活对象继续遍历下去。最终的结果是:这部分对象仍会被标记为存活,即本轮 GC 不会回收这部分内存。
这部分本应该回收但是没有回收到的内存,被称之为浮动垃圾。浮动垃圾并不会影响应用程序的正确性,只是需要等到下一轮垃圾回收中才被清除。
另外,针对并发标记开始后的新对象,通常的做法是直接全部当成黑色,本轮不会进行清除。这部分对象期间可能会变为垃圾,这也算是浮动垃圾的一部分。
漏标
假设 GC 线程已经遍历到 E(变为灰色了),此时应用线程先执行了:
var G = objE.fieldG;
objE.fieldG = null; // 灰色E 断开引用 白色G
objD.fieldG = G; // 黑色D 引用 白色G
此时切回到 GC 线程,因为 E 已经没有对 G 的引用了,所以不会将 G 置为灰色;尽管因为 D 重新引用了 G,但因为 D 已经是黑色了,不会再重新做遍历处理。
最终导致的结果是:G 会一直是白色,最后被当作垃圾进行清除。这直接影响到了应用程序的正确性,是不可接受的。
不难分析,漏标只有同时满足以下两个条件时才会发生:
- 一个或者多个黑色对象重新引用了白色对象;即黑色对象成员变量增加了新的引用。
- 灰色对象断开了白色对象的引用(直接或间接的引用);即灰色对象原来成员变量的引用发生了变化。
如下代码:
var G = objE.fieldG; // 1.读
objE.fieldG = null; // 2.写
objD.fieldG = G; // 3.写
我们只需在上面三个步骤中任意一个中,将对象 G 记录起来,然后作为灰色对象再进行遍历即可。比如放到一个特定的集合,等初始的 GC Roots 遍历完(并发标记),该集合的对象遍历即可(重新标记)。
重新标记是需要 STW 的,因为应用程序一直在跑的话,该集合可能会一直增加新的对象,导致永远都跑不完。当然,并发标记期间也可以将该集合中的大部分先跑了,从而缩短重新标记 STW 的时间,这个是优化问题了。看到了没?三色标记算法也并不能完全解决 STW 的问题,只能尽可能缩短 STW 的时间,尽可能达到停顿时间最少。
读屏障与写屏障
针对于漏标问题,JVM 团队采用了读屏障与写屏障的方案。读屏障是拦截第一步;而写屏障用于拦截第二和第三步。它们拦截的目的很简单:就是在读写前后,将对象 G 给记录下来。
读屏障
oop oop_field_load(oop* field) {
pre_load_barrier(field); // 读屏障-读取前操作
return *field;
}
读屏障是直接针对第一步:var G = objE.fieldG;,当读取成员变量之前,先记录下来。
void pre_load_barrier(oop* field, oop old_value) {
if ($gc_phase == GC_CONCURRENT_MARK && !isMarkd(field)) {
oop old_value = *field;
remark_set.add(old_value); // 记录读取到的对象
}
}
这种做法是保守的,但也是安全的。因为条件一中【一个或者多个黑色对象重新引用了白色对象】,重新引用的前提是:得获取到该白色对象,此时已经读屏障就发挥作用了。
写屏障
我们再来看下第二、三步的写操作,给某个对象的成员变量赋值时,底层代码:
/**
* @param field 某对象的成员变量,如 E.fieldG
* @param new_value 新值,如 null
*/
void oop_field_store(oop* field, oop new_value) {
*field = new_value; // 赋值操作
}
所谓的写屏障,其实就是指给某个对象的成员变量赋值操作前后,加入一些处理(类似 Spring AOP 的概念)。
void oop_field_store(oop* field, oop new_value) {
pre_write_barrier(field); // 写屏障-写前操作
*field = new_value;
post_write_barrier(field, value); // 写屏障-写后操作
}
增量更新和原始快照
虽然三色标记法很高效,但是也会引申出其他的问题。
首先我们上文说过并发标记的过程是不会STW的,就是你妈在打扫卫生,而你在旁边一直丢垃圾,这也没关系,大不了最后就是还有一些垃圾没扫干净而已。
对于三色标记来说就是把应该要清理的对象标记成存活,这样本次GC就无法清理这个对象,这个被称作为浮动垃圾,解决方案就是等下次GC的时候再清理,这次扫不干净就等你妈下次打扫卫生的时候再清理就行了。
与此相反,如果把存活对象标记成需要清理,那么就有点麻烦了,这样你的程序就该出问题了。
所以经过研究表明,只有同时满足两个条件才会发生这种对象消失的问题:
- 插入了一条或者多条黑色到白色对象的引用
- 删除了全部从灰色到白色对象的引用
那么,针对这个问题也有两种解决方案:增量更新和原始快照,如果对应到垃圾回收器的话,CMS使用的是增量更新,而像G1则是使用原始快照。
思路就是既然要同时满足,那么我只需要破坏其中一个条件那么不就可以了吗?
所以,先看上面我们的例子中的一个场景,假设A扫描完,刚好C成为灰色,此时C->D的引用删除,同时A->D新增了引用(同时满足两个条件了吧),这样本来按照顺序接下来D应该会变成黑色(黑色对象不应该被清理),但是由于C->D没有引用了,A已经成为了黑色对象,他不会再被重新扫描了,所以即便新增了A->D的引用,D也只能成为白色对象,最终被无情地清理。
增量更新解决方案就是,他会把这些新插入的引用记录下来,扫描结束之后,再以黑色对象为根重新扫描一次。这样看起来不就是增量更新吗?新插入的记录再扫一次!
原始快照则是去破坏第二个条件,他把这个要删除的引用记录下来,扫描结束之后,以灰色对象为根重新扫描一次。所以就像是快照一样,不管你删没删,其实最终还是会按照之前的关系重新来一次。
增量更新
当对象 D 的成员变量的引用发生变化时(objD.fieldG = G;),我们可以利用写屏障,将 D 新的成员变量引用对象 G 记录下来:
void post_write_barrier(oop* field, oop new_value) {
if ($gc_phase == GC_CONCURRENT_MARK && !isMarkd(field)) {
remark_set.add(new_value); // 记录新引用的对象
}
}
这种做法的思路是:不要求保留原始快照,而是针对新增的引用,将其记录下来等待遍历,即增量更新(Incremental Update)。
增量更新破坏了漏标的条件一:【 一个或者多个黑色对象重新引用了白色对象】,从而保证了不会漏标。
原始快照
当对象 E 的成员变量的引用发生变化时(objE.fieldG = null;),我们可以利用写屏障,将 E 原来成员变量的引用对象 G 记录下来:
void pre_write_barrier(oop* field) {
oop old_value = *field; // 获取旧值
remark_set.add(old_value); // 记录 原来的引用对象
}
当原来成员变量的引用发生变化之前,记录下原来的引用对象。
这种做法的思路是:尝试保留开始时的对象图,即原始快照(Snapshot At The Beginning,SATB),当某个时刻的 GC Roots 确定后,当时的对象图就已经确定了。
比如当时 E 是引用着 G 的,那后续的标记也应该是按照这个时刻的对象图走(E 引用着 G)。如果期间发生变化,则可以记录起来,保证标记依然按照原本的视图来。
原始快照破坏了漏标的条件二:【灰色对象断开了白色对象的引用(直接或间接的引用)】,从而保证了不会漏标。
本文小结
基于可达性分析的 GC 算法,标记过程几乎都借鉴了三色标记的算法思想,尽管实现的方式不尽相同,比如标记的方式有栈、队列、多色指针等。本文详细介绍JVM 的三色标记算法相关的知识与内容,对于初学者来说,这部分内容还是比较复杂的,建议多读几遍。