太上老君的“三色标记法”
Posted KnightHONG
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了太上老君的“三色标记法”相关的知识,希望对你有一定的参考价值。
垃圾回收首先要知道到底哪些对象是已经死亡、可以被回收,当前主流的编程语言的垃圾收集器基本都基于可达性分析算法来判断一个对象是否可以被GC。可达性分析算法要求全过程都基于一个能保障一致性的快照中进行,也就是必须冻结正在运行的用户线程。
那么问题来了,为什么可达性分析算法在运行时,需要一个能保障一致性的快照?如果可达性分析算法运行的线程和用户的线程并发执行,会出现什么问题?这个停顿的时间能不能减少?
想要解决这些疑问,不得不深入了解下,可达性分析算法底层到底如何运作的,它是如何标记出哪些对象是存活的。洪爵在上篇文章《面试官:如何判定一个对象是否存活?》粗略的讲到可达性分析算法大概含义,本章节来深挖它底层逻辑,当面试官一脸自信的问到这个问题,认为你答不出来的时候,请狠狠的扇他一巴掌。
首先回顾下,可达性分析算法由一系列被称之为”GC Roots“的根对象作为起始节点集,从这些根节点开始,根据引用关系向下搜索,搜索过程所走过的路径称之为”引用链“,如果一个对象到RC Roots没有任何引用链相连,则表明这个对象不可达。
这还是比较抽象的概念,具体是如何运作我们引用三色标记法来说明:
- 白色:尚未标记过
- 灰色:已经访问过,但是还没有访问该对象的属性中直接引用的对象
- 黑色:已经访问过了,该对象的属性直接引用对象也已经访问了
初始状态,即还没有开始可达性分析算法,所有的对象还处于白色标记中。
开始可达性分析算法后,把GC Roots直接关联到的对象加入到【灰色标记队列】中。
遍历对象A、C、K的属性中引用的对象,加进【灰色标记队列中】。
这个时候A、C、K对象的属性中直接引用的对象都访问完了,A、C、K三个对象从【灰色标记队列】中移除,加入【黑色标记队列】中,表示该对象是可达,即存活状态。
按照同样的动作,访问B、D属性中直接引用的对象,加入到【灰色标记队列】中。
标记B、D对象为黑色,加入到【黑色标记队列】。
以此类推,接下来分别是:
最后A~I对象都被标记为黑色,而L、M、N三个对象被标记为白色(没有标记白色这个动作,只是为了方便理解),代表没有访问过,可以被回收。
有的同学就会问了,**如果L、M、N对象中的任何一个后面又被GC Roots直接或者间接关联了呢?被回收岂不是不合理?**好的,问题非常好,这个是不会出现的情况,因为已经这三个对象已经丢失了,不知道它们的内存地址的位置。
我们已经了解了可达性分析算法的具体运作过程,那么我们来分析一下,**为什么可达性分析算法在运行时,需要一个能保障一致性的快照?**举一个可达性分析算法和用户线程并发运行的例子,同样也继续使用三色标记法来为大家说明:
假设可达性分析算法已经跑到如下图所示:
A、C、K三个对象已经被标记为黑色,即已经访问过,并且属性中引用的对象也访问了,即B和D对象,已经被标记为灰色,表示已经访问但B和D对象中属性引用的对象还有没有访问。这个时候用户并发线程做了一个操作,K对象中某个属性指向了对象G,然后断开了对象D引用对象G的关系。
而对象K已经被标记为黑色,代表该对象被访问过,并且属性中的引用对象也被访问过。所以对象G直到最后可达性分析算法结束,都不会被标记为灰色或者黑色。这就会出现回收了存活对象的问题,直接影响了程序的正确性。
可达性分析需要一个能保障一致性快照的场景下运行的原因已经找到了,那么我们有没有什么可以优化的点,让STW的时间缩短或者让可达性分析线程可以和用户线程并发运行呢?
Wilson在1994年理论上证明,当且仅当同时满足以下两个条件时,才会产生“对象”消失的问题:
- 一个或者多个黑色对象有了对白色对象的新引用
- 删除了全部灰色对象到该白色对象的直接或者间接引用
那要解决并发过程中因为引用关系导致的存活对象被回收问题,我们可以打破其中任何一个条件即可,打破第一条规则我们称之为增量更新、打破第二条规则我们称之为原始快照。
增量更新是啥意思呢,当黑色对象新增一个对白色对象的引用关系时,我们把这个引用关系给记录下来,等并发标记完对象后,再将记录过的新的引用关系中黑色对象重新走一遍标记流程,可以简单的理解为该黑色对象重新被标记为了灰色对象。
原始快照当灰色对象要删除对白色对象的引用,那么就将要删除的引用记录下来,在并发扫描结束后,会按照刚开始扫描那一刻的对象图来进行标记。
很多脑洞大开的童鞋就会想到,如果在增量更新后,又出现黑色对象对白色对象的新引用,那岂不是俄罗斯套娃,永无止境?其实有很多办法可以解决,比如说对重新标记的场景进行STW等等。
到了文章末尾,洪爵又有了新的问题提出给大家**,增量更新和原始快照是如何实现的,如何在并发的情况下进行记录?**别着急,洪爵下一篇文章告诉你(禁止套娃)。
愿每个人都能带着怀疑的态度去阅读文章并探究其中原理。
道阻且长,往事作序,来日为章。
期待我们下一次相遇!
JVM 三色标记法与读写屏障
微信公众号:运维开发故事,作者:老郑
三色标记法
GC 垃圾回收器其主要的目的是为了实现内存的回收,在这个过程中主要的两个步骤就是:内存标记,内存回收。
三色标记法简介
三色标记法,主要是为了高效的标记可被回收的内存块。
三色标记(Tri-color Marking)作为工具来辅助推导,把遍历对象图过程中遇到的对象,按照“是否访问过”这个条件标记成以下三种颜色:
-
白色:表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始的阶段,所有的对象都是白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。
-
黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代 表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对 象不可能直接(不经过灰色对象)指向某个白色对象。
-
灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。
三色标记过程
标记过程:
-
在 GC 并发开始的时候,所有的对象均为白色;
-
在将所有的 GC Roots 直接应用的对象标记为灰色集合;
-
如果判断灰色集合中的对象不存在子引用,则将其放入黑色集合,若存在子引用对象,则将其所有的子引用对象存放到灰色集合,当前对象放入灰色集合
-
按照此步骤 3 ,依此类推,直至灰色集合中所有的对象变黑后,本轮标记完成,并且在白色集合内的对象称为不可达对象,即垃圾对象。
-
标记结束后,为白色的对象为 GC Roots 不可达,可以进行垃圾回收。
误标
什么是误标?当下面两个条件同时满足,会产生误标:
-
赋值器插入了一条或者多条黑色对象到白色对象的引用
-
赋值器删除了全部从灰色对象到白色对象的直接引用或者间接引用
误标的解决方案
要解决误标的问题,只需要破坏这两个条件中的任意一种即可,分别有两种解决方案:增量更新(Incremental Update) 和原始快照(Snapshot At The Beginning, STAB)
增量更新
增量更新要破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象 了。
原始快照 (STAB)
原始快照要破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删 除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描 一次。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。
漏标和多标
对于错标其实细分出来会有两种情况,分别是:漏标和多标
多标-浮动垃圾
如果标记执行到 E 此刻执行了 object.E = null
在这个时候, E/F/G 理论上是可以被回收的。但是由于 E 已经变为了灰色了,那么它就会继续执行下去。最终的结果就是不会将他们标记为垃圾对象,在本轮标记中存活。在本轮应该被回收的垃圾没有被回收,这部分被称为“浮动垃圾”。浮动垃圾并不会影响程序的正确性,这些“垃圾”只有在下次垃圾回收触发的时候被清理。还有在,标记过程中产生的新对象,默认被标记为黑色,但是可能在标记过程中变为“垃圾”。这也算是浮动垃圾的一部分。
漏标-读写屏障
写屏障(Store Barrier)
给某个对象的成员变量赋值时,其底层代码大概长这样:
/**
* @param field 某个对象的成员属性
* @param new_value 新值,如:null
*/
void oop_field_store(oop* field, oop new_value)
*fieild = new_value // 赋值操作
所谓写屏障,其实就是在赋值操作前后,加入一些处理的逻辑(类似 AOP 的方式)
void oop_field_store(oop* field, oop new_value)
pre_write_barrier(field); // 写屏障-写前屏障
*fieild = new_value // 赋值操作
pre_write_barrier(field); // 写屏障-写后屏障
写屏障 + SATB
当对象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确定后,当时的对象图就已经确定了。比如 当时 D是引用着G的,那后续的标记也应该是按照这个时刻的对象图走(D引用着G)。如果期间发生变化,则可以记录起来,保证标记依然按照原本的视图来。值得一提的是,扫描所有GC Roots 这个操作(即初始标记)通常是需要STW的,否则有可能永远都扫不完,因为并发期间可能增加新的GC Roots。
SATB破坏了条件一:【灰色对象 断开了 白色对象的引用】,从而保证了不会漏标。
一点小优化:如果不是处于垃圾回收的并发标记阶段,或者已经被标记过了,其实是没必要再记录了,所以可以加个简单的判断:
void pre_write_barrier(oop* field)
// 处于GC并发标记阶段 且 该对象没有被标记(访问)过
if($gc_phase == GC_CONCURRENT_MARK && !isMarkd(field))
oop old_value = *field; // 获取旧值
remark_set.add(old_value); // 记录 原来的引用对象
写屏障 + 增量更新
当对象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)。
增量更新破坏了条件二:【黑色对象 重新引用了 该白色对象】,从而保证了不会漏标。
读屏障(Load Barrier)
oop oop_field_load(oop* field)
pre_load_barrier(field); // 读屏障-读取前操作
return *field;
读屏障直接针对第一步 var objF = object.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); // 记录读取到的对象
这种做法是保守的,但也是安全的。因为条件二中【黑色对象 重新引用了 该白色对象】,重新引用的前提是:得获取到该白色对象,此时已经读屏障就发挥作用了。
三色标记法与垃圾回收器
增量更新:CMS
原始快照(STAB):G1,Shenandoah
参考文档
-
https://www.jianshu.com/p/12544c0ad5c1
-
https://hllvm-group.iteye.com/group/topic/44381
-
https://www.oracle.com/webfolder/technetwork/tutorials/obe/java/G1GettingStarted/index.html
-
https://tech.meituan.com/2016/09/23/g1.html
-
《深入理解 JVM 虚拟机-第三版》周志明
以上是关于太上老君的“三色标记法”的主要内容,如果未能解决你的问题,请参考以下文章