前情提要,OopMap + Safe Point + Safe Region 有效提高 GC Roots 枚举的效率。然而,仅靠三者的组合,在实际情况中还是存在着缺陷。
什么是Remembered Set
在分代算法的场景下,线程执行的过程中,对象可能存储在新生代,也可能在老年代。那么,如果对象之间的引用关系,大概率会存在对象跨代引用。
举个例子,H 引用 E、J 引用 G 就是跨代引用,如下图所示:
![图1](https://img-blog.csdnimg.cn/img_convert/88ab1ed01b84019e1b7472aa61cc01d2.png)
在触发 新生代GC 的时候,由于 GC Roots 到 E 和 G 是不可达的,那么 E 和 G 将会被当作垃圾对象回收。导致 H 和 J 指向的地址不再是存放 E 和 G,并且是不确定的,这将会造成程序崩溃。
因此,关于 对象跨代引用 的问题,JVM 引入了 Remembered Set(记忆集)的概念。
Remembered Set 用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。例如,新生代GC 需要 Remembered Set 记录哪些老年代对象(非收集区域)引用了新生代对象(收集区域),如图1的 H->E、J->G。
Remembered Set 只是一种抽象的数据机构,根据不同的记录粒度,有不同的具体实现。
◉ 字长精度:每个记录精确到一个机器字长
(处理器的寻址位数,如32位、64位)
◉ 对象精度:每个记录精确到一个对象
◉ 卡精度:每个记录精确到一块内存区域
什么是Card Table
Card Table(卡表)就是 Remembered Set 卡精度的具体实现,是目前比较常用的 Remembered Set 实现方式。
可以理解为 Remembered Set 就是抽象类,Card Table 就是具体实现类。
在 HotSpot 中,卡表是一个字节数组,数组的每一个元素对应着所表示的内存区域中一块特定大小的内存块,这个内存块称为 Card Page(卡页)。
在 HotSpot 中,默认的卡表标记逻辑如下:
CARD_TABLE [this adress >> 9] = 0
这意味着,卡页的大小是2的9次幂,512字节。
以新生代的 Card Table 为例,Card Table 的每一个元素用来 标记 老年代的某一块内存区域(Card Page)的所有对象是否引用了新生代对象。
只要存在一个对象引用了新生代对象,那么将对应 Card Table 的数组元素的值标记为 0,说明这个元素变脏(Dirty)。
以图1为例,新生代的Card Table 和 Card Page 如下图所示:
![图2](https://img-blog.csdnimg.cn/img_convert/b6583ddca991f548f68c7e877c1075d9.png)
那么,在 新生代GC 的时候,不需要全量扫描老年代的内存空间,只需要筛选出 Card Table 中标记为 0 的元素,扫描老年代指定范围的内存块。
例如,图2 中的 Page1 和 Page2。
什么是Write Barrier
既然,Card Table 需要将对应下标的数组元素标记为 0,那么这个标记的操作是怎么进行的呢?
在 HotSpot 中,通过 Write Barrier(写屏障)维护 Card Table 的标记。
Write Barrier 就是在引用对象赋值的时候,使用 AOP(面向切面编程)进行标记的操作。
◉ 写前屏障(Pre-Write Barrier):赋值前的写屏障
◉ 写后屏障(Post-Write Barrier):赋值后的写屏障
注:直到 G1收集器出现之前,其他收集器都只用到了写后屏障
举个例子,从 Java 代码的角度去理解 Write Barrier:
不过,采用 Write Barrier 也会产生额外的开销,如果虚拟机为 每个 赋值操作都生成相应的指令并执行,必然会更大的开销,而且还会存在 “伪共享” (FalseSharing)的问题。
注:在新生代GC中,虚拟机为每个赋值操作都生成相应的指令并执行的开销,还是比扫描整个老年代的开销低得多。
针对这个问题,有一个解决方案就是采用 “先检查,再更新”。
先检查 Card Table 的标记,只有当 Card Table 的元素未被标记过,才会将该元素标记为 0。
if (CARD_TABLE [this adress >> 9] != 0)
CARD_TABLE [this adress >> 9] = 0
JDK 7 后,HotSpot 可以通过参数进行配置,是否开启 “先检查,再更新”
-XX:+UseCondCarMark