Card Table 是 JVM 用于高效识别老年代中可能含跨代引用的内存区域的机制:将老年代按512字节划分为卡页,每页对应 Card Table 中一个 byte;写屏障在老年代对象引用年轻代对象时将其标记为 dirty;Minor GC 仅扫描 dirty 卡页内对象,避免全老年代扫描。

Card Table 不是“跳过老年代”的开关,而是让 JVM 知道“哪些老年代区域必须扫、哪些可以放心跳过”——它本身不跳过任何东西,只提供脏页索引。
为什么 Minor GC 不能直接跳过整个老年代?
因为老年代对象可能持有对年轻代对象的引用(跨代引用),如果完全跳过,被引用的年轻代对象会被误回收。但全量扫描老年代又太慢——几 GB 老年代逐对象检查字段,STW 时间会从毫秒级飙升到百毫秒以上。
Card Table 的设计目标就是打破这个两难:它不要求知道“谁引用了谁”,只要快速回答“哪片老年代内存里 可能 有跨代引用”。
- 老年代被划分为固定大小的卡页(
CardTable::card_size,硬编码为 512 字节) - 每个卡页对应 Card Table 数组中一个
byte元素,初始值为0(clean) - 只有当老年代对象写入引用指向年轻代对象时(如
oldObj.field = youngObj),写屏障才将对应卡页标记为1(dirty) - Minor GC 前,JVM 只需遍历 Card Table 这个极小数组(比如 1GB 老年代 → 2MB Card Table),找出所有
1的位置,再针对性扫描那些卡页内的对象
写屏障才是 Card Table 生效的前提,没它就等于没表
Card Table 本身是静态结构,不会自动更新。它的全部价值依赖写屏障在运行时拦截并响应跨代写操作。
- 写屏障只对“老年代对象 → 年轻代对象”的赋值生效;
youngObj.field = oldObj或老代内部赋值完全不触发 - JIT 编译和解释执行路径都插入了写屏障逻辑(如
store_check),但 JNI 或Unsafe.putObject直接绕过 JVM 内存模型时,写屏障不生效,可能导致卡页漏标 → 跨代引用丢失 → 年轻代对象被错误回收 - 计算卡表索引用的是地址右移:
(uintptr_t)obj >> 9(等价于除以 512),不是哈希也不是遍历,O(1) 定位
Card Table 和 Remembered Set 是分工关系,不是替代关系
Card Table 是粗筛,Remembered Set(RSet)是精筛。很多资料把两者混为一谈,但实际流程是串行的:
- 写屏障把某卡页标为 dirty → 该卡页进入 Dirty Card Queue
- Refinement 线程异步消费队列,扫描 dirty 卡页内所有对象,用
OopMap快速识别哪些字段真正指向年轻代 → 把源对象(老年代)登记进对应年轻代区域(如 Eden)的 RSet - Minor GC 开始时,GC Roots = 栈/方法区根 + 所有年轻代区域的 RSet 条目;RSet 查找是 O(1) 或 O(log n),比扫描卡页快,但构建成本高
- 所以 Card Table 是“线索生成器”,RSet 是“线索汇总表”;跳过前者直接维护后者,每次跨代写都要更新哈希表,写放大严重,延迟不可控
真正容易被忽略的点在于:Card Table 的粒度(512 字节)决定了它无法避免“假阳性”——一个 dirty 卡页里可能只有 1 个对象有跨代引用,甚至根本没有,但整个 512 字节块都得被扫描。这也是为什么 G1 改用 Region + RSet 细化管理,而 CMS 和 Parallel GC 仍重度依赖 Card Table:它是在写入开销、内存占用、扫描效率之间最务实的平衡点。











