跨代引用是分代垃圾收集中必须高效解决的关键问题。下面通过一个具体例子,详细说明 卡表(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_shiftcard_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 时如何使用卡表
- 遍历卡表:Minor GC 开始时,遍历整个卡表数组,找出所有值不为 0 的脏卡。
- 扫描脏卡区域:对于每个脏卡,计算出该卡在老年代中对应的内存地址范围,然后扫描这块区域内的所有对象,找出其中指向年轻代的引用。
- 加入 GC Roots:将这些引用作为额外的 GC Roots,与常规的栈、静态变量等一起,用于标记年轻代中的存活对象。
- 重置卡表:处理完所有脏卡后,通常会将脏卡重置为干净状态(0),以便下一次记录。有些收集器可能会延迟重置,或采用其他策略。
四、具体例子演示
假设堆布局如下:
- 老年代起始地址:
0x10000000 - 卡大小:512 字节
- 卡表数组:每个元素对应 512 字节的老年代内存
场景:
- 老年代对象
oldObj位于地址0x10001000(该地址属于卡索引(0x10001000 - 0x10000000) / 512 = 8,即卡表第 8 个元素对应的区域)。 - 年轻代对象
youngObj位于年轻代中。 - 程序执行
oldObj.field = youngObj。
步骤 1:写屏障触发
- 检查发现
oldObj在老年代,youngObj在年轻代,符合跨代引用条件。 - 计算
oldObj地址对应的卡索引:card_index = 8。 - 将卡表[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
- 扫描 GC Roots:从常规 GC Roots(栈、静态变量等)开始标记年轻代对象。
- 处理卡表:遍历卡表,发现索引 8 为脏卡。
- 扫描脏卡区域:计算出脏卡 8 对应的老年代地址范围是
[0x10001000, 0x100011FF)。扫描这个范围内的所有对象,找到oldObj,并发现它的field指向youngObj。 - 将
youngObj加入 GC Roots:这样youngObj被标记为存活。 - 重置卡表:将卡表[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 内存管理的重要一步。