跨代引用-解决办法

4 阅读8分钟

跨代引用是分代垃圾收集中必须高效解决的关键问题。下面通过一个具体例子,详细说明 卡表(Card Table) 如何作为记忆集的实现,结合写屏障来解决老年代到年轻代的跨代引用问题。


一、跨代引用问题的背景

在分代堆中,年轻代(Young Generation)和老年代(Old Generation)是独立管理的。当进行 Minor GC(只回收年轻代)时,需要准确知道哪些年轻代对象仍然存活。判断一个年轻代对象是否存活,除了看它是否被 GC Roots(栈、静态变量等)直接引用,还需要看它是否被老年代中的对象引用。因为老年代对象可能持有年轻代对象的引用,如果忽略了这些引用,存活对象会被误判为垃圾。

朴素的做法:每次 Minor GC 时扫描整个老年代,找出所有指向年轻代的引用。但老年代通常比年轻代大得多(例如占堆的 2/3),扫描整个老年代会带来巨大的性能开销,使 Minor GC 无法做到“快速”,违背分代设计的初衷。

因此,需要一种机制,只记录那些可能包含跨代引用的老年代区域,在 Minor GC 时只需扫描这些区域,而不必扫描整个老年代。


二、解决方案:记忆集 + 写屏障

2.1 记忆集(Remembered Set)

记忆集是一种抽象数据结构,用于记录从非收集区域(如老年代)指向收集区域(如年轻代)的指针。它不关心具体的指针位置,只记录“某块内存区域存在跨代引用”。

记忆集的实现可以有多种精度(如对象精度、卡精度等)。HotSpot 虚拟机采用 卡精度(Card Precision),具体实现为 卡表(Card Table)

2.2 写屏障(Write Barrier)

写屏障是在每次执行引用类型字段赋值操作时插入的一段额外代码,用于动态维护卡表。当程序修改一个对象的字段时,写屏障会检查这次赋值是否产生了新的跨代引用(即老年代对象引用年轻代对象),如果是,则更新卡表。


三、卡表(Card Table)的工作原理

3.1 基本结构

  • 卡(Card):将老年代内存划分为一个个固定大小的连续区域,每个区域称为一张卡。HotSpot 中,卡的大小默认为 512 字节(可通过参数调整)。
  • 卡表:是一个字节数组(byte[]),数组的每个元素对应一张卡。初始状态下,所有卡的值都是 0(表示干净,即该卡内没有跨代引用)。
  • 映射关系:老年代中任意地址 addr 对应的卡表索引可通过公式计算:
    card_index = (addr - heap_start) >> card_size_shift
    
    其中 card_size_shift 是卡大小对应的位移量(512 字节对应 9 位,因为 2^9 = 512)。

3.2 写屏障的工作流程

当应用程序执行类似 oldObj.field = youngObj 的赋值操作时,JVM 会插入写屏障,执行以下步骤(伪代码):

void write_barrier(oop old_obj, oop young_obj) {
    // 如果 old_obj 位于老年代,并且 young_obj 位于年轻代
    if (is_old_gen(old_obj) && is_young_gen(young_obj)) {
        // 计算 old_obj 所在地址对应的卡索引
        card_idx = card_index_for_address(old_obj);
        // 将卡表对应字节标记为脏(通常设为 1)
        card_table[card_idx] = DIRTY;
    }
    // 执行实际的字段赋值
    *old_obj.field = young_obj;
}
  • 判断条件:通常需要快速判断对象所在的代。HotSpot 通过对象头的某些标志位或通过地址范围判断。
  • 标记为脏:将卡表元素设为非 0 值(例如 1)。脏卡表示该卡覆盖的内存区域中至少存在一个跨代引用

3.3 Minor GC 时如何使用卡表

  1. 遍历卡表:Minor GC 开始时,遍历整个卡表数组,找出所有值不为 0 的脏卡。
  2. 扫描脏卡区域:对于每个脏卡,计算出该卡在老年代中对应的内存地址范围,然后扫描这块区域内的所有对象,找出其中指向年轻代的引用。
  3. 加入 GC Roots:将这些引用作为额外的 GC Roots,与常规的栈、静态变量等一起,用于标记年轻代中的存活对象。
  4. 重置卡表:处理完所有脏卡后,通常会将脏卡重置为干净状态(0),以便下一次记录。有些收集器可能会延迟重置,或采用其他策略。

四、具体例子演示

假设堆布局如下:

  • 老年代起始地址:0x10000000
  • 卡大小:512 字节
  • 卡表数组:每个元素对应 512 字节的老年代内存

场景:

  • 老年代对象 oldObj 位于地址 0x10001000(该地址属于卡索引 (0x10001000 - 0x10000000) / 512 = 8,即卡表第 8 个元素对应的区域)。
  • 年轻代对象 youngObj 位于年轻代中。
  • 程序执行 oldObj.field = youngObj

步骤 1:写屏障触发

  1. 检查发现 oldObj 在老年代,youngObj 在年轻代,符合跨代引用条件。
  2. 计算 oldObj 地址对应的卡索引:card_index = 8
  3. 将卡表[8] 的值设置为 DIRTY(例如 1)。

此时卡表状态:

索引:0  1  2  3  4  5  6  7  8  9 ...
值: 0  0  0  0  0  0  0  0  1  0 ...

步骤 2:后续发生 Minor GC

  1. 扫描 GC Roots:从常规 GC Roots(栈、静态变量等)开始标记年轻代对象。
  2. 处理卡表:遍历卡表,发现索引 8 为脏卡。
  3. 扫描脏卡区域:计算出脏卡 8 对应的老年代地址范围是 [0x10001000, 0x100011FF)。扫描这个范围内的所有对象,找到 oldObj,并发现它的 field 指向 youngObj
  4. youngObj 加入 GC Roots:这样 youngObj 被标记为存活。
  5. 重置卡表:将卡表[8] 重置为 0。

步骤 3:完成 Minor GC

  • 所有被标记的年轻代对象被复制到 Survivor 区或晋升,未被标记的回收。
  • 卡表恢复干净状态,准备记录下一次跨代引用。

五、卡表的优化与注意事项

5.1 伪共享(False Sharing)问题

  • 问题:卡表是字节数组,多个线程可能同时修改不同卡,但这些卡可能位于同一个 CPU 缓存行(通常 64 字节)。当一个线程修改其中一个字节时,会使整个缓存行失效,导致其他线程需要重新加载,降低性能。
  • 解决方案:HotSpot 在实现时,可能通过填充(padding)使每个卡表元素单独占一个缓存行,但这会增加内存占用。更常见的做法是允许一定的伪共享,但通过写屏障的局部性和并发控制来缓解。另外,G1 等收集器使用了更精细的 RSet 和日志记录来减少伪共享影响。

5.2 并发标记时的卡表维护

  • 在并发收集器(如 CMS)中,应用线程和 GC 线程同时运行。CMS 在并发标记阶段也会使用卡表来记录标记期间发生的引用变更。通过写屏障将新产生的跨代引用对应的卡标记为脏,然后在重新标记阶段扫描脏卡,修正标记结果。

5.3 卡表的大小与开销

  • 假设老年代大小为 2GB,卡大小为 512 字节,则卡表需要的空间为 2GB / 512B = 4MB,即 4MB 的字节数组。这个内存开销可以接受。
  • 写屏障的额外指令开销通常很小(约 5% 以内),因为现代 CPU 可以高效处理。

5.4 其他记忆集实现

  • G1 收集器没有使用简单的卡表,而是为每个 Region 维护一个 RSet(Remembered Set),记录哪些其他 Region 引用了本 Region 中的对象。RSet 的粒度更细,可以是卡精度或对象精度,且支持双向记录,以支持 Mixed GC 时快速找到跨 Region 引用。
  • ZGC 使用染色指针和读屏障,基本摆脱了传统的记忆集,但这是另一条技术路线。

六、总结

卡表是解决跨代引用问题的经典且高效的手段:

  • 作用:记录老年代中哪些内存卡可能包含指向年轻代的引用。
  • 核心机制:写屏障在引用赋值时实时标记脏卡;Minor GC 时只扫描脏卡区域,避免全堆扫描。
  • 优点:空间开销小(约老年代的 1/512),时间开销低,实现简单可靠。
  • 局限:存在伪共享问题,但在工程实践中可通过优化缓解。

通过卡表,JVM 成功地在分代收集中兼顾了正确性和性能,使得 Minor GC 能够快速完成,从而维持整个垃圾收集系统的高效运转。理解卡表的工作原理,是深入掌握 JVM 内存管理的重要一步。