卡表是JVM为优化跨代引用扫描而设计的轻量级数据结构,通过写屏障标记脏卡页,使Minor GC仅扫描可能含跨代引用的卡页。

分代收集本身依赖“对象大多短命”这个观察,但老年代对象引用年轻代对象的情况确实存在。如果不加处理,每次 Minor GC 都得扫描整个老年代来找这些跨代引用,GC 停顿会急剧拉长——尤其当老年代动辄几百 MB 甚至几 GB 时,这完全不可接受。JVM 用一套轻量、低开销的机制解决了这个问题,核心是避免全量扫描。
卡表(Card Table)是实际落地的数据结构
HotSpot JVM 把老年代按固定大小切分成若干“卡页”(Card),每页通常为 512 字节(29 字节)。卡表就是一个字节数组,每个元素对应一个卡页,初始值为 0(clean)。当某次写操作让老年代对象持有了对年轻代对象的引用,JVM 就通过写屏障(Write Barrier)把对应卡页在卡表中的位置标记为 1(dirty)。Minor GC 时,JVM 只需遍历卡表中所有 dirty 的条目,定位到对应的卡页,再扫描这些卡页里的对象,检查它们是否引用了年轻代对象。
- 卡表本质是空间换时间:用极小内存(比如 1GB 老年代仅需约 2MB 卡表)换取扫描范围大幅缩小
- 它不是精确记录“哪个对象引用了谁”,而是粗粒度地标记“哪块内存里可能有跨代引用”
- 卡页大小是权衡结果:太小 → 卡表膨胀、维护开销大;太大 → 扫描冗余多、精度下降
写屏障确保卡表实时更新
卡表不会自己变 dirty,必须靠写屏障在关键时机介入。每当执行类似 oldObj.field = youngObj 这样的赋值操作,JVM 会在赋值指令前后插入一小段额外逻辑(即写屏障),判断右侧引用是否指向年轻代,且左侧对象是否在老年代。满足条件就标记对应卡页为 dirty。
- 写屏障分为两类:增量式(Incremental)和屏障式(Barrier),HotSpot 主要用后一种
- 它带来轻微性能损耗,但远小于全堆扫描的代价,属于可接受的 trade-off
- 现代 JVM 对写屏障做了大量优化,比如内联、批量处理,进一步压缩开销
记忆集(Remembered Set)是更通用的抽象概念
卡表其实是记忆集的一种具体实现。记忆集定义上就是“记录从非收集区指向收集区的所有引用”的数据结构。在 G1 收集器中,记忆集升级为更精细的 per-region RSet(Remembered Set),每个 Region 都维护自己的 RSet,记录哪些其他 Region 有指向它的引用。这种设计支持并发标记和混合回收,但实现更复杂、内存占用更高。
- 卡表适合年轻代回收场景,简单高效
- RSet 适合区域化收集器(如 G1),支持更灵活的回收粒度
- 两者目标一致:把跨代引用的查找范围从“全老年代”收缩到“少量脏卡/少量 Region”
跨代引用假说支撑了整个优化逻辑
这套机制能成立,关键在于一条经验性假设:跨代引用数量远少于同代引用。如果老年代频繁地、大量地引用年轻代对象,卡表里 dirty 卡会越来越多,最终退化成近似全扫。现实中绝大多数业务符合该假说——比如 Spring Bean 是长期存活的老年代对象,但它引用的 DTO、VO 等临时对象往往也在老年代;真正跨代引用多的场景(如缓存池、对象池)可通过调优或代码规避。
- 避免在老年代集合中反复添加年轻代新对象(如示例中向老年代 ArrayList 大量 add 同一个年轻代对象)
- 减少长生命周期对象对短生命周期对象的强持有
- 必要时启用 -XX:+UseCondCardMark 等参数,避免重复标记已 dirty 的卡

















