Card Table用于标记可能含跨代引用的512字节卡页,写屏障在老年代引用新生代时将其设为dirty,Minor GC仅扫描dirty卡页内对象以识别真实跨代引用。

Card Table 不是用来“定位引用”的,而是用来标记“可能有跨代引用的内存区域”——它本身不存引用地址,只做粗粒度脏页标记。
Card Table 本质是一页 512 字节的 byte 数组
HotSpot JVM 将老年代(Old Generation)按 512 字节为单位划分为若干“卡页(Card Page)”,每页对应 Card Table 中的一个 byte 元素。初始值全为 0(clean),一旦某卡页内发生“老年代对象写入引用指向年轻代对象”的操作,写屏障(Write Barrier)就会将对应位置设为 1(dirty)。
这意味着:
-
Card Table不记录具体哪个老年代对象、引用了哪个年轻代对象,只记录“第 N 个 512 字节块里,可能有跨代引用” - Minor GC 时,JVM 只需扫描所有
dirty卡页内的对象,检查其字段是否真的引用了年轻代——这比遍历整个老年代快几个数量级 - 卡页大小不可配置,硬编码为 512 字节(
CardTable::card_size),与平台无关
写屏障是触发 Card Table 更新的关键环节
每次执行类似 obj.field = youngObj 这样的赋值,且 obj 在老年代、youngObj 在年轻代时,JVM 会在字节码层面插入写屏障逻辑(如 store_check),完成两件事:
- 计算
obj所在地址对应的卡表索引:(uintptr_t)obj >> 9(右移 9 位即除以 512) - 将
Card Table中该索引位置设为0xff(或任意非零值) - 注意:写屏障只对“老 → 年轻”的写入生效;年轻代内部、老代内部、年轻 → 老代的写入不会触发卡表更新
没有写屏障,Card Table 就是废表——这也是为什么自定义 JNI 或 Unsafe 直接绕过 JVM 内存模型写入时,可能导致跨代引用漏标、GC 错误回收。
Remember Set(记忆集)才是真正的引用索引,但它依赖 Card Table 构建
Remember Set(RSet)才是真正记录“哪些老年代对象引用了本区域对象”的数据结构,但它的构建不是实时的,而是异步的:
- Refinement 线程定期从脏卡队列(Dirty Card Queue)中取出已标记的卡页
- 逐个扫描这些卡页中的对象,用 OopMap 快速识别出哪些字段是跨代引用
- 把源对象(老年代)和目标区域(如某块 Survivor)的关系登记进对应区域的
Remember Set - 所以 Minor GC 开始前,GC Roots 实际包含:传统根 + 所有年轻代区域对应的
Remember Set条目
换句话说:Card Table 是“线索生成器”,Remember Set 是“线索汇总表”。跳过前者直接维护后者,内存开销和写入延迟都不可接受。
容易被忽略的三个实际影响点
真实调优或排查时,这几个细节常被误读:
- 卡表本身不参与 Stop-The-World 阶段,但 Refinement 线程处理脏卡会竞争 CPU,高并发写场景下可能拖慢 GC 准备速度
-
-XX:+UseCondCardMark可避免重复写卡(仅当原值为 clean 时才设 dirty),但在 CMS/G1 中默认启用,ZGC/Shenandoah 则完全不用卡表机制 - 使用
Unsafe.putObject或 JNISetObjectField绕过 Java 层赋值时,写屏障不触发——若涉及跨代引用,必须手动调用BarrierSet::write_ref_field_pre等等(极少见,通常意味着你在写 JVM 级代码)
真正理解 Card Table,不是记住“它怎么工作”,而是清楚它在哪一级妥协:用空间换时间(少量内存存 byte 数组),用精度换吞吐(只标页不标对象),用异步换低延迟(Refinement 延后处理)。这些取舍,今天仍刻在 G1 和 ZGC 的设计基因里。

















