在分代垃圾回收器中,跨代引用是指老年代对象直接持有对新生代对象的引用。当业务代码在老年代对象上频繁修改字段,使其指向新分配在新生代的实例时,虚拟机必须记录这种引用关系,否则Young GC可能错误回收被老年代引用的年轻对象。记录机制的核心就是卡表(Card Table)。

一、卡表与写屏障的基本原理
卡表本质上是一个字节数组,堆空间被划分为固定大小的卡页(Card Page,通常512字节到几KB不等)。每当发生一次对象引用字段的写操作,且写屏障(Write Barrier)判断被修改对象属于老年代、新引用目标可能位于新生代时,就会把该对象所在卡页对应的卡表项标记为“脏”(dirty,一般置为0x01)。这样在Young GC时,回收器不需要扫描整个老年代,只需筛选出脏卡对应的那部分老年代区域,将其作为GC Roots的一部分。
从实现角度看,写屏障是一段插入到字段赋值指令周围的机器码。以HotSpot为例,在解释器、C1、C2编译后的代码中都会埋入该逻辑。看似只多了一条“算卡页+写字节”的指令,但在高并发、高频赋值的场景下,这种额外开销会被放大。下面的伪代码展示了写屏障的核心逻辑:
// 假设 cardTable 是字节数组,object 是被修改的老年代对象
// fieldOffset 是字段偏移,newValue 是新引用
void oop_store(oop object, oop newValue) {
// 正常写入引用
*field_addr(object, fieldOffset) = newValue;
// 写屏障:标记卡表
int card_index = ((uintptr_t)object) >> CARD_SHIFT; // CARD_SHIFT对应卡页大小
if (cardTable[card_index] != DIRTY) {
cardTable[card_index] = DIRTY;
}
}
这段代码虽然简短,但包含了一次移位、一次数组访问和一次条件写。如果老年代中成千上万个对象每秒都在改引用,光是卡表维护本身就会吃掉可观的CPU周期。更重要的是,卡表项虽小,却分布在连续内存中,容易引发后续要讲的缓存一致性问题。
二、频繁跨代更新带来的直接性能损耗
当老年代频繁更新变量引用新生代对象时,会产生大量脏卡。在每次Young GC之前,GC线程必须遍历卡表,找出所有脏卡,并扫描这些卡页覆盖的老年代内存块,把其中的引用加入到标记栈。脏卡越多,需要扫描的老年代范围就越大,Young GC的停顿时间就越长。这种损耗和老年代“改引用”的频次成正比,而不是和老年代总大小成正比。
我们可以通过一个简单的对比来理解。假设老年代有8GB,但只有100MB的区域在频繁改引用,那么正常情况下脏卡只覆盖这100MB,Young GC扫描量很小;但如果代码写出bug,在老年代缓存里每个请求都new一个对象并塞进老年代容器的字段,导致几GB区域持续变脏,Young GC就不得不扫描这几GB,停顿可能从几毫秒涨到几十毫秒。下面的示例展示了一个容易引发问题的写法:
class OldObject {
// 这个字段在老年代对象中,被频繁指向新新生代对象
public Object ref;
}
// 错误示范:每次调用都新建对象并赋值给老年代对象字段
OldObject old = getFromOldGen();
for (int i = 0; i < 100000; i++) {
old.ref = new YoungObject(i); // 触发写屏障,持续产生脏卡
use(old.ref);
}
上面的循环在极短时间内让同一个卡页反复变脏(甚至不同卡页),不仅增加了写屏障执行次数,还让Young GC的卡表扫描阶段负担加重。如果系统对延迟敏感,这种写法会直接导致P99响应时间劣化。优化方向通常是避免在老年代长期对象上高频挂载短命对象,或改用线程本地分配、弱引用等结构。
三、卡表伪共享引发的隐性开销
除了扫描成本,卡表还有一个容易被忽视的问题:伪共享(False Sharing)。由于卡表是连续的字节数组,相邻卡页的标记字节很可能位于同一个CPU缓存行(通常64字节)。当多个线程同时修改不同老年代对象、但这些对象恰好落在同一缓存行的不同卡页时,CPU缓存一致性协议会让该缓存行在多个核心间反复失效和同步,导致写屏障的实际开销远超预期。
HotSpot在某些版本中引入了“卡表屏障批处理”或“卡表缓存行填充”的优化,但应用层仍可通过降低并发改引用的冲突来缓解这个问题。例如,将频繁变动的引用集中到少数卡页(通过对象布局优化),或者调大卡页尺寸,使单次写操作覆盖的粒度变粗、减少总卡页数。下面的表格对比了不同卡页大小对伪共享的影响:
| 卡页大小 | 卡表总项数 | 伪共享概率 | 单次扫描粒度 |
|---|---|---|---|
| 512字节 | 很大 | 高 | 细,扫描灵活 |
| 2KB | 中等 | 中 | 适中 |
| 4KB | 较小 | 低 | 粗,可能多扫无用对象 |
从表中可以看出,调大卡页能降低伪共享,但会让每次Young GC扫描稍多的老年代空间,属于典型的时空权衡。实际调优时要结合应用的引用更新分布来决定,而不是盲目跟风修改参数。
四、定位与缓解跨代引用损耗的实践思路
要确认系统是否受卡表维护拖累,可以先开启GC日志,观察Young GC的“GC Roots扫描”或“Card Table Scan”阶段耗时。如果这部分时间随业务压力线性增长,且老年代并无大量存活对象晋升,就大概率是频繁跨代写导致。进一步可用JFR或async-profiler抓取写屏障相关栈,看哪些方法在反复给老年代字段赋值。
缓解手段首先是代码层重构:把“老年代对象持有新生代短命对象”的模式改成“新生代对象反向引用老年代配置”,或使用WeakReference让GC自行回收而不必记入卡表强引用。其次是JVM层调优,比如选择G1或ZGC等更现代的回收器,它们用不同的记忆集(Remembered Set)实现,对跨代写的处理粒度不同。最后才是调整卡页尺寸等底层参数。示例中使用弱引用避免脏卡:
class OldCache {
// 用弱引用持有,不会强制记录跨代强引用
private WeakReference<YoungObject> weakRef;
public void setTemp(YangObject y) {
weakRef = new WeakReference<YoungObject>(y);
}
}
通过上述方式,老年代到新生代的引用不再经由卡表强追踪,写屏障不会被触发,从而彻底规避了这部分性能损耗。当然,弱引用带来的生命周期管理复杂度需要业务自行权衡。
五、总结与架构思考
跨代引用本身不是问题,问题在于“频繁更新”这一行为放大了卡表的写屏障成本和扫描成本,并在多核下引入伪共享。理解卡表的工作机制后,架构上应尽量让长期存活的对象保持稳定引用,把易变、短命的数据放在新生代内部互相引用,或借助更合适的引用类型与回收器来卸掉记忆集负担。
在系统设计时,如果把缓存、连接池等本应稳定的老年代组件设计成“每条请求都换一次内部引用”,就等于主动制造跨代写风暴。把这类热点的可变性下沉到线程本地或新生代临时对象中,往往比事后调GC参数更有效。性能优化从来不只是改几个启动选项,而是从对象生命周期与代际关系上做减法。