来源:极客时间 | 深入拆解 Java 虚拟机
卡表
为了解决在Minor GC中为了找出Minor GC中 所有的GC Roots 因为老年代中可能存在对新生代的引用,导致我们需要扫描整个老年代吗? HotSpot 给出的解决方案是一项叫做卡表(Card Table)的技术。
该技术将整个堆划分为一个个大 小为 512 字节的卡,并且维护一个卡表,用来存储每张卡的一个标识位。这个标识位代表对应的卡 是否可能存有指向新生代对象的引用。如果可能存在,那么我们就认为这张卡是脏的。
在进行 Minor GC 的时候,我们便可以不用扫描整个老年代,而是在卡表中寻找脏卡,并将脏卡中 的对象加入到 Minor GC 的 GC Roots 里。当完成所有脏卡的扫描之后,Java 虚拟机便会将所有脏 卡的标识位清零。
由于 Minor GC 伴随着存活对象的复制,而复制需要更新指向该对象的引用。因此,在更新引用的 同时,我们又会设置引用所在的卡的标识位。这个时候,我们可以确保脏卡中必定包含指向新生代 对象的引用。
在 Minor GC 之前,我们并不能确保脏卡中包含指向新生代对象的引用。其原因和如何设置卡的标 识位有关。
首先,如果想要保证每个可能有指向新生代对象引用的卡都被标记为脏卡,那么 Java 虚拟机需要截 获每个引用型实例变量的写操作,并作出对应的写标识位操作。
这个操作在解释执行器中比较容易实现。但是在即时编译器生成的机器码中,则需要插入额外的逻 辑。这也就是所谓的写屏障(write barrier,注意不要和 volatile 字段的写屏障混淆)。
写屏障需要尽可能地保持简洁。这是因为我们并不希望在每条引用型实例变量的写指令后跟着一大 串注入的指令。
因此,写屏障并不会判断更新后的引用是否指向新生代中的对象,而是宁可错杀,不可放过,一律 当成可能指向新生代对象的引用。
这么一来,写屏障便可精简为下面的伪代码 [1]。这里右移 9 位相当于除以 512,Java 虚拟机便是通 过这种方式来从地址映射到卡表中的索引的。最终,这段代码会被编译成一条移位指令和一条存储 指令。
CARD_TABLE [this address >> 9] = DIRTY;
虽然写屏障不可避免地带来一些开销,但是它能够加大 Minor GC 的吞吐率( 应用运行时间 /(应用 运行时间 + 垃圾回收时间) )。总的来说还是值得的。不过,在高并发环境下,写屏障又带来了虚 共享(false sharing)问题 [2]。
在介绍对象内存布局中我曾提到虚共享问题,讲的是几个 volatile 字段出现在同一缓存行里造成的 虚共享。这里的虚共享则是卡表中不同卡的标识位之间的虚共享问题。
在 HotSpot 中,卡表是通过 byte 数组来实现的。对于一个 64 字节的缓存行来说,如果用它来加 载部分卡表,那么它将对应 64 张卡,也就是 32KB 的内存。
如果同时有两个 Java 线程,在这 32KB 内存中进行引用更新操作,那么也将造成存储卡表的同一部 分的缓存行的写回、无效化或者同步操作,因而间接影响程序性能。
为此,HotSpot 引入了一个新的参数 -XX:+UseCondCardMark
,来尽量减少写卡表的操作。其伪代 码如下所示:
if (CARD_TABLE [this address >> 9] != DIRTY) CARD_TABLE [this address >> 9] = DIRTY;