JVM系列——HotSpot虚拟机的GC算法细节实现

之前的文章从理论原理上介绍了常见的对象存活判定算法和垃圾收集算法,Java虚拟机实现这些算
法时,必须对算法的执行效率有严格的考量,才能保证虚拟机高效运行。

根节点枚举

我们以可达性分析算法中从GC Roots集合找引用链这个操作作为介绍虚拟机高效实现的第一个例子。
固定可作为GC Roots的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如
栈帧中的本地变量表)中。尽管目标明确,但查找过程要做到高效并非一件容易的事情。

目前,所有收集器在根节点枚举这一步骤时都是必须暂停用户线程的,因此根节点枚举与之前提及的整理内存碎片一样会有“Stop The World”的困扰。
现在可达性分析算法耗时最长的查找引用链的过程已经可以做到与用户线程一起并发,但根节点枚举始终还
是必须在一个能保障一致性的快照中才得以进行

这里“一致性”的意思是整个枚举期间执行子系统看起来就像被冻结在某个时间点上,不会出现分析过程中,根节点集合的对象引用关系还在不断变化的情况。否则分析结果的准确性无法保证

由于目前主流Java虚拟机使用的都是准确式垃圾收集.
所以当用户线程停顿下来之后,其实并不需要一个不漏地检查完所有执行上下文和全局的引用位置;虚拟机应当是有办法直接得到哪些地方存放着对象引用的。

在HotSpot的解决方案里,是使用一组称为OopMap的数据结构来达到这个目的。
一旦类加载动作完成的时候,HotSpot就会把对象内对应偏移量上的类型的数据计算出来,在即时编译过程中,也会在特定的位置记录下栈里和寄存器里哪些位置是引用
这样收集器在扫描时就可以直接得知这些信息了,并不需要真正一个不漏地从方法区等GC Roots开始查找。

安全点

什么是安全点

在OopMap的协助下,HotSpot可以快速准确地完成GC Roots枚举。
但一个很现实的问题随之而来:可能导致引用关系变化,或者说导致OopMap内容变化的指令非常多。
如果为每一条指令都生成对应的OopMap,那将会需要大量的额外存储空间,这样垃圾收集伴随而来的空间成本就会变得无法忍受的高昂。

实际上HotSpot也并未对每条指令都生成OopMap,只是在“特定的位置”记录了这些信息,这些位置被称为安全点(Safepoint)

安全点的选取

有了安全点,因此用户程序执行时并非在任意位置都能停下来开始垃圾收集,而是强制要求必须执行到达安全点后才能够暂停
因此,安全点的选定既不能太少,会让收集器等待时间过长;也不能太多,这会增大运行时的内存负荷。

安全点位置的选取基本上是以“是否具有让程序长时间执行的特征”为标准 进行选定的。

什么时候产生安全点
长时间执行”的最明显特征就是指令序列的复用,例如方法调用、循环跳转、异常跳转 等都属于指令序列复用,所以只有具有这些功能的指令才会产生安全点。

如何跑到安全点

如何在垃圾收集发生时让所有线程(这里其实不包括执行JNI调用的线程)都跑到最近的安全点,然后停顿下来?这里有两种方案可供选择:抢先式中断(Preemptive Suspension)和主动式中断(Voluntary Suspension)。

主动式中断

主动式中断的思想是当垃圾收集需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志位,各个线程执行过程时会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。

轮询标志的地方和安全点是重合的,另外还要加上所有创建对象和其他需要在Java堆上分配内存的地方,这是为了检查是否即将要发生垃圾收集,避免没有足够内存分配新对象。

由于轮询操作在代码中会频繁出现,这要求它必须足够高效。
HotSpot使用内存保护陷阱的方式,把轮询操作精简至只有一条汇编指令的程度。
当需要暂停用户线程时,虚拟机把对应的内存页设置为不可读,那线程执行到test指令时就会产生一个自陷异常信号,然后在预先注册的异常处理器中挂起线程实现等待,这样仅通过一条汇编指令便完成安全点轮询和触发线程中断了。

