关于垃圾回收算法,基本就是那么几种:标记-清除、标记-复制、标记-整理。在此基础上可以增加分代(新生代/老年代),每代采取不同的回收算法,以提高整体的分配和回收效率。
无论使用哪种算法,标记总是必要的一步。你不先找到垃圾,怎么进行回收?今天一起看下三色标记法。
先看一下知识点导图:
一、如何标记
在 GC 领域里,判断对象存活的主流思路是两个,「引用计数」和「可达性分析」。
1、引用计数
顾名思义,引用计数的思路就是给每个对象进行计数,每被其它对象引用一次,计数就 +1,引用失效后,计数就 -1。当计数器的数值为 0,就意味着它没有被使用,可以回收。
2、可达性分析
可达性分析的思路就是通过引用链路判断对象是否可被触达,如果能触达说明该对象当前正在被使用,不可回收;反之,没有触达到的对象则认为是无使用的,可以回收。
这个引用链路的结构类似于有向有环图,但是根节点不止一个,是一个集合,称之为 GCRoots。
目前主流的 GC 机制大多用的是「可达性分析」这条路线。
为什么引用计数不好用呢?因为它有一个特别严重的问题:无法处理循环引用。
像上图这样的情况,引用计数永远不为 0,这些对象就永远不会被回收。
二、常规标记-清除
常规的标记清除严格按照追踪式算法的思路来实现的。这个算法会设置一个标志位来记录对象是否被使用。最开始所有的标记位都是 0,如果发现对象是可达的就会置为 1,一步步下去就会呈现一个类似树状的结果。
等标记的步骤完成后,会将未被标记的对象统一清理,再次把所有的标记位设置成 0 方便下次清理。
标记清除法主要包含两个步骤:
- 标记
- 清除
示例如下:
1、开启STW,停止程序的运行,图中是本次GC涉及到的root节点和相关对象。
2、从根节点出发,标记所有可达对象。
3、停止STW,然后回收所有未被标记的对象
这样执行整个GC期间需要STW,将整个程序暂停。因为如果不进行STW的话,会出现已经被标记的对象A,引用了新的未被标记的对象B,但由于对象A已经标记过了,不会再重新扫描A对B的可达性,从而将B对象当做垃圾回收掉的问题。
三、三色标记
垃圾收集器依据可达性分析算法判断对象是否存活时,将遍历GC Roots过程中遇到的对象,按照“是否访问过”这个条件,把对象标记成白色(white)、灰色(gray)、黑色(black)三种颜色,这个标记过程称为三色标记法。
相比传统的标记清扫算法,三色标记最大的好处是可以异步执行,从而可以以中断时间极少的代价或者完全没有中断来进行整个 GC。
1、基本算法
三色标记法将对象用三种颜色表示,分别是白色、灰色和黑色。
最开始所有对象都是白色的,然后把其中全局变量和函数栈里的对象置为灰色。
第二步把灰色的对象全部置为黑色,然后把原先灰色对象指向的变量都置为灰色,以此类推。
等发现没有对象可以被置为灰色时,所有的白色变量就一定是需要被清理的垃圾了。
- 初始标记阶段,指的是标记 GCRoots 直接引用的节点,将它们标记为灰色,这个阶段需要 「Stop the World」。
- 并发标记阶段,指的是从灰色节点开始,去扫描整个引用链,然后将它们标记为黑色,这个阶段不需要「Stop the World」。
- 重新标记阶段,指的是去校正并发标记阶段的错误,这个阶段需要「Stop the World」。
- 并发清除,指的是将已经确定为垃圾的对象清除掉,这个阶段不需要「Stop the World」。
三色标记法是一个 false negative(假阴性)的算法:
- 三色标记法因为多了一个白色的状态来存放不确定的对象,所以可以异步地执行。
- 当然异步执行的代价是可能会造成一些遗漏,因为那些早先被标记为黑色的对象可能目前已经是不可达的了。
2、现代垃圾回收器实现
现代追踪式(可达性分析)的垃圾回收器几乎都借鉴了三色标记的算法思想,尽管实现的方式不尽相同:比如白色/黑色集合一般都不会出现(但是有其他体现颜色的地方)、灰色集合可以通过栈/队列/缓存日志等方式进行实现、遍历方式可以是广度/深度遍历等等。
对于读写屏障,以Java HotSpot VM 为例,其并发标记时对漏标的处理方案如下:
- CMS:写屏障 + 增量更新
- G1:写屏障 + SATB
- ZGC:读屏障
四、多标及漏标问题
三色标记算法缺陷:在并发标记阶段的时候,因为用户线程与GC线程同时运行,有可能会产生多标或者漏标;
- 多标--多标记(浮动垃圾)
- 漏标--漏标记
1、多标问题
并发标记:用户与GC线程同时运行,假设现在扫描到C对象,B对象变为黑色,用户线程执行C的属性E=null,GC线程扫描C对象引用链,认为E对象是为可达对象,但是C对象根本没有引入到E对象,E对象应该是为垃圾对象,这种问题,可以在重新标记阶段(修正)修复。
并发清除阶段:用户与GC线程同时运行,会产生新的对象但是没有及时被GC清理。
多标只能在下一次GC清理垃圾的修复。
2、漏标问题
1.用户线程先执行C的E属性=null;GC线程的GcRoot就扫描不到E。Gc就认为E对象就是为垃圾对象,不可达对象。
2.用户线有执行B.E属性=E;E对象就是应该是为可达对象。
3.因为GCRoot是从C开始,不会从黑色的B开始,就会导致漏标的情况发生。
漏标的问题满足两个条件:
- 有至少一个黑色对象在自己被标记之后指向了这个白色对象
- 所有的灰色对象在自己引用扫描完成之前删除了对白色对象的引用
只有当上面两个条件都满足,三色标记算法才会发生漏标的问题。换言之,如果我们破坏任何一个条件,这个白色对象就不会被漏标。
CMS如何解决漏标问题---写屏障+增量更新方式
满足一个条件(灰色对象与白色对象断开连接),在并发标记阶段当我们黑色对象(B)引用关联白色对象(E),记录下B黑色对象。
在重新标记阶段(所有用户线程暂停),有将B对象变为灰色对象将整个引用链全部扫描。
缺点:遍历B整个链的效率非常低,有可能会导致用户线程等待的时间非常长。
G1如何解决漏标问题---原始快照方式
在C断开E的时候,会记录原始快照,在重新标记阶段的时候以白色对象变为灰色为起始点扫描整个链,本次GC是不会被清理。
好处:如果假设B(黑色对象)引入该白色对象的时候,无需做任何遍历效率是非常高。
缺点:如果假设B(黑色对象) 没有引入该白色对象的时候,该白色对象在本次GC继续存活,只能放在下一次GC在做并发标记的时候清理。
tips:以浮动垃圾(占内存空间)换让我们用户线程能够暂停的时间更加短。
总结:
对于读写屏障,以Java HotSpot VM为例,其并发标记时对漏标的处理方案如下:
- CMS:采用的是写屏障 + 增量更新
- G1: 采用的是写屏障 + 原汁快照(SATB)
- ZGC:采用的是读屏障
CMS收集器解决漏标问题:增量方式 如果现在B(黑色)对象引入白色对象,写屏障。
好处:避免浮动垃圾,缺点扫描整个引用链效率比较低。
G1收集器解决漏标问题:原始快照方式。
好处:效率非常高,无需扫描整个引用链,缺点:可能会产生浮动垃圾。