因此只能靠一些条件来猜测是否有指针指向。比如在栈上扫描的时候根据所在地址是否在 GC 堆的上下界之内,是否字节对齐等手段来判断这个是不是指向 GC 堆中的指针。
之所以称之为保守式 GC 是因为不符合猜测条件的肯定不是指向 GC 堆中的指针,因此那块内存没有被引用,而符合的却不一定是指针,所以是保守的猜测。
我再画一张图来解释一下,看了图之后应该就很清晰了。
前面我们知道可以根据指针指向地址来判断,比如是否字节对齐,是否在堆的范围之内,但是就有可能出现恰好有数值的值就是地址的值。
这就混乱了,所以就不能确定这是指针,只能保守认为就是指针。
因此肯定不会有误杀对象的情况。只会有对象已经死了,但是有疑似指针的存在指向它,误以为它还活着而放过了它的情况发生。
所以保守式 GC 会有放过一些“垃圾”,对内存不太友好。
并且因为疑似指针的情况,导致我们无法确认它是否是真的指针,所以也就无法移动对象,因为移动对象就需要改指针。
有一个方法就是加个中间层,也就是句柄层,引用会先指到句柄,然后再从句柄表找到实际对象。
所以直接引用不需要改变,如果要移动对象只需要修改句柄表即可。不过这样访问就多了一层,效率就变低了。
半保守式GC半保守式GC,在对象上会记录类型信息而其他地方还是没有记录,因此从根扫描的话还是一样,得靠猜测。
但是得到堆内对象了之后,就能准确知晓对象所包含的信息了,因此之后 tracing 都是准确的,所以称为半保守式 GC。
现在可以得知半保守式 GC 只有根直接扫描的对象无法移动,从直接对象再追溯出去的对象可以移动,所以半保守式 GC 可以使用移动部分对象的算法,也可以使用标记-清除这种不移动对象的算法。
而保守式 GC 只能使用标记-清除算法。
准确式 GC相信大家看下来已经知道准确意味 JVM 需要清晰的知晓对象的类型,包括在栈上的引用也能得知类型等。
能想到的可以在指针上打标记,来表明类型,或者在外部记录类型信息形成一张映射表。
HotSpot 用的就是映射表,这个表叫 OopMap。
在 HotSpot 中,对象的类型信息里会记录自己的 OopMap,记录了在该类型的对象内什么偏移量上是什么类型的数据,而在解释器中执行的方法可以通过解释器里的功能自动生成出 OopMap 出来给 GC 用。
被 JIT 编译过的方法,也会在特定的位置生成 OopMap,记录了执行到该方法的某条指令时栈上和寄存器里哪些位置是引用。
这些特定的位置主要在:
循环的末尾(非 counted 循环)
方法临返回前 / 调用方法的call指令后
可能抛异常的位置
这些位置就叫作安全点(safepoint)。
那为什么要选择这些位置插入呢?因为如果对每条指令都记录一个 OopMap 的话空间开销就过大了,因此就选择这些个关键位置来记录即可。
所以在 HotSpot 中 GC 不是在任何位置都能进入的,只能在安全点进入。
至此我们知晓了可以在类加载时计算得到对象类型中的 OopMap,解释器生成的 OopMap 和 JIT 生成的 OopMap ,所以 GC 的时候已经有充足的条件来准确判断对象类型。
因此称为准确式 GC。
其实还有个 JNI 调用,它们既不在解释器执行,也不会经过 JIT 编译生成,所以会缺少 OopMap。
在 HotSpot 是通过句柄包装来解决准确性问题的,像 JNI 的入参和返回值引用都通过句柄包装起来,也就是通过句柄再访问真正的对象。
这样在 GC 的时候就不用扫描 JNI 的栈帧,直接扫描句柄表就知道 JNI 引用了 GC 堆中哪些对象了。
安全点我们已经提到了安全点,安全点当然不是只给记录 OopMap 用的,因为 GC 需要一个一致性快照,所以应用线程需要暂停,而暂停点的选择就是安全点。
我们来捋一遍思路。首先给个 GC 名词,在垃圾收集场景下将应用程序称为 mutator 。
一个能被 mutator 访问的对象就是活着的,也就是说 mutator 的上下文包含了可以访问存活对象的数据。
这个上下文其实指的就是栈、寄存器等上面的数据,对于 GC 而言它只关心栈上、寄存器等哪个位置是引用,因为它只需要关注引用。
但是上下文在 mutator 运行过程中是一直在变化的,所以 GC 需要获取一个一致性上下文快照来枚举所有的根对象。
而快照的获取需要停止 mutator 所有线程,不然就得不到一致的数据,导致一些活着对象丢失,这里说的一致性其实就像事务的一致性。
而 mutator 所有线程中这些有机会成为暂停位置的点就叫 safepoint 即安全点。
openjdk 官网对安全点的定义是: