简单易懂JVM系列(三)-根节点枚举及相关算法实现细节

一、背景

这部分知识在面试过程中频繁出现,了解这部分知识一方面可以使我们对于接下来的垃圾收集器更好理解,另一方面我们可以更深入探讨这些方法细节,避免仅停留于表面概念。那么接下来就进入这部分知识吧!

img

二、根节点枚举

2.1 根节点枚举效率低问题

接下来我们以可达性分析算法作为例子,如果忘记了可以观看上一篇文章。可达性分析算法从GC Roots集合寻找引用链从而判断对象是否可回收,其中可固定作为GC Roots的节点主要为以下俩种。

  • 全局性的引用。例如常量或静态属性
  • 执行上下文。栈帧中的本地变量表

尽管GC Roots节点确定,但随着Java应用越来越大,光方法区的大小就常有数百上千兆,其中类、变量更是数不清,因此若是从这些节点出发必然需要花费大量时间

此外,所有收集器在根节点枚举均需要STW(Stop the world),若STW时间过长必然会影响执行效率。尽管现在可达性分析算法耗时最长的查找引用链已经可以做到与用户线程一起并发,但根节点枚举需要始终保证一致性的快照中进行,即这个时间点所有对象之间的引用关系不会变化,否则不能保证分析结果的准确性。

2.2 解决办法

目前主流Java虚拟机使用的都是准确式垃圾收集,也就是当用户线程停顿下来,并不需要对每个全局性的引用和执行上下文进行准确无误的检查,虚拟机应该有办法获得哪些地方存储着对象引用

HotSpot使用一组成为OopMap的数据结构来记录信息,当类加载动作完成,HotSpot就会把对象内什么偏移量上是什么类型数据计算出来,在即时编译过程中也会把特定的位置记录下栈里和寄存器里哪些位置是引用,这样垃圾收集器在扫描时就能直接得知这些信息,这样就不需要一个不漏从方法区GC Roots开始查找。

三、安全点

3.1 OopMap内存占用问题

HotSpot通过OopMap可以快速准确地完成GC Roots枚举,但随着会产生产生一个问题:当引用关系变化时,或者导致OopMap内容变化的指令非常多,那么多需要为每一条指令都生成对应的OopMap,那么会占用大量额外存储空间

3.2 解决办法

实际上HotSpot也的确没有为每一条指令都生成OopMap, 只是在特定位置记录这些信息,这些位置称为安全点

垃圾收集不能在指令流任意位置停下来进行垃圾收集,而是要求必须执行到安全点后才能够暂停

安全点选择需要符合以下三个条件

  • 安全点不能太少,以至于收集器等待时间过长
  • 也不能太过频繁,以至于过分增大运行时内存负荷
  • 选取基本上以“是否具有让程序长时间执行的特征”为标准选定。因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长而长时间执行,“长时间执行”的最明显特征就是指令序列复用,例如方法调用、循环跳转,异常跳转等属于指令序列服用,这样的指令才会产生安全点

那么如何在垃圾收集发生时,线程都跑到最近的安全点并停顿下来,这里提供了俩种方案,抢先式中断主动式中断

抢先式中断:抢先式中断不需要线程的执行代码主动去配合,在垃圾收集发生时,系统首先把所有线程都中断,如果用户线程中断的地方不在安全点上,就恢复这条线程执行,让它运行到安全点时在中断。不过主流虚拟机并没有采用抢先式中断

主动式中断:当垃圾收集需要中断线程的时候,不直接对线程操作,仅设置一个标志位, 各个线程在执行过程中会不停的去轮询这个标志位,一旦发现这个中断标志为真时就自己在最近的安全点主动中断挂起。轮询查询标志与安全点位置是重合的,另外还要加上所有创建对象和其他需要在Java堆上分布内存的地方。HotSpot把内存保护陷阱的方式,把轮询操作精简至只有一条汇编指令的程度

四、安全区域

4.1 安全点存在的缺陷

安全点机制保证了程序执行时,在不长的时间内就会遇到可进入垃圾收集过程的安全点。但是程序“不执行“的时候,也就是线程没有被分配处理器时间,典型场景就是用户线程处于Sleep状态或者Blocked状态,这时候线程无法响应虚拟机的中断请求,无法主动走到安全点进行挂起中断,那么此时应该怎么办?

4.2 安全区域

安全区域:能够确保在某一段代码片段之中,引用关系不会发生变化,那么在这一段位置任意位置开始垃圾收集都是安全的。

  • 当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,那么当这段时间虚拟机发起垃圾收集时可不必管这些已经声明进入安全区域的线程。
  • 当线程要离开安全区域时,它需要检查虚拟机是否已经完成根节点枚举,如果完成便可离开,否则需要一直等待,直至收到可以离开安全区域通知。

4.3 记忆集与卡表

记忆集:是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。最简单的实现方案可以用非收集区域中所有含跨代引用的对象数组来实现这个数据结构

最简单的实现方案固然简单,但会占用大量空间且维护成本 高。在垃圾收集场景中,收集器只需要通过记忆集判断出某一块非收集区域是否存在有指向收集区域的指针就可以,并不需要太多细节。因此可以通过改变记忆集的记录粒度来节省记忆集的空间和维护成本。

  • 字长精度:每个记录精确到一个机器字长(就是处理器的寻址位数,例如32位、64 位, 这个精度决定了机器访问物理内存地址的指针长度)。
  • 对象精度:每个记录精确到一个对象,该对象有字段包含跨代指针。
  • 每个记录精确到每一块内存区域,该区域内有对象含有跨代指针。

卡表:就是”卡精度“的具体实现,也是最常用的一种记忆集实现方式。记忆集只是一种”抽象“数据结构,只定义记忆集行为意图,并没有定义具体实现。

HotSpot虚拟机通过一个字节数组来实现卡表,这也是最简单的实现方式。以下是HotSpot默认的卡表标记逻辑。

CARD_TABLE[this address >> 9] = 0;

字节数组CARD_TABLE的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存卡就是卡页一般来说卡页的大小都是以2的N次幂的字节数

上述代码中我们卡页发现,HotSpot使用的卡页是2的9次幂(512字节)。假设卡表标识内存区域的起始地址是0x0000,那么数组CARD_TABLE的第0、1、2号元素分别对应0x00000x01FF、0x02000x03FF、0x0400~0x05FF的卡页内存块,如下图所示。

0x0000~0x01FF。其中0x表示16进制,而0x01FF转换为十进制为511,那么这个地址段包含的字节数恰好为512个字节。

img

此外,一个卡页的内存中通常包含不止一个对象,只要卡页内有一个(或更多)对象字段存在着跨代指针,那就将对应卡表的数组元素的值标识为1,称这个元素变脏(Dirty),没有则标识为0,那么垃圾收集只需要判断卡表中变脏的元素,就可以轻易得出哪些卡页内存卡中存在跨代指针,并加入GC Roots中一并扫描。

4.4 写屏障

上面我们通过使用记忆集来缩减GC Roots扫描范围的问题,但还没有解决卡表元素如何维护的问题,例如卡表元素何时变脏?,谁来将它们变脏?

卡表元素何时变脏?

  • 卡表元素应该在有其他分代区域中对象引用了本区域对象时,其对应的卡表元素就应该变脏,也就是引用类型字段赋值的那一刻。

如何将卡表元素变脏呢?

  • 假如是解释执行的字节码,而虚拟机负责每条字节码指令的执行,有充分介入空间。但是在编译执行的场景中,经过即时编译后的代码一已经是纯粹的机器指令流,那么需要在机器码层面把维护卡表的动作放到每一个赋值操作之中。

HotSpot通过写屏障(Writer Barrier)技术维护卡表状态,写屏障可以看作在虚拟机层面对”引用类型字段赋值“这个动作的AOP切面,也就是这个动作前后都在写屏障范围内,那么在动作前即为写前屏障,动作后为写后屏障。

应用写屏障后,虚拟机就会为所有赋值操作生成相应的指令,一旦收集器在写屏障中增加更新卡表操作,无论更新的是否老年代对新生代对象的引用,都会进行更新,不过更新开销远小于Minor GC时扫描整个老年代的代价相比低得多。

卡表还经常面临着”伪共享“问题,伪共享是处理并发底层细节时一种需要经常考虑的问题,举个例子,当多线程修改互相独立的变量时,如果这些变量刚好共享同一个缓存行,那么就会互相影响,从而导致性能下降,这就是伪共享问题。

一种最简单解决“伪共享”问题的方法,就是设置有条件的写屏障,写屏障前要先检查卡表标记,只有当该卡表元素未被标记过时才将其变脏。

4.5 并发的可达性分析

可达性分析算法理论上要求全过程都基于一个能保障一致性的快照中才能够进行分析,这意味着必须全程冻结用户线程的运行。

在根节点枚举过程中,GC Roots相比于Java堆中全部对象毕竟也算是极少数,且在各种优化技巧下(OopMap)的加持下,它带来的停顿已经是非常短暂且相对固定了。但GC Roots寻找引用链过程时间就与Java堆的大小成正比例关系。其中”标记“阶段是所有追踪式垃圾收集算法的共同特征,如果随着堆容量变大,标记对象的时间越长,那么对所有垃圾收集器都有巨大影响。

想解决或者降低用户线程的停顿,就要搞清楚为什么要在一个能保障一致性的快照上才能进行对象图遍历?可达性分析扫描过程以及三色标记法大家可以参考这篇文章(深入理解可达性分析扫描过程)。

三色标记法中,按照“是否访问过”这个条件标记为三种颜色

  • 白色:表示对象未被垃圾收集器访问过
  • 黑色:表示对象已经被垃圾收起访问过
  • 灰色:表示对象以及被垃圾收集器访问过,但这个对象至少存在一个引用还没有被扫描过

如果用户线程与收集器并发工作,那么收集器在对象图上标记颜色,用户线程在修改引用关系(修改对象图结构),这样可能会出现俩种后果。

  • 把原本消亡的对象错误标记为存活,但可以容忍,只是逃过本次垃圾收集,下次收集清理就好了
  • 把原本存活的对象错误标记为已消亡,这是非常致命的后果。

Wilson在理论上证明了,当且仅当以下俩个条件同时满足时,会产生”对象消失“问题。即原本黑色的对象被误标记为白色。

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

那么我们要解决并发扫描时对象小时问题,只需要破坏这俩个条件中的任意一个即可,因此就产生俩种方案,增量更新,原始快照。

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

原始快照:原始快照破坏的是第二个条件,也就是无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照进行搜索。

  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值