1.三色标记法
GC算法从整体思路上来讲基本只有两大流派,即引用计数派和扫描标记派。而扫描标记派又分为可并行方式和不可并行方式。三色标记法就是可并行方式最常用的一种扫描标记方法。
网上对于三色标记法的详细介绍多如牛毛,本文重点不在这里,所以只简单介绍一下原理。
本文其实没有重点。
1.1三色标记的基本思路
1.1.1基本的扫描清除
GC开始时,可以认为所有对象的状态都是待定的。接着从所有可以被称为ROOT节点的对象出发,开始递归遍历,每个被遍历到的对象都认为是有用的对象,也就是不能被清除的对象,将其做上标记。遍历结束时有用的对象就都已经被标记过了,那些没有被标记的就是可以清除的对象,然后标记阶段结束进入清除阶段。遍历所有对象,将没有被标记的对象清除。GC结束。
其过程大体如下:
初始
标记结束
清除结束
基础的扫描清除方式是基于非并行的,在扫描清除之前需要先暂停程序,会有一个STW的过程(Stop The World),在扫描清除之后再恢复程序运行:
在这种方式中,对象只有被标记和不被标记两种状态,如果用颜色来标记的话,两种颜色就完全足够。同时这种方式的问题也很明显,当STW的时间过长时,会造成程序明显的卡顿。虽然后续通过延迟清除的方式对此做了优化,当时效果依然不是很能令人满意。
优化后的过程:
要解决这个问题,可以进行并行式GC,以减少单次STW的时间。三色标记法就是为了满足并行式GC而诞生的。
1.1.2基础的三色标记
如果用一句话简单比喻三色标记法的话,那就是“用不同颜色表达的深度优先搜索”:
- 初始时所有对象都被认为是待定状态(标白)。
- 以每个ROOT对象为起点,加入OpenList(标灰)。
- 从OpenList中取出一个节点,作为遍历对象。
- 依次将遍历对象的所有子节点全部加入OpenList(标灰)。
- 将遍历对象加入CloseList(标黑)。
- 重复执行3、4、5三步,直到OpenList清空。标记阶段结束。
当标记阶段结束时,所有被遍历到的对象都会被标记成黑色,所有没有被查询到的对象仍然保持白色。也即,黑色的对象是有用的对象,白色的对象是可被清除的对象。然后进入清除阶段,将所有白色对象清除,回收内存,并将所有黑色对象重新标回白色,以便进行下一次GC。
三色标记法的优点是,由于通过不同颜色标记出了对象的不同状态,也就是维持了OpenList和CloseList,理论上可以随时打断。从GC线程切换回业务线程时,OpenList和CloseList不受影响,当再次切换回GC线程时,继续执行上述3、4、5过程即可。这样就不需要全程STW,从而将GC耗时碎片化。
1.2并行模式下的问题
既然上边提到了“理论上”,就代表一定会有“实际上”的问题。三色标记清除的GC方式,在并行模式下最主要也是最严重的问题,就是可能会出现错标的情况。
1.2.1悬挂指针
假设,当GC执行到上图所示的状态时,切换回业务线程。
而业务线程的逻辑中,恰巧将对象1对对象2的引用改为了对对象3的引用,并将对象2对对象3的引用清空。
引用关系就变成下图这样。
此时再切换回GC线程。
- 对象2从OpenList中取出,遍历不到子节点,标黑放入CloseList。
- 对象1由于已经放到了CloseList中,并不会再次对他进行遍历,因此没有机会遍历到对象3。
- 标记结束,开始清除。
结果如上图所示:
- 对象2被保留了,这个问题倒无所谓,等下一次GC将对象2清除即可。
- 严重的问题是对象3被错误的清除了,此时从对象1指向对象3的指针就成了野指针。
1.2.2新建对象
再假设,当GC线程执行到上图情况时切换回业务线程。
业务逻辑中创建了对象4,并从对象1中引用到对象4。由于新建的对象默认都是白色的,于是变成下图所示状态。
此时切换回GC线程。
- 对象2从OpenList中取出,遍历不到子节点,标黑进入CloseList。
- 对象1由于已经进入CloseList,不会再次对其进行遍历,因此没有机会遍历到新建的对象4。
- 标记结束,执行清除。
对象4又被错误的清除了。
2.基于写屏障的解决方案
2.1三色不变原则
比较上面所说的两个例子,我们发现,问题都出在,当GC执行到一半时:
- 黑色对象直接指向了白色对象(白色对象有用)。
- 白色对象向上一路查找都没有灰色对象(白色对象不会被遍历到)。
可见,上述两个条件共同导致了错标问题的出现。反过来讲,只要破坏了上述两个条件中的任意一条就可以避免这个问题。于是有:
- 针对条件1,规定----黑色对象一定不能直接指向白色对象,只能指向黑色和灰色对象。这个就是强三色不变原则。
- 针对条件2,规定----黑色对象可以直接指向白色对象,但所有指向该白色节点的链路中,一定至少有一条链路中包含灰色节点。这个就是弱三色不变原则。
对于强三色不变原则和弱三色不变原则,二者只要遵循其一,就可以破坏上述条件。于是也出现了两种不同的解决方案。
2.2插入写屏障
插入写屏障方案遵循强三色不变原则,在每次创建一个引用关系的时候触发。
- 当创建引用关系 “A.a = B” 时,如果A为黑色,B为白色,则将B强制置为灰色。
这样再回头看刚才提到的悬挂指针的问题,当对象1引用对象3的时候,就会触发插入写屏障,将对象3强制标灰,变成如下状态:
同理,对于新建对象的例子也会触发插入写屏障,变成如下状态:
插入写屏障方案原理清晰,易于理解,但是也有局限性。我们知道像JAVA、C#一类代码,都是在运行时解释执行,当运行时执行到A.a = B时,会在底层触发一次 Switch(WriteBarrier) 的操作。由于栈上的对象是此时最为活跃的对象,会大量触发这个操作,因此运行时对栈上对象一般都不会开启写屏障,而只对堆上的对象做此处理。这就相当于没有对栈上的对象做保护,因此需要在标记结束后再对栈上对象整体遍历一遍,以确保标记的正确性,而这次遍历的过程也是BTW的。
所以当程序很大很复杂时,这个过程依然可能比较耗时。
2.3删除写屏障
删除写屏障遵循弱三色不变原则,在每次删除一个引用关系的时候触发。
- 在A.a = B的情况下,如果将A.a = null,即将A到B的引用断开,如果B为白色,则将B置为灰色。
同样来看悬挂指针的例子
初始对象3引用对象4。
然后对象1引用对象4,此时对象4依然为白色,可以从对象2遍历到。
对象1断开对对象2的引用,由于对象2不为白色,不会触发删除写屏障,对象4依然为白色,可以从对象2遍历到。
对象3断开对对象4的引用,由于对象4为白色,触发删除写屏障,将对象4标灰,因此对象4及其后续子节点也可保证被遍历到。
总结起来,删除写屏障的思想是,保证白色对象向上的链路上一定有至少一个灰色对象。于是在每次断开对白色对象的引用时,为了使被断开引用的白色对象(以及其后的所有白色对象)满足这一点,就强制将这个白色对象变为灰色。
当然这有一个前提,就是还没出现断开操作的时候,也就是初始状态就要满足这一点,因此删除写屏障需要在GC开始时先扫描一遍所有ROOT节点并标灰,以保证每条链路都从灰色对象开始,不过这个操作基本不会耗时。
但是很显然,删除写屏障是解决不了上文新建对象这个例子的问题的。
2.4混合写屏障
插入写屏障和删除写屏障都有各自的优缺点,因此,现在的运行时基本都采用了混合写屏障的方式,以兼具二者的优点,这里就不多介绍了。
对于写屏障,可以参考这篇文章:【一个优雅的链接】 ,讲的要更详细一些,图也画的更多,还有参考资料可以深入了解。
最后提一下,作为游戏程序猿的话,经常会在面试中被问到Lua的GC原理,而且一般问到这里往往都会追问一下Lua中三色标记法为什么会有两个白色。其实就是运用写屏障的机制解决新建对象的问题,只不过新建的对象不是标记为灰色,而是基于一个假设,即:新建的对象以及其引用链路下方的所有对象都是不会被当次GC回收的。
在此假设下,为了不浪费时间去遍历这些注定不会被回收的对象,就没必要再将其标为灰色了,可以直接标为白色等待下一次GC再处理(因为每一轮GC开始时所有对象都是白色的)。
但是我们知道,在标记阶段结束开始执行清除的时候,本轮的白色对象是会被清除的,为了避免新对象被清除的尴尬局面出现,这些新对象就被标记成了另外一种白色(下一轮白色),以区别于本轮的白色,这样GC清理完成后,将剩余的黑色对象置回白色时,也是置为下一轮白色,大家就都统一了(统一是一定要统一的)。
其实是一个双缓存机制。