JVM 新生代垃圾回收如何避免全堆扫描?

Sherwin.Wei Lv8

JVM 新生代垃圾回收如何避免全堆扫描?

回答重点

JVM 在进行新生代垃圾回收时,通过以下方式避免全堆扫描

1)卡表(Card Table)机制:JVM 使用卡表记录老年代引用新生代对象的指针变化,从而在进行新生代回收时,只扫描那些老年代中确实有引用指向新生代的区域,避免了全堆扫描。

2)写屏障(Write Barrier):当老年代中的对象引用新生代对象时,写屏障会拦截这种引用,并在卡表中标记相关信息。这样,垃圾回收器在扫描时只需要检查标记的区域,而不是遍历整个老年代。

扩展知识

卡表(Card Table)机制

  • JVM 将老年代划分为小块区域(通常是 512 字节左右),称为 “卡”(Card)。每个卡对应一个字节,这些字节组成了所谓的卡表。当老年代对象持有对新生代对象的引用时,该引用对应的卡字节会被标记为“脏卡”(Dirty Card)。
  • 在进行新生代垃圾回收时,GC 不会扫描整个老年代,而是只会扫描卡表中被标记为脏卡的区域。这样可以有效避免全堆扫描,提升垃圾回收效率。

写屏障(Write Barrier)

  • 写屏障是一个用于拦截对象写入引用的机制。在老年代对象引用新生代对象时,写屏障会立即将相应的卡表区域标记为脏卡。
  • 通过写屏障的监控,JVM 能够在垃圾回收过程中准确地定位哪些老年代区域包含对新生代对象的引用,避免不必要的扫描。

卡表的进一步理解

根据对象存活的特性进行了分代,提高了垃圾收集的效率,但是像在回收新生代的时候,有可能有老年代的对象引用了新生代对象,所以老年代也需要作为根,但是如果扫描整个老年代的话效率就又降低了。

所以就搞了个叫记忆集(Remembered Set)的东西,来记录跨代之间的引用而避免扫描整体非收集区域。

因此记忆集就是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。根据记录的精度分为

  • 字长精度,每条记录精确到机器字长。
  • 对象精度,每条记录精确到对象。
  • 卡精度,每条记录精确到一块内存区域。

最常见的是用卡精度来实现记忆集,称之为卡表。

我来解释下什么叫卡。

拿对象精度来距离,假设新生代对象 A 被老年代对象 D 引用了,那么就需要记录老年代 D 所在的地址引用了新生代对象。

那卡的意思就是将内存空间分成很多卡片。假设新生代对象 A 被老年代 D 引用了,那么就需要记录老年代 D 所在的那一块内存片有引用新生代对象。

image.png

也就是说堆被卡切割了,假设卡的大小是 2,堆是 20,那么堆一共可以划分成 10 个卡。

因为卡的范围大,如果此时 D 旁边在同一个卡内的对象也有引用新生代对象的话,那么就只需要一条记录。

一般会用字节数组来实现卡表,卡的范围也是设为 2 的 N 次幂大小。来看一下图就很清晰了。

image.png

假设地址从 0x0000 开始,那么字节数组的 0号元素代表 0x0000~0x01FF,1 号代表0x0200~0x03FF,依次类推即可。

然后到时候回收新生代的时候,只需要扫描卡表,把标识为 1 的脏表所在内存块加入到 GC Roots 中扫描,这样就不需要扫描整个老年代了。

用了卡表的话占用内存比较少,但是相对字长、对象来说精度不准,需要扫描一片。所以也是一种取舍,到底要多大的卡。

还有一种多卡表,简单的说就是有多张卡表,这里我画两张卡表示意一下。

image.png

上面的卡表表示的地址范围更大,这样可以先扫描范围大的表,发现中间一块脏了,然后再通过下标计算直接得到更具体的地址范围。

这种多卡表在堆内存比较大,且跨代引用较少的时候,扫描效率较高。

而卡表一般都是通过写屏障来维护的,写屏障其实就相当于一个 AOP,在对象引用字段赋值的时候加入更新卡表的代码。

image.png

这其实很好理解,说白了就是当引用字段赋值的时候判断下当前对象是老年代对象,所引用对象是新生代对象,于是就在老年代对象所对应的卡表位置置为 1,表示脏,待会需要加入根扫描。

不过这种将老年代作为根来扫描会有浮动垃圾的情况,因为老年代的对象可能已经成为垃圾,所以拿垃圾来作为根扫描出来的新生代对象也很有可能是垃圾。

不过这是分代收集必须做出的牺牲。

Comments