三色标记法

简介: 三色标记法

前言

关于垃圾回收算法,基本就是那么几种:标记-清除、标记-复制、标记-整理。在此基础上可以增加分代(新生代/老年代),每代采取不同的回收算法,以提高整体的分配和回收效率。

无论使用哪种算法,标记总是必要的一步。这是理算当然的,你不先找到垃圾,怎么进行回收?

垃圾回收器的工作流程大体如下:

  1. 标记出哪些对象是存活的,哪些是垃圾(可回收);
  2. 进行回收(清除/复制/整理),如果有移动过对象(复制/整理),还需要更新引用。

本文着重来看下标记的部分。

概述

在 CMS 垃圾收集器中提到了,在 CMS 的并发清理阶段才产生的垃圾对象,会被当做浮动垃圾,留到下一次 GC 再清理。其实在并发标记阶段,由于用户线程在并发运行,也可能会导致引用关系发生改变,导致标记结果不准确,从而引发更加严重的问题,这些发生变更的数据会在重新标记阶段被处理,那么会出现什么问题?又是如何处理的呢?

CMS 算法的基础是通过可达性分析找到存活的对象,然后给存活的对象打个标记,最终在清理的时候,如果一个对象没有任何标记,就表示这个对象不可达,需要被清理,标记算法就是使用的三色标记。并发标记阶段是从 GC Root 直接关联的对象开始枚举的过程。

对于三色标记算法而言,对象会根据是否被访问过(也就是是否在可达性分析过程中被检查过)被分为三个颜色:白色、灰色和黑色:

  • 白色:这个对象还没有被访问过,在初始阶段,所有对象都是白色,所有都枚举完仍是白色的对象将会被当做垃圾对象被清理。
  • 灰色:这个对象已经被访问过,但是这个对象所直接引用的对象中,至少还有一个没有被访问到,表示这个对象正在枚举中。
  • 黑色:对象和它所直接引用的所有对象都被访问过。这里只要访问过就行,若 A 只引用了 B,B 引用了 C、D,那么只要 A 和 B 都被访问过,A 就是黑色,若 B 所引用的 C 或 D 还没有被访问到,此时 B 就是灰色。

根据这些定义,我们可以得出:

  • 在可达性分析的初始阶段,所有对象都是白色,一旦访问了这个对象,那么就变成灰色,一旦这个对象所有直接引用的对象都访问过(或者没有引用其它对象),那么就变成黑色
  • 初始标记之后,GC Root 节点变为黑色(GC Root 不会是垃圾),GC Root 直接引用的对象变为灰色
  • 正常情况下,一个对象如果是黑色,那么其直接引用的对象要么是黑色,要么是灰色,不可能是白色(如果出现了黑色对象直接引用白色对象的情况,就说明漏标了,就会导致对象误删,后面会介绍如何解决),这个特性也可以说是三色标记算法正确性保障的前提条件。

标记过程

  1. 初始时,所有对象都在【白色集合】中;
  2. 将 GC Roots 直接引用到的对象挪到【灰色集合】中
  3. 从灰色集合中获取对象:
  • 将本对象引用到的其他对象全部挪到【灰色集合】中
  • 将本对象挪到【黑色集合】里面
  1. 重复步骤 3,直至【灰色集合】为空时结束
  2. 结束后,仍在【白色集合】的对象即为 GC Roots 不可达,可以进行回收

并发标记问题

垃圾变成了非垃圾(多标)

浮动垃圾:本应该被标记为白色的对象,没有被标记,造成该对象本次 GC 不会被回收。

标记的下一步操作是取出 C 对象进行分析,但是这个时候 GC 线程的时间片用完了,操作系统调度用户线程来运行,而用户线程先执行了这个操作:A.c = null,已完成标记的 A 与 C 断开联系。这时候如果 GC 线程获得时间片继续运行,继续标记 C、D 最后为黑色。**最后发现问题:C、D 其实已经从 GC Root 不可达,但是还是完成了标记,不会被 GC 回收。**这种也可以当作浮动垃圾。可以等下次 GC 处理。

非垃圾变成了垃圾(漏标)

漏标只有同时满足以下两个条件时才会发生:

条件一:灰色对象断开了白色对象的引用;即灰色对象原来成员变量的引用发生了变化。
条件二:黑色对象重新引用了该白色对象;即黑色对象成员变量增加了新的引用。

场景重现

标记的下一步操作是从队列中取出 B 对象进行分析,但是这个时候 GC 线程的时间片用完了,操作系统调度用户线程来运行,而用户线程先执行了这个操作:A.f = F,已完成标记的 A 与 F 建立联系。接着执行:B.f = null,去掉 B 与 F 之间的联系。这时候如果 GC 线程获得时间片继续运行,发现 B 对象没有直接引用,那么将 B 对象变为黑色。然后继续标记 C、D,发现 C、D 没有直接引用,那么 C、D 变成黑色。最后发现问题:F 其实被 A 引用,但是 F 仍然漏标,这直接影响了程序的正确性。

增量更新和原始快照(SATB)

上面一共出现了两个问题,从结果上来看,可以这样描述:

  1. 一个本应该是垃圾的对象被视为了非垃圾
  2. 一个本应该不是垃圾的对象被视为了垃圾

对于第一个问题,我们前文也提到了,即使不去处理它也无所谓,大不了等到下次 GC 再清理。最重要的是第二个问题,如果误清理了正在被使用的对象,那就是实打实的 BUG 了。那么如何解决这个问题呢?

出现漏标这个问题的主要原因是,一个对象从被 B 引用,变更为了被 A 引用。那么对于 A 来说就是多了一个直接引用,对于 B 来说就是少了一个直接引用。我们可以从这两个方面入手来解决这个问题,对应了也有两个方案,分别是增量更新(Incremental Update)和原始快照(SATB,Snapshot At The Beginning)。

读写屏障

在这讲述解决方案之前,要描述两个名词:读屏障写屏障。注意,这里的屏障和并发编程中的屏障是两码事儿。这里的屏障很简单,可以理解成就是在读写操作前后插入一段代码,用于记录一些信息、保存某些数据等,概念类似于 AOP。

增量更新

增量更新是站在新增引用的对象(也就是例子中的 A 对象)的角度来解决问题。所谓增量更新,就是在赋值操作之前添加一个写屏障,在写屏障中记录新增的引用。比如,用户线程要执行:A.f = F;那么在写屏障中将新增的这个引用关系记录下来。标准的描述就是,当黑色对象新增一个白色对象的引用时,就通过写屏障将这个引用关系记录下来。然后在重新标记阶段,再以这些引用关系中的黑色对象为根,再扫描一次,以此保证不会漏标。

在我们这个例子中,在并发标记阶段,A 是一个黑色对象,F 是一个白色对象,A 引用了 F,这个引用关系会被记录下来,然后通过这个记录在重新标记阶段再从 A 对象开始枚举一次,保证如果 A 还是保持着F的引用,那么 F 会被正确标记;如果 A 到 F 的引用在并发标记阶段又断开了,此次枚举也无法访问到它,活该被清除。

要实现也很简单,在重新标记阶段直接把 A 对象(和其它有相同情况发生的对象)变为灰色,放入队列中,再来一次枚举过程。要注意,在重新标记阶段如果用户线程还是继续执行,那么这个 GC 永远可能也做不完了,所以重新标记需要 GC 中断,但是这个时间消耗不会太夸张。如果实在重新标记阶段耗时过长,那么可以尝试在重新标记之前做一次 Minor GC。

原始快照SATB

**原始快照是站在减少引用的对象(也就是例子中的 B 对象)的角度来解决问题。**所谓原始快照,简单的讲,就是在赋值操作(这里是置空)执行之前添加一个写屏障,在写屏障中记录被置空的对象引用。比如,用户线程要执行:B.f=null;那么在写屏障中,首先会把 B.f 记录下来,然后再进行置空操作。记录下来的这个对象就可以称为原始快照。

那么记录下来之后呢?很简单,之后直接把它变为黑色。意思就是默认认为它不是垃圾,不需要将其清理。当然,这样处理有两种情况,一种情况是,F 的确不是垃圾,直到清理的那一刻,都仍然有至少一个引用链能访问到它,这没有什么问题;另一种情况就是 F 又变成了垃圾。在上述的例子中,就是 A 到 F 的引用链也断了,或者直接 A 都成垃圾了,那 F 对象就成了浮动垃圾。对于浮动垃圾,前面不止一次就提到了,直接不用理会,如果到下一次 GC 时它仍然是垃圾,自然会被清理掉。

方案选择

从增量更新和原始快照的实现(理论上)就可以发现,原始快照相比于增量更新来说效率会更高,因为不用在重新标记阶段再去做枚举遍历,但是也就可能会导致有更多的浮动垃圾。G1 使用的就是原始快照,CMS 使用的是增量更新。

既然原始快照可能会有更严重的浮动垃圾问题,那么为什么不使用增量更新呢?原因可能很简单,就是因为简单。想象一下,G1 虽然也是基于年轻代和老年代的分代收集算法,但是年轻代和老年代被弱化为了逻辑上,其所管理的内存被划分为了很多 region,对象跨代引用带来的问题在 G1 中要比传统的分代收集器更加突出,虽然有 Remember Set 方案缓解,但是相对来说在重新标记阶段进行再次遍历枚举的代价会大很多。最重要的是,重新标记(最终标记)阶段是会 GC 中断的,如果这个阶段花费太多的时间去做可达性分析,那么就违背了 G1 低延时的理念。

总结

这里有一个需要注意的点,重新标记阶段会 GC 中断,以此保证标记结果的正确性(主要是漏标)。到现在你可能理解了,垃圾收集器中所描述的:并发清理阶段产生的垃圾会被当做浮动垃圾,只能留待下一次GC被清理。那么实际上是怎么回事呢?其实就很简单了,只要在并发清理阶段产生的对象,直接就认为是黑色对象,全部都不是垃圾。如果一个对象最终成了垃圾,那它就是浮动垃圾,如果没成垃圾,那么标记为黑色也没有什么问题。因为到了清理阶段,标记工作已经完成,没有办法再找到合适的方式去处理这个问题,不然一次 GC 可能永远也结束不了。

话说回来,对于上面漏标的情况,你可能还有一个疑问:在并发标记过程中,除了引用关系发生变更的情况,如果用户线程直接创建了一个新对象,这个对象默认是白色,又直接和黑色对象关联,那又该当如何呢?也就是白色对象可能是从其他对象的引用链上”转移“过来的,也可能就是一个新对象。其实可以想象的到,对于新对象加入到黑色节点,我们无法使用原始快照,但是可以使用增量更新,或者直接简单处理,和并发清理阶段一样:在这期间创建的新对象都认为不是垃圾(比如标记为黑色),如果成了垃圾,那就是浮动垃圾,还是留待下一次 GC 处理。总之,标记的总体原则就是:“另可放过,不可杀错”。

关于黑白灰三个颜色,是一个抽象的概念,虽然使用可达性分析的垃圾收集器基本都采取三色标记的思想,但在实现上可能也各不相同,像如何标识颜色、灰色队列如何实现等等。

相关文章
|
6月前
|
存储 算法 编译器
【c 语言 】移位操作符详解
【c 语言 】移位操作符详解
297 0
|
1月前
|
Java 开发者
【编程基础知识】2的n次幂与二进制位全为1之间的联系,为啥只差一个1
本文深入探讨了2的n次幂与二进制位全为1之间的数学联系,解释了2的n次幂减一的二进制表示为何全为1,并探讨了这一特性在HashMap中的应用。通过基础数学原理和实际代码示例,文章揭示了这一特性的实用价值,适合各水平的编程爱好者学习。
19 3
|
1月前
|
算法
数据结构与算法二:栈、前缀、中缀、后缀表达式、中缀表达式转换为后缀表达式
这篇文章讲解了栈的基本概念及其应用,并详细介绍了中缀表达式转换为后缀表达式的算法和实现步骤。
46 3
|
6月前
编译原理-----逆波兰表示法,四元式,三元式,间接三元式
编译原理-----逆波兰表示法,四元式,三元式,间接三元式
242 0
|
编译器 C语言
C语言:将一句话的单词进行倒置,标点不倒置。
总体思路: (可以把两步顺序调换) 第一步: 把 整个字符串 逆序 (知道 整个字符串 的首尾地址后,一对一对向整个字符串中间靠拢交换)
127 0
|
11月前
|
机器学习/深度学习 算法
算法分析 | 小 o 和小欧米茄符号
算法分析 | 小 o 和小欧米茄符号
185 0
渐进记法
渐进记法是一种记忆技巧,通过将需要记忆的信息分解成更小的部分,并逐步增加这些部分的数量,来帮助人们更容易地记住信息。
52 0
|
算法 安全 Java
JVM 三色标记法
JVM 三色标记法
【基础算法】浅浅刷个小题 # 找不同 # 字符串中的单词数 # 重新排列字符串 #
【基础算法】浅浅刷个小题 # 找不同 # 字符串中的单词数 # 重新排列字符串 #