卡表是HotSpot VM用byte[]实现的脏页地图,将老年代每512字节划为一卡,标记跨代引用;Minor GC仅扫描脏卡,避免全量遍历老年代,大幅降低STW停顿。

卡表到底是什么,为什么Minor GC离不开它
卡表不是Java代码能碰的API,而是HotSpot VM内部用byte[]实现的一张“脏页地图”——把老年代按512字节切分成一个个“卡(Card)”,每张卡对应1字节标记位。只要老年代某块内存里有字段指向年轻代对象,JVM就通过写屏障把对应卡位设为1(即“脏卡”)。Minor GC时,GC线程只扫描这些脏卡里的对象,跳过整块干净的老年代。
不靠它的话,每次Minor GC都得遍历几百MB甚至几GB的老年代,停顿直接从几毫秒飙到几百毫秒。这不是理论风险,是线上真实发生过的GC抖动根源。
写屏障怎么触发卡表更新,哪些赋值会进检查
所有对象字段赋值,比如obj.field = new_obj,JIT编译后都会插一段写屏障逻辑。它不做全量判断,只快速比对new_obj的内存地址是否落在年轻代区间内——是,就计算card_index = (uintptr_t)new_obj >> 9(右移9位等价于除以512),再把card_table[card_index]置为1。
- 仅对堆内对象引用生效;局部变量、栈上对象、常量池引用不触发
-
static字段赋值也会触发,因为类静态区属于“非收集区域”,可能跨代引用年轻代 - G1中每个Region有自己的Remembered Set,底层仍依赖卡表做初始筛选,但多了一层哈希索引加速
卡表大小和stride配置不当,反而拖慢GC
卡表本身是紧凑的byte[],但扫描方式影响实际耗时。HotSpot默认一个stride含256张卡(即128KB内存),GC线程按stride批量扫描。这里容易踩两个坑:
-
-XX:+UseCondCardMark开启后,只有卡原本是0才写1,避免重复写导致缓存失效;但CMS下该选项可能漏标,慎开 - 调大
-XX:ParGCCardsPerStrideChunk=512看似吞吐更好,但单次stride扫描时间变长,STW波动加大;小堆建议保持默认,大堆(>8GB)可试减半 - 卡表映射靠位运算硬编码,改不了卡大小(固定512B),别试图用JVM参数调整
CardSize
看得到卡表状态吗?用jstat和-XX:+PrintGCDetails定位脏卡压力
你没法直接打印卡表内容,但能间接观察它的负载。启动时加-XX:+PrintGCDetails -XX:+PrintGCTimeStamps,Minor GC日志里会出现类似[CardTable: 1245K->1245K(1245K)]的行——括号内是卡表总大小,箭头前后是本次扫描的脏卡数据量。
更准的方法是用jstat -gc <pid>看YGC次数和YGCT时间,再结合-XX:+PrintAdaptiveSizePolicy看JVM是否频繁因脏卡过多而扩大年轻代——这说明老年代正高频修改引用,比如缓存框架大量更新value指向新创建的DTO对象。
真正难调试的是跨代引用“藏得深”:比如Spring Bean里一个@PostConstruct方法往全局Map put了刚new的对象,这个引用链在GC Roots里不可见,全靠卡表兜底。这种场景下,光看堆dump找不到根因,必须结合卡表扫描耗时反推。

















