在HotSpot虚拟机中,堆内存通常被划分为新生代(Young Generation)和老年代(Old Generation)。新生代又分为Eden区和两个Survivor区(From/To)。这种分代设计基于"弱代假说"(Weak Generational Hypothesis)——绝大多数对象都是朝生夕死的,存活时间长的对象会逐渐晋升到老年代。
跨代引用(Inter-generational Reference)指的是老年代对象持有对新生代对象的引用,或者新生代对象引用老年代对象。这种引用关系会导致一个关键问题:在进行Minor GC(只回收新生代)时,为了确定新生代对象是否存活,必须扫描整个老年代来确认引用关系,这显然会极大降低GC效率。
实际统计表明,在大多数Java应用中,老年代对新生代的引用只占老年代对象的1%-5%。为这少量引用扫描整个老年代显然不划算。
Card Table是HotSpot解决跨代引用问题的核心数据结构,其核心思想是将老年代空间划分为固定大小的"卡片"(Card),通常每个卡片对应512字节的内存区域。通过维护这些卡片的脏标记(Dirty)来避免全量扫描。
在JVM源码中(以OpenJDK为例),Card Table的相关定义如下:
cpp复制// hotspot/share/gc/shared/cardTable.hpp
class CardTable: public CHeapObj<mtGC> {
friend class VMStructs;
protected:
// 卡片大小,默认为512字节
static const int _card_shift = 9;
static const int _card_size = 1 << _card_shift;
// 卡片标记值
static const int _clean_card = 0;
static const int _dirty_card = 1;
// 实际存储卡片标记的字节数组
volatile jbyte* _byte_map;
};
JVM通过写屏障技术来维护Card Table的准确性。当程序修改对象引用字段时,会触发以下伪代码逻辑:
java复制void oop_field_store(oop* field, oop new_value) {
*field = new_value; // 正常的引用更新
// 写屏障逻辑开始
if (current_object.in_old_gen() && new_value.in_young_gen()) {
card_table.mark_card(current_object);
}
// 写屏障逻辑结束
}
在HotSpot中,写屏障的具体实现依赖于CPU架构。以x86为例,其汇编实现会内联到生成的机器码中:
assembly复制; 引用字段更新指令
mov [rdi+offset], rax
; 写屏障逻辑
cmp [rbx+age], OLD_GEN
jl skip_barrier
test rax, YOUNG_MASK
jz skip_barrier
; 标记卡片逻辑
mov rcx, [rdi]
shr rcx, CARD_SHIFT
mov byte [card_table + rcx], DIRTY
skip_barrier:
当发生Minor GC时,GC线程会:
cpp复制// hotspot/share/gc/parallel/psCardTable.cpp
void CardTable::scan_dirty_cards(HeapWord* start, HeapWord* end) {
for (HeapWord* addr = start; addr < end; addr += _card_size) {
if (is_dirty(addr)) {
oop obj = cast_to_oop(addr);
scan_object(obj); // 扫描该对象引用的新生代对象
}
}
}
在GC完成后,JVM采用两种策略清理卡片标记:
选择策略时需要在标记精度和清理开销之间权衡。精确清理能减少下次GC的工作量,但会增加本次GC的停顿时间。
通过JVM参数-XX:CardTableEntrySize可以调整卡片大小(必须是2的幂):
在内存大于32GB的机器上,建议使用
-XX:CardTableEntrySize=1024来减少Card Table的内存占用(约占总堆的0.1%)
添加-XX:+G1PrintHeapRegions参数可以输出跨代引用统计信息:
code复制[GC ref stats: 0.1 ms]
Eden regions: 100->100
Survivor regions: 10->10
Old-gen regions with young refs: 42 (avg cards: 3.7)
Young-gen regions with old refs: 15 (avg cards: 1.2)
问题1:GC日志中出现大量"Scanning cards of dirty regions"耗时
可能原因:
解决方案:
-XX:CardTableEntrySize问题2:Card Table内存占用过高
计算公式:
code复制Card Table大小 = 堆大小 / 卡片大小 * 1字节
例如32GB堆,512字节卡片:
code复制32GB / 512B = 64MB Card Table
优化方案:
G1 GC不再使用全局Card Table,而是为每个Region维护一个Remembered Set(RSet)。相比Card Table:
优势:
劣势:
ZGC采用完全不同的方案——在指针元数据中标记引用关系。其优势是:
但需要64位指针和特定的硬件支持(如x86的多级页表)。
监控指标:
sun.gc.cardTable.scanDuration:卡片扫描耗时sun.gc.cardTable.dirtyCards:脏卡片数量参数调优组合:
bash复制# 针对写密集型应用
-XX:CardTableEntrySize=256 -XX:+UseCondCardMark -XX:CardTableRSShift=4
# 针对大内存应用
-XX:CardTableEntrySize=1024 -XX:CardTableRegionSize=2M
代码优化模式:
java复制// 避免高频更新老年代对象对新生代的引用
class Cache {
private static final Object[] oldGenCache = new Object[1000];
// 反模式:频繁修改老年代到新生代引用
void update(int index, Object newValue) {
oldGenCache[index] = newValue; // 触发写屏障
}
// 优化方案:批量更新
void batchUpdate(Map<Integer, Object> updates) {
disableCardMarking(); // 伪代码,实际需要JNI调用
try {
updates.forEach((i, v) -> oldGenCache[i] = v);
} finally {
enableCardMarking();
}
}
}
新一代JVM的演进: