JVM HotSpot 可达性分析算法实现细节

2年前 (2022) 程序员胖胖胖虎阿
242 0 0

本文部分摘自《深入理解 Java 虚拟机第三版》

根节点枚举

在之前关于可达性分析算法的介绍中我们讲过,我们需要先找出可固定作为 GC Roots 的节点,然后沿着引用链去寻找那些无用的垃圾对象。GC Roots 节点一般在全局性引用(例如常量和类静态属性)与执行上下文(例如栈帧中的本地变量表)中,尽管目标明确,但查找过程要做到高效并非一件易事,若要逐个查找可作为起源的引用肯定需要消耗不少时间

迄今为止,所有收集器在根节点枚举这一步骤时都是必须暂停用户线程,也即 Stop The World,因为如果在分析过程中出现根节点集合中对象的引用关系仍在不断变化的情况,分析结果的准确性也就无法保证了

在对栈内存进行分析时,虚拟机会看哪些位置存储了 Reference 类型,如果发现某个位置确实存的是 Reference 类型,就意味着它所引用的对象这一次不能被回收。但问题是,栈帧的本地变量表里面只有一部分数据是 Reference 类型的,那些非 Reference 类型(基本数据类型)的数据对我们毫无用处,但我们还是不得不对整个栈全部扫描一遍,这是对时间和资源的一种浪费。在 HotSpot 的解决方案中采用了一组称为 OopMap 的数据结构来实现直接找到对象引用,一旦类加载动作完成,HotSpot 就会把栈中代表引用的位置全部记录下来,这样收集器在扫描时就可以直接得知这些消息了

安全点

尽管有了 OopMap,但如果引用关系经常变化,虚拟机就需要为每一条指令都生成对应的 OopMap,这将会占用大量的额外存储空间

HotSpot 当然没那么笨,它只会在特定的位置去记录这些信息,这些位置被称为安全点(SafePoint)。有了安全点的设定,用户程序就必须执行到安全点才能暂停,而不是在代码指令流的任意位置随意停顿。安全点的选定不能太少,让收集器等待时间过长,也不能太频繁,导致增大运行时内存负担。安全点的位置选定基本上是以“是否具有让程序长时间执行的特征”为标准进行选定,“长时间执行”的最明显特征就是指令序列的复用,例如方法调用、循环跳转、异常跳转等,只有具有这些功能的指令才能产生安全点

对于安全点,另外一个要考虑的问题就是,如何在垃圾收集发生时让所有线程都跑到最近的安全点。一般有两种方案可供选择:

  • 抢先式中断:垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地点不在安全点上,就恢复该线程执行,直至跑到安全点再中断。现实中几乎没有虚拟机会采用抢先式中断
  • 主动式中断:垃圾收集发生时,不直接对线程操作,而是设置一个标志位,各个线程在执行时会不停地主动去轮询这个标志,一旦发现标志位为真就在最近的安全点主动中断

安全区域

安全点看似解决了我们遇到的问题,但还有一个需要思考的点:如果某一个用户线程正好处于“不执行”状态该怎么办?所谓“不执行”就是没有分配处理器时间片,典型的场景如用户线程处于 Sleep 或 Blocked 状态,这时线程无法响应中断请求,自然也就不能走到安全点主动挂起自己,而虚拟机也不可能持续等待线程重新被分处理器时间片。对于这种情况,就需要引入安全区域(Safe Region)来解决

安全区域是指能够确保在某一代码片段中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。我们也可以把安全区域看作是被扩展拉伸了的安全点

当用户线程执行到安全区域时,首先会标识自己已经进入安全区域,那样当这段时间里虚拟机要发起垃圾收集时就不必去管这些已经声明自己在安全区域内的线程了。当线程要离开安全区域时,会检查虚拟机是否已经完成了根节点枚举,如果完成了,就继续执行,否则一直等待,直到收到可以离开安全区域的信号为止

记忆集与卡表

之前在讲解分代收集理论时,提到为了解决对象跨代引用的问题,垃圾收集器会在新生代建立名为记忆集(Remember Set)的数据结构,避免将整个老年代加入 GC Roots。事实上,所有涉及部分区域收集行为的垃圾收集器都会面临相同的问题

记忆集是一种用于记录非收集区域指向收集区域的指针集合的抽象数据结构,最简单的实现可以是数组,其中存放非收集区域中所有含跨代引用的对象。实际上,收集器只需要通过记忆集判断某一块非收集区域是否存在指向收集区域的指针即可,并不需要了解跨代指针的全部细节,因此我们可以适当选择更粗犷的记录粒度:

  • 字长精度:每个记录精确到一个机器字长,该字包含跨代指针
  • 对象精度:每个记录精确到一个对象,该对象里有字段含跨代指针
  • 卡精度:每个记录精确到一块内存区域,该区域有对象含跨代指针

最常用的是第三种“卡精度”,使用一种称为“卡表”的方式去实现记忆集。这里要提的一点是,记忆集只是一种抽象的数据结构,卡表是记忆集的一种具体实现,两者的关系可以类比 Java 中的 Map 和 HashMap

卡表最简单的形式可以是一个字节数组,HotSpot 虚拟机也确实这么做了。字节数组的每一个元素都对应其标识的内存区域中一块特定大小的内存块,这个内存块称为“卡页”。一个卡页的内存通常包含不止一个元素,只要卡页内有一个或多个对象的字段存在跨代指针,那就将对应卡表的数组元素标识为 1,否则为 0。发生垃圾收集时,只要筛选出卡表中变脏的元素,就能轻易地把它们加入 GC Roots

写屏障

如何维护卡表元素呢?例如它们何时变脏,谁来把它们变脏等。何时变脏的答案很明显,只要有其他分代区域的对象引用了本区域对象,那么对应的卡表元素就应该变脏,变脏时间点原则上应该发生在引用类型字段赋值的那一刻

问题是如何变脏呢?HotSpot 虚拟机是通过写屏障(Write Barrier)技术来维护卡表状态的。写屏障可以看作是虚拟机对“引用类型字段赋值”这个动作的 AOP 切面,在引用对象赋值时会产生一个环形通知,供程序执行额外的动作。应用写屏障后,虚拟机就会为所有赋值操作生成对应的指令。尽管这个动作也会产生额外开销,但和 Minor GC 时扫描整个老年代相比根本不值一提

卡表在高并发场景下还会面临伪共享(False Sharing)问题。现代中央处理器的缓存系统是以缓存行(Cache Line)为单位存储的,当多线程修改互相独立的变量,而这些变量恰好共享同一缓存行,则会导致性能降低。如果所有卡表元素共享同一缓存行,那么更新时有可能会出现伪共享问题。一种简单的解决方案是先检查卡表标记,只有当该卡表元素未被标记过时才将其标记为变脏

并发的可达性分析

可达性分析算法理论上要求全过程都基于一个能保障一致性的快照,即必须冻结全部用户线程。在根结点枚举阶段,由于 GC Roots 相比整个 Java 堆的全部对象毕竟还算极少数,且有各种优化技巧(如 OopMap),它带来的停顿可以说微不足道。但如果从 GC Roots 开始往下遍历对象图,那么这一阶段的停顿时间必然与 Java 堆容量成正比例关系:堆越大,存储的对象就越多,对象图结果越复杂,自然花的时间也越多

因此,部分垃圾收集器是允许用户线程与收集器线程并发工作的,但如果在收集器标记对象的同时,用户线程修改了引用关系,就会产生两种后果:把原本应该消亡的对象错误标记为存活;把原本应该存活的对象错误标记为消亡。前一种还好一些,不过是产生浮动垃圾罢了,而后一种就非常致命了,程序肯定会因此发生错误。为了更好地说明这个问题,我们按照“是否访问过”为条件将对象标记为以下三种颜色:

  • 白色:表示对象尚未被垃圾收集器访问过
  • 黑色:表示对象已经被垃圾收集器访问过,且该对象的所有引用都已经被扫描
  • 灰色:表示对象已经被垃圾收集器访问过,但该对象至少还有一个引用没有被扫描

前面提到过的将应该存活的对象错误标记为消亡这一现象称为“对象消失”问题,即原本应该是黑色的对象被误标为白色,这一问题当且仅当以下两个条件同时满足时才会发生:

  • 赋值器插入一条或多条从黑色对象到白色对象的新引用
  • 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用

要想解决对象消失问题,只需破坏这两个条件的任意一个即可,由此产生了两种解决方案:增量更新(Incremental Update)和原始快照(Snapshot At The Beginning,SATB)

增量更新破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用,就将其记录下来,等并发扫描结束后,再将记录过的新引用关系中的黑色对象作为根,重新扫描一次

原始快照破坏的是第二个条件,当灰色对象要删除指向白色对象的引用时,同样将其记录下来,等并发扫描结束后,再将记录过的引用关系中的灰色对象为根,重新扫描一次

以上两种方式都是基于写屏障实现

版权声明:程序员胖胖胖虎阿 发表于 2022年9月3日 上午7:16。
转载请注明:JVM HotSpot 可达性分析算法实现细节 | 胖虎的工具箱-编程导航

相关文章

暂无评论

暂无评论...