根节点枚举

目前为止,所有收集器在根节点枚举这一步都必须暂停用户线程。

目前可达性分析算法耗时最长的查找引用链的过程可以与用户线程一起并发,但根节点枚举需要在一个类似于一致性快照才可以进行,这里一致性是指,整个枚举根节点的过程,子系统就像被冻结在某个点,这个过程中对象间的引用关系是不能发生变化的,否则分析的结果没有意义。

在暂停用户线程时,虚拟机使用了一组称为OopMap的数据结构来避免遍历所有的执行上下文全局引用的位置。一旦类加载完成时,HotSpot就会把对象内每个偏移量上是什么类型的数据计算出来,也会在特定位置记录下栈里和寄存器里哪些位置是引用。这样收集器在扫描时就可以直接得到这些信息,就不用遍历了。

安全点

上面提到使用OopMap来避免进行遍历所有的引用,但是导致OopMap进行变化的指令特别多,如果为每一条指令都生成OopMap,那么将耗费大量的内存空间。

实际上,HotSpot并没有为每一条都指令都生成对应的OopMap,而是在特定的位置记录了这些信息,这个特定的位置就是安全点。这也就意味着用户代码并不是在任何时候都可以暂停,然后进行垃圾回收,而是必须到达安全点才可以暂停。

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

抢先式中断在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上。几乎没有虚拟机使用。

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

安全区域

上面的安全点保证了正在执行的代码每隔一段时间都会遇到一个可以进行垃圾回收的点,但是对于那些没在运行的程序,就需要另外考虑。比如用户线程处于sleep状态或者Blocked状态,这时候线程无法响应虚拟机的中断请求,不能再走到安全点去中断挂起自己,这时候就需要引入安全区域。

安全区域能够保证在一段代码内,引用关系不会发生变化,因此在这段区域内任何地方进行垃圾回收都是安全的。

当用户线程执行到安全区域时,会标识自己已经进入,那样当这段时间里虚拟机要发起垃圾收集时就不必去管这些已声明自己在安全区域内的线程了。当线程要离开安全区域时,它要检查虚拟机是否已经完成了根节点枚举(或者垃圾收集过程中其他需要暂停用户线程的阶段),如果完成了,那线程就当作没事发生过,继续执行;否则它就必须一直等待,直到收到可以离开安全区域的信号为止。

记忆收集卡

记忆集是一种用于记录从非收集区域指收集区域的指针集合的抽象数据结构。存储在新生代当中。

有了记忆集之后,在进行垃圾收集时,收集器只需要通过记忆集判断出某一块非收集区域是否有指向目前正在收集区域的指针就可以了。

一种最常用的实现记忆集的方式是卡表,卡表的每个记录精确到一块内存区域,该区域内有对象含有的跨代指针。

一个具体的卡表结构如下:

image-20230313203352373

字节数组CARD_TABLE的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作“卡页”(Card Page)。

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

写屏障

在有其他分代区域中的对象引用了本区域的对象时,卡表需要变脏,变脏的时间点原则上在发生引用类型字段赋值的那一刻。但是如何在赋值的那一刻更新维护卡表呢?如果是解释执行的字节码,好处理一点,因为虚拟机负责每条字节码指令的执行。但是在经过即时编译的代码已经是纯粹的机器指令,虚拟机无法处理。

在HotSpot虚拟机中是通过写屏障维护卡表状态的。写屏障可以看作在虚拟机层面对引用类型字段赋值这个动作的AOP切面,在引用对象赋值时会产生一个环形通知,供程序执行额外的动作,也就是说赋值的前后都在写屏障的覆盖范畴内。在赋值前的屏障叫做写前屏障,赋值后的屏障叫做写后屏障。

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

为了避免伪共享问题,一种简单的解决方案是不采用无条件的写屏障,而是先检查卡表标记,只有当该卡表元素未被标记过时才将其标记为变脏。

并发的可达性分析

参考

《深入理解Java虚拟机》