Partial GC能快至毫秒级,关键在于卡表仅标记可能含跨代引用的老年代卡页(512字节/页),GC时只扫描dirty卡页而非全老年代;写屏障在老年代对象引用新生代时更新卡表,CMS用增量更新、G1用SATB应对漏标。

为什么 Partial GC 能快到毫秒级?卡表是关键加速器
Partial GC(比如 Minor GC)之所以能控制在毫秒级,根本原因不是它“只扫新生代”,而是它**不扫整个老年代**——但又必须处理跨代引用。卡表就是为解决这个矛盾而生的:它把老年代按 512 字节一块切分成“卡页”(Card),用一个字节数组标记哪些卡页里**可能含有指向新生代的引用**。GC 时只需扫描这些被标记的卡页,而不是遍历全部老年代对象。
卡表本质是个空间换时间的索引结构。它不保证 100% 精确(会有误标),但极大缩小了扫描范围。实测中,一次 Minor GC 对老年代的扫描量通常只有几百 KB 到几 MB,而非整个 GB 级老年代。
卡表怎么被更新?写屏障是幕后推手
卡表不会自动同步,靠的是写屏障(Write Barrier)——每次 JVM 执行类似 obj.field = otherObj 这样的赋值操作时,如果 otherObj 在新生代、而 obj 在老年代,JVM 就会触发写屏障,把 obj 所在卡页对应的位置在卡表中设为 dirty(比如置为 1)。
注意几个关键点:
- 写屏障只在**老年代对象引用新生代对象**时触发,反向不触发
- HotSpot 默认使用的是「后写屏障」(Post-write barrier),开销极小,现代 CPU 流水线可很好掩盖
- 如果关闭写屏障(如用
-XX:-UseCondCardMark),卡表可能漏标,导致 Minor GC 漏掉存活对象,最终引发错误回收
卡表失效的典型场景:CMS 和 G1 的不同应对
卡表不是万能的。当发生并发修改(比如用户线程一边写、GC 线程一边扫描卡表),就可能出现“漏标”。CMS 和 G1 的处理方式直接体现了卡表的局限性:
CMS 使用增量更新(Incremental Update):一旦发现老年代对象新增了对新生代的引用,就把它重新标灰,后续再扫——这会增加 Minor GC 的扫描工作量,但保证不漏。
G1 改用 SATB(Snapshot At The Beginning):在标记开始前拍个快照,之后所有删除的跨代引用都会被记录进队列,最后统一补扫——这降低了扫描实时性压力,但需要额外内存存缓冲区。
所以如果你看到 Minor GC 时间突然变长,先查 GC log 里有没有大量 card table scanning 或 remembered set scanning 耗时,再结合收集器类型判断是卡表膨胀还是写屏障竞争太激烈。
调优时最容易忽略的卡表相关参数
卡表本身不可配置大小,但它的行为受几个关键参数影响:
-XX:+UseCondCardMark:启用条件式卡表标记,避免重复写卡表位(推荐开启)
-XX:MaxGCPauseMillis=200:G1 下该参数会间接影响卡表扫描粒度——目标越短,G1 越倾向减少单次扫描的卡页数,分多轮做
-XX:G1RSetUpdatingPauseTimePercent=10:控制 RSet(Remembered Set,G1 对卡表的增强版)更新占用 STW 时间比例,超限会降频更新,可能导致后续 GC 扫描变重
真正危险的是手动调大 -XX:NewRatio 却没同步观察卡表扫描耗时——新生代变小后,对象更快进入老年代,跨代引用密度上升,卡表 dirty 卡页数可能翻倍,Minor GC 反而更慢。
卡表机制本身不难理解,但它的实际效果高度依赖写屏障的完整性、GC 算法对 dirty 卡的调度策略,以及应用层跨代引用的分布模式。很多“Minor GC 突然变慢”的问题,根源不在新生代对象数量,而在老年代某几个热点类频繁修改指向新生代的字段,把对应卡页反复刷 dirty。










