迄今为止,所有垃圾收集器在根节点枚举上都必须要暂停用户线程,问题同前文中提到的在整理内存碎片。
在根节点枚举时,算法必须在一个能保证一致性快照中才能得以进行,通俗讲就是要保证在枚举的过程中,不允许对象的引用关系还在不断变化,因为这样才能保证验证结果的准确性。
由于目前的主流JVM使用的都是准确式垃圾收集器(指使用准确式内存管理的垃圾收集器,即使用此管理的虚拟机可以知道内存中某个位置的数据具体是什么类型),所以当用户线程停顿时,不需要一个不漏的检查完执行上下文和全局的引用位置,虚拟机有办法直接知道哪些地方存放的是对象引用(注意区分数据与引用)。
在HotSpot中,对象的类型信息里有记录自己的OopMap,记录了在该类型的对象内什么偏移量上是什么类型的数据,在即时编译中也会在特定位置记录栈和寄存器里哪些地方是引用。这收集器在扫描时就可以直接获知这些信息,不需要一个不漏的从方法区等GC Roots开始查找。
HotSpot在OopMap的帮助下可以快速准确的完成GC Roots的枚举,但导致OopMap内容发生变化的指令非常多,如果为每一条指令都生成对应的OopMap,将需要大量的额外存储空间,高昂的空间成本将变得不可接受。
实际上HotSpot并没有为每条指令都声称OopMap,前文提到虚拟机只是在特定的位置记录这些信息,这些特定的位置被称为安全点(SafePoint)。安全点的设定决定了用户程序执行时并非在代码指令流的任意位置都能暂停用户线程开始垃圾收集,而是强制要求必须执行到安全点后才能够暂停。据此,安全的选定不能过少导致收集器等待时间过长,也不能太过频繁从而增加运行时内存负荷。安全点的选取标准是“是否具有让程序长时间执行的特征”,只有具有方法调用、循环跳转、异常跳转等功能的指令才会产生安全点。
对于安全点还有一个需要考虑的问题,即如何在垃圾收集发生时让所有线程都执行到最近的安全点然后停顿。解决方案有两种
由于轮询操作的频繁出现,这要求轮询操作必须足够高效,HotSpot使用内存保护陷阱的方式,把轮询操作精简至只有一条汇编指令,虚拟机把0x160100的内存页设置为不可读,线程执行到0x160100地址指令时会产生一个自陷异常信号(操作系统中,用于实现用户态下运行的进程调用操作系统内核程序,常应用于程序中断处理机制),然后在预先注册的异常处理器中挂起等待。
安全区域是安全点的扩展,根本目的相同,扩展的目的是为了解决用户线程在Blocked或者Sleep状态下无法相应虚拟机中断请求,从而无法执行到安全点中断挂起自己的问题。
当用户线程执行到安全区域里的代码时欧拉欧拉,首先会标识自己已经进入了安全区域,这样在执行安全区域代码的这段时间内,虚拟机发起垃圾收集时就不必管理这些已经声明自己在安全区域内的线程。当线程要离开安全区域时,要检查虚拟机是否已经完成了垃圾收集过程中需要暂停用户线程的阶段,完成了则线程将正常离开安全区域,否则线程必须一直等待,直到接收到可以离开安全区域的信号为止。
为了解决对象跨代引用所带来的问题(不止体现在新生代和老年代上,所有部分收集行为都会面临对象跨代引用问题),垃圾收集器在新生代中建立的名为记忆表(Remembered Set)的数据结构(注:记忆集并不是某种特定的数据结构,而是一种抽象概念,需要具体到虚拟机的实现才能讨论其具体的结构),避免将整个老年代加入GC Roots。
在垃圾收集场景中,收集器只需要通过记忆集判断出某一块非收集区域是否存在指向收集区域的指针即可,不需要记录完整的对象,基于这个前提,设计者在实现记忆集的时候可以选择精度较低的记录方式,常见的有
其中卡精度使用的是一种被称为"卡表(Card Table)"的方式实现记忆集,卡表也是目前最常用的一种记忆集实现形式。卡表最简单的形式可以只是一个字节数组,下面的代码是HotSpot默认的卡表标记逻辑:
CARD_TABLE [this address >> 9] = 0;
字节数组每一个元素都对应着其标识的内存区域一块特定大小的内存块,这个内存块被称作"卡页(Card Page)"。通常卡页的大小都是2的N次幂的字节数,上述代码中可见HotSpot中默认卡页512字节。若卡表标识内存区域起始地址为0x0000,则数组内第0、1、2号元素,分别对应了地址0x0000~0x01FF、0x0200~0x03FF、0x0400~0x05FF卡页内存块。
如果卡表中的某一卡页存在跨代引用指针,则将对应卡表的数组元素的值设置为1,称为元素变脏。垃圾收集时只需要筛选出卡表中变脏的元素,就可以得出哪些卡页中包含跨代指针,将其加入GC Roots中一起扫描。
在解决了如何使用记忆集来缩减GC Roots扫描范围的问题后,下一个问题也应运而生,以HotSpot中的卡表为例,在出现了跨代引用的情况时,如何在跨代引用赋值的那一刻去更新维护卡表呢?在编译执行的场景下,经过即时编译后的代码已经是纯粹的指令流,没有介入空间,因此必须有一个机器码层面的手段来维护更新卡表。
HotSpot通过写屏障(Write Barrier)维护更新卡表,其思路类似于面向切面编程,在跨代引用对象赋值时会产生一个环形通知,供程序执行额外动作,即赋值动作钱后都在写屏障的范围内,真正代码执行前的额外操作称为写前屏障,相反则称为写后屏障,更新维护卡表的操作就在写屏障里。虽然对每次跨代引用更新都会增加一次更新卡表的操作,但这个额外的开销相比将整个老年代加入GC Roots还是划算的。
使用写屏障还会出现伪共享问题,即当不同线程同时要更新同一个卡页时,会彼此影响(写回、无效化或同步)导致性能降低,解决方法是可以在更新卡页时加一个判断,只有没脏的卡页才能被更新,解决伪共享问题的同时会增加一次额外的判断开销。解决与不解决伪共享问题二者均有性能损耗,应根据实际运行情况来权衡。
前文中提到过,可达性分析算法理论上要求全过程都基于一个能保证一致性的快照才能够进行分析,因此必须全程冻结用户线程的运行,标记阶段是所有追踪式垃圾收集算法的共同特征,如果可以减少这个阶段的线程停顿时间,那么收益将是系统性的。
想要降低用户线程的停顿时间,那么就必须要先搞清楚为什么全过程都要基于一个能保证一致性的快照才能够正确分析,接下来引入三色标记作为辅助工具来推导,三色含义分别如下
扫描流程示意图如下:
假设只有垃圾收集线程在工作,那么扫描过程一定不会出现问题,但如果不暂停用户线程,即用户线程与垃圾收集线程共同工作。当垃圾收集线程在"标记颜色时",用户线程同时修改了引用关系,这样可能会出现两种问题。
出现上述问题的三色示意图如下
浮动垃圾的产生尚可接受,但本应存活的对象被回收确实致命的。当且仅当以下两个条件同时满足时,会产生对象消失的问题,即黑色对象被误判为白色
因此,为了解决对象消失问题,只需要破坏这两个条件中的其中一个即可。由此产生了两种解决方案,分别为增量更新和原始快照。
两种方式的记录操作都是虚拟机通过写屏障实现。
参考书籍 《深入理解Java虚拟机》第三版 ——周志明
本篇内容主要用于作者自身学习总结记录,才疏学浅,如文中出现纰漏,还望指正