深度解析 JVM:为什么 Minor GC 必须依赖 Card Table?
目标读者是:已经了解“堆分代”概念,但对 Minor GC 如何在底层高效运作充满好奇的 Java 开发者。
在几乎所有的 Java 面试中,当我们谈论 JVM,我们都会提到“分代收集”。我们都背过这个标准答案:对象在 Eden 区出生,经过 Minor GC 存活的进入 Survivor 区,熬过(默认 15 次)GC 的对象进入老年代。
这个模型的核心优势在于实现了“分代假说”——绝大多数对象朝生夕死。因此,JVM 可以非常频繁地执行 Minor GC,只回收年轻代(这个区域很小),这个过程(理论上)非常快。而只在万不得已时,才执行 Full GC(STW 时间很长)。
这个模型听起来天衣无缝。直到你提出那个“灵魂拷问”:
Minor GC 在只扫描年轻代的前提下,如何处理“老年代对象”对“年轻代对象”的引用?
如果你不处理这个引用,一个被老年代对象(比如一个全局 Cache)引用的年轻代对象将被错误地当作垃圾回收,系统瞬间崩溃。但如果你去处理这个引用,你就必须去扫描整个老年代——这使得 Minor GC 和 Full GC 一样慢,“分代收集”的优势将荡然无存。
这是一个致命的悖论。而 JVM 用来解开这个死结的“银弹”,就是我们今天要深入探讨的机制——Card Table(卡片表)。
常见的误区:GC Roots 列表就够用了吗?
在我们深入 Card Table 之前,让我们先破除一个常见的误解。
有人会说:“GC Roots 不是包含了‘静态变量’吗?静态变量(通常在老年代或方法区)如果引用了年轻代对象,GC 扫描它们不就行了?”
这个想法只对了一半,但它忽略了最致命的场景:间接引用。
请看这个场景:
一个静态变量 (GC Root) -> 指向 -> 老年代对象A -> 指向 -> 年轻代对象B
当 Minor GC 启动时,它从 GC Root(静态变量)出发。当它走到“老年代对象 A”时,它发现“A”是一个老年代对象。由于 Minor GC 的职责不是回收老年代,它的扫描会在此处停止。它根本不会继续深入扫描 A 对象的字段。
其结果是: “年轻代对象 B” 丢失了。它被错误地判定为“不可达”,并在这次 Minor GC 中被回收。灾难发生了。
因此,Minor GC 必须有一个额外的信息来源,来告诉它:“嘿,你不能只看 GC Roots 列表,在庞大的老年代中,还隐藏着一些对象(比如对象 A),你也必须把它们当作临时的 Root 来扫描!”
Card Table:那个“额外的信息来源”
Card Table 就是那个“额外的信息来源”。你不需要把它想得太复杂,它在物理上非常简单:
Card Table 本质上就是一个字节数组(byte[]
)。
它的工作逻辑是:
- 映射(Mapping):JVM 将整个老年代的内存空间,划分为N个固定大小(例如 512 字节)的逻辑区域,这个小区域被称为 “卡页”(Card Page)。
- 索引(Indexing):Card Table(那个
byte[]
数组)中的每一个元素(一个 byte),都一对一地映射到一个“卡页”。CardTable[0]
对应老年代 0~511 字节。CardTable[1]
对应老年代 512~1023 字节。- ...以此类推。
- 状态(State):这个 byte 的值代表其对应的“卡页”的状态。我们只需要两个状态:“干净”(Clean)和“脏”(Dirty)。
- 干净 (值为 0):代表这个 512B 的卡页中,没有任何对象引用了年轻代对象。(或者说,自上次 Minor GC 以来,没有发生过变化)。
- 脏 (值为 1):代表这个 512B 的卡页中,至少有一个对象的字段在最近被修改过,它**“可能”** 包含一个指向年轻代的引用。
核心机制:Write Barrier (写屏障)
你肯定会问:JVM 是如何知道一个卡页是“干净”还是“脏”的?它总不能在 Minor GC 的时候再去扫描一遍吧?
答案是:JVM 不是在 GC 时(被动)去查找,而是在引用发生变化时(主动)去记录。这个“主动记录”的动作,被称为 “写屏障” (Write Barrier)。
“写屏障”是 JIT(即时编译器)在编译 Java 代码时,自动(像 AOP 一样)注入到“引用赋值”操作之后的一小段机器码。
让我们回到那个经典的赋值场景:
// 假设 myOldObject 此时在老年代
// 假设 newYoungObject 刚刚被 new 出来,在年轻代
myOldObject.someField = newYoungObject;
当 JVM(JIT 编译后)执行这行代码时,它实际执行了两步:
- 赋值:执行
putfield
字节码,将newYoungObject
的地址赋给someField
。 - 写屏障触发:紧接着执行 JIT 注入的“写屏障”代码。
这段“写屏障”代码的逻辑(简化后)如下:
// (伪代码) 写屏障逻辑
void post_write_barrier(Object obj_being_written, Object new_value) {
// 1. 检查被赋值的对象是否在老年代
if (obj_being_written.isInOldGen()) {
// 2. 如果是,计算它在老年代的地址属于第几个“卡页”
int card_index = calculate_card_index(obj_being_written.address());
// 3. 标记 Card Table 数组,把这个卡页设为 "Dirty"
CardTable[card_index] = DIRTY_VALUE (e.g. 1);
}
// 注意:为了速度,写屏障通常是“保守”的。
// 它甚至不去检查 new_value 是不是真的在年轻代。
// 只要一个老年代对象的引用字段被“写”了,它就“宁可错杀,不可放过”,
// 直接标“脏”。这比在运行时做复杂判断要快得多。
}
决战时刻:Minor GC 的完整根集合
有了 Card Table 和写屏障的帮助,Minor GC 的“悖论”被完美解开。
当一次 Minor GC 启动时(STW 开始),它的“GC Roots 集合”现在由两部分构成:
- Set A:标准的 GC Roots 列表:
- 栈帧中的局部变量。
- JNI 引用。
- 类的静态字段。
- Set B:Card Table 中的所有“脏卡”:
- GC 不再扫描整个老年代。
- 它转而去扫描那个小得多的 Card Table 字节数组。
- 它找到所有值为 1(Dirty)的条目,并将这些条目对应的“卡页”(那 512B 内存) 中的所有对象,全部加入到 GC Roots 集合中,进行扫描。
通过这种方式,我们文章开头提到的那个 老年代对象A -> 年轻代对象B
的引用链就被找到了。因为在赋值发生时,“写屏障”早已把“老年代对象 A”所在的卡页标记为了“脏”。Minor GC 通过 Card Table 扫描,精准地把“老年代对象 A”也纳入了扫描范围,从而保护了“年轻代对象 B”不被回收。
总结
Card Table 是 JVM 设计中一个极其精妙的权衡(Trade-off)。
- 它付出的代价:增加了应用程序运行时的开销。每一次引用赋值(
putfield
),都可能要多执行几条“写屏障”的指令。 - 它换来的收益:它将 Minor GC 从“必须扫描整个老年代”的灾难中拯救出来,使其可以只扫描极小的 Card Table 和少数“脏页”。这是 Minor GC 得以实现“快速、频繁”执行的基石。
最后,这个机制也是 G1 收集器中 RSet (Remembered Set) 的基础。G1 只是把这个思想用得更极致,它为每一个 Region 都维护了一个 RSet(而 RSet 的数据来源,正是 Card Table 和写屏障),从而实现了对单个 Region 的独立回收。
希望这篇解析能让你对 JVM 的运行原理有更深的理解。