卡表是JVM中用于加速Minor GC时老年代扫描的索引结构,将老年代划分为512字节卡页并用字节数组标记脏卡,避免全堆扫描;它默认启用,不需手动开启,真正影响停顿的是其使用方式及新生代配置合理性。

卡表不是开关,是老年代扫描的“索引簿”
卡表(Card Table)本身不直接减少停顿时间,它只是让老年代在 Minor GC 时,能快速定位“哪些老年代对象引用了新生代”,从而避免全堆扫描。如果你误以为开启某个参数就能启用卡表、进而“加速 GC”,那就踩坑了——JVM 在所有分代回收器(Parallel、G1、CMS)中默认就用卡表,根本不需要手动打开。
真正影响停顿的,是卡表如何被使用:CMS 依赖它做并发标记阶段的增量更新;G1 用的是更细粒度的 Remembered Set(记忆集),但底层仍借鉴卡表思想;而 Parallel GC 虽用卡表,却只在 Full GC 前做一次粗略扫描,对 Minor GC 停顿几乎无优化作用。
为什么调大 -XX:MaxNewSize 可能让卡表失效
卡表把老年代按 512 字节(典型值,取决于 JVM 版本和平台)划分为一个个“卡页(card)”,每个卡页对应一个字节的卡表项。当老年代中某个对象修改了对新生代的引用(比如往 ArrayList 里 add 了一个新对象),JVM 就会把对应卡页的标记位设为 dirty——这个过程叫“写屏障(write barrier)”。但如果新生代过大(例如 -Xmn4g),Minor GC 频次下降,但每次晋升对象暴增,导致大量老年代对象突然开始引用新生代对象,卡表 dirty 位爆炸式增长。下一次 Minor GC 时,GC 线程必须遍历全部 dirty 卡页,扫描其中的老年代对象,反而拖慢 STW 时间。
- 现象:
ParNew日志中出现大量[CardTableEntryClosure: 123456 cards],且young-gc耗时从 30ms 跳到 120ms - 对策:保持
-Xmn在合理范围(如堆的 1/4~1/3),宁可稍增 Minor GC 频率,也要控制单次晋升量 - 验证方式:加
-XX:+PrintGCDetails -XX:+PrintGCTimeStamps,观察每次ParNew后的 card 处理数量是否稳定
-XX:+UseCondCardMark 不是性能开关,而是写屏障的省电模式
默认写屏障会无条件把卡页标记为 dirty,哪怕那个字段本来就没变。而 -XX:+UseCondCardMark 让 JVM 先比较旧值和新值,只在确实发生引用变更时才 dirty 卡页。听起来很美?但它只在特定场景生效:
- 适用:老年代对象长期持有弱变化引用(如缓存容器偶尔更新 entry)
- 风险:增加每次写操作的 CPU 开销(一次 compare-and-swap);在高并发写密集场景(如高频
ConcurrentHashMapput),可能反而抬高延迟 - 注意:JDK 8u281+ 默认启用该优化,无需显式配置;JDK 17+ 已移除此参数,由 JVM 自动决策
真正降低老年代停顿,得绕开卡表依赖
卡表本质是“分代假设”的补丁机制——它解决的是“老→新”引用漏扫问题,但无法缓解老年代自身碎片化、对象存活率高、回收算法重等根源问题。当发现 Full GC 或 Concurrent Mode Failure 频发,别在卡表上死磕:
- 先确认是否真因老年代压力大:用
jstat -gc <pid></pid>看OU(Old Used)是否持续 >90%,且OC(Old Capacity)没明显增长 - 若确认是老年代膨胀,优先检查对象晋升逻辑:
-XX:PretenureSizeThreshold是否误配导致大对象直入老年代;-XX:MaxTenuringThreshold是否过低,让本该在 Survivor 区自然死亡的对象提前晋升 - 对 JDK 11+ 应用,直接切
-XX:+UseZGC或-XX:+UseShenandoahGC:它们不用卡表,靠读屏障(read barrier)实现并发标记,老年代停顿可压到 10ms 内
卡表是幕后苦力,不是指挥官。它工作得好,你感觉不到;它被滥用,你才会看到 GC 日志里那一长串 card 扫描耗时——而这时,问题往往早就不在卡表本身了。

