最后说下抢先式,抢先式中断不需要线程的执行代码主动去配合,是系统介入,但现在几乎没有虚拟机实现采用抢先式中断来暂停线程响应GC事件。

安全区域

安全点的似乎已经解决了如何停顿用户线程,让虚拟机进入垃圾回收状态的问题了;
但实际情况却并不一定。安全点机制保证了程序执行时,在不太长的时间内就会遇到可进入垃圾收集
过程的安全点。

但是,程序“不执行”的时候呢
程序不执行就是没有分配处理器时间,典型的场景便是用户线程处于Sleep状态或者Blocked状态。
这时候线程无法响应虚拟机的中断请求,不能再走到安全的地方去中断挂起自己,虚拟机也显然不可能持续等待线程重新被激活分配处理器时间。

对于这种情况,就必须引入**安全区域(Safe Region)**来解决。

安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的

我们也可以把安全区域看作被扩展拉伸了的安全点。
当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,那样当这段时间里虚拟机要发起垃圾收集时就不必去管这些已声明自己在安全区域内的线程了。

当线程要离开安全区域时,它要检查虚拟机是否已经完成了根节点枚举(或者垃圾收集过程中其他需要暂停用户线程的阶段),如果完成了,那线程就当作没事发生过,继续执行;
否则它就必须一直等待,直到收到可以离开安全区域的信号为止。

记忆集与卡表

之前在分代收集理论中提到了为解决对象跨代引用所带来的问题,垃圾收集器在新生代中建立了名为记忆集(Remembered Set)的数据结构,用以避免把整个老年代加进GC Roots扫描范围。
事实上并不只是新生代、老年代之间才有跨代引用的问题,所有涉及部分区域收集(Partial GC)行为的
垃圾收集器,典型的如G1、ZGC和Shenandoah收集器,都会面临相同的问题。

记忆集

因此我们有必要进一步理清记忆集的原理和实现方式,以便后续理解与学习。

记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构

如果不考虑效率和成本的话,最简单的实现可以用非收集区域中所有含跨代引用的对象数组来实现这个数据结
构。
这种方案,无论是空间占用还是维护成本都相当高昂。

在垃圾收集的场景中,收集器只需要通过记忆集判断出某一块非收集区域是否存在有指向了收集区域的指针就可以了,并不需要了解这些跨代指针的全部细节

卡表

因此在实现记忆集的时候,便可以选择更粗粒度的记录来节省记忆集的存储和维护成本。下面列举了一些可供选择(当然也可以选择这个范围以外的)的记录精度:

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

其中,第三种“卡精度”所指的是用一种称为**“卡表”**(Card Table)的方式去实现记忆集,这也是目前最常用的一种记忆集实现形式。

记住前面定义中提到记忆集其实是一种“抽象”的数据结构。
而卡表就是记忆集的一种具体实现,它定义了记忆集的记录精度、与堆内存的映射关系等。
关于卡表与记忆集的关系,读者不妨按照Java语言中HashMap与Map的关系来类比理解。

卡表最简单的形式可以只是一个字节数组[2],而HotSpot虚拟机确实也是这样做的。以下这行代码是HotSpot默认的卡表标记逻辑:

CARD_TABLE [this address >> 9] = 0;

字节数组CARD_TABLE的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个
内存块被称作“卡页”(Card Page)。一般来说,卡页大小都是以2的N次幂的字节数,通过上面代码可
以看出HotSpot中使用的卡页是2的9次幂。

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

写屏障

上一节何使用记忆集来缩减GC Roots扫描范围的问题,但还没有解决卡表元素如何维护的问题,例如它们何时变脏、谁来把它们变脏等。

我们知道当有其他分代区域中对象引用了本区域对象时,其对应的卡表元素就应该变脏,变脏时间点原则上应该发生在引用类型字段赋值的那一刻。

但问题是如何变脏,即如何在对象赋值的那一刻去更新维护卡表呢
如果是解释执行的字节码,那相对好处理,虚拟 机负责每条字节码指令的执行,有充分的介入空间;、
但在编译执行的场景中,经过即时编译后的代码已经是纯粹的机器指令流了,这就必须找到一个在机器码层面的手段,把维护卡表的动作放到每一个赋值操作之中。

HotSpot通过写屏障(Write Barrier)技术维护卡表状态的(这里的“写屏障”,以及后面的“读屏障”与并发乱序执行问题中的“内存屏障”是不同的)。

写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的AOP切面,在引用对象赋值时会产生一个环形(Around)通知,供程序执行额外的动作。

也就是说赋值的前后都在写屏障的覆盖范畴内。有写前屏障(Pre-Write Barrier)和写后屏障(Post-Write Barrier)。

开销

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

除了写屏障的开销外,卡表在高并发场景下还面临着“伪共享”(False Sharing)问题。
伪共享是处理并发底层细节时一种经常需要考虑的问题,现代中央处理器的缓存系统中是以缓存行(Cache Line)为单位存储的,当多线程修改互相独立的变量时,如果这些变量恰好共享同一个缓存行,就会彼此影
响(写回、无效化或者同步)而导致性能降低,这就是伪共享问题
(这里太细节,暂不多说)

并发的可达性分析(三色标记法)

目前主流编程语言的垃圾收集器基本上都是依靠可达性分析算法来判定对象是否存活的,可达性分析算法理论上要求全过程都基于一个能保障一致性的快照中才能够进行分析。
而由于GC Roots占比较少,且基于OopMap等手段,由于GC Roots的枚举导致的停顿时间已经非常短且相对固定了。

可从GC Roots再继续往下遍历对象图时,所需停顿时间就必定会与Java堆容量成正比了:堆越大,存储的对象越多,对象图结构越复杂,要标记更多对象而产生的停顿时间就更长。
而“标记”阶段是所有追踪式垃圾收集算法的共同特征。

三色标记

想降低用户线程的停顿,要先搞清楚为什么必须在一个能保障一致性的快照上才能进行对象图的遍历。
这里引入三色标记(Tri-color Marking)来帮助理解:把遍历对象图过程中遇到的对象,按照“是否访问过”这个条件标记成以下三种颜色:

  • 白色:表示对象尚未被垃圾收集器访问过。在可达性分析刚刚开始的阶段,所有的对象都是白色的。若在分析结束的阶段,仍然是白色的对象,即代表不可达。
  • 黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。

黑色的对象代表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象(因为色是未被访问过的)

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

当用户线程与收集器是并发工作时,收集器在对象图上标记颜色,同时用户线程在修改引用关系——即修改对象图的结构
这样可能出现两种后果:

  • 一种是把原本消亡的对象错误标记为存活,这是可以容忍的,只不过一些垃圾逃过回收,下次收集清理掉就好。
  • 另一种是把原本存活的对象错误标记为已消亡,这是非常致命的,程序肯定会因此发生错误。

原本存活对象被标记为已消亡的例子入下
image.png
首先,用户线程和收集器一起工作,相安无事。
此时用户线程将右侧黑色对象对下方灰色对象的引用切断,并将下方对象和左侧黑色对象建立链接:
image.png
由于黑色对象不会再访问,导致白色对象被回收,这很危险。

  • todo:两张图之间不太连贯,后续手动补充一个,连贯的状态。

因此我们需要一定的约束来避免这种情况。
Wilson于1994年在理论上证明了,当且仅当以下两个条件同时满足时,会产生“对象消失”的问题,即原本应该是黑色的对象被误标为白色:

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

增量更新和原始快照

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

增量更新要破坏的是第一个条件。

当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录(引用关系)下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。

这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了。

原始快照要破坏的是第二个条件。

当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录(引用关系)下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。

这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。

无论是对引用关系记录的插入还是删除,虚拟机的记录操作都是通过写屏障实现的。
在HotSpot虚拟机中,增量更新和原始快照这两种解决方案都有实际应用:譬如,CMS是基于增量更新
来做并发标记的,G1、Shenandoah则是用原始快照来实现。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值