导读:本期聚焦于新加坡程序员创作的《如何通过 JVM 的卡表分代扫描机制理解 Partial GC 如何在毫秒级内完成》,敬请观看详情。Minor GC 为什么能在几十毫秒甚至几毫秒内完成回收?关键在于 JVM 并不需要扫描整个老年代来确认新生代对象的存活情况。卡表机制把老年代切分成固定大小的卡页,只要某个卡页内发生跨代引用写入,写屏障就会把对应的卡页标记为脏,回收时只需遍历这些脏卡页即可。本文从分代假说讲起,逐步拆解卡表的数据结构、写屏障的标记过程、脏卡清理流程,并对比 G1 中卡表与 RSet 的演进关系,最后分析伪共享问题与优化手段,帮助你彻底搞懂 Partial GC 的高效原理。

分代收集是现代垃圾回收器的基石设计,其核心假设是绝大多数对象朝生夕死。但分代也带来了一个天然的难题:Minor GC 时,新生代对象可能被老年代对象引用,如果不处理这些跨代引用,就会误判存活对象;如果为此扫描整个老年代,分代的意义又荡然无存。JVM 用一个非常精巧的数据结构解决了这个矛盾,它就是卡表。理解了卡表的工作方式,你就明白了为什么 Partial GC 可以做到毫秒级完成。

如何通过 JVM 的卡表分代扫描机制理解 Partial GC 如何在毫秒级内完成

跨代引用问题:卡表要解决的核心矛盾

在典型的分代布局中,堆被划分为新生代和老年代,新生代又细分为 Eden 区和两个 Survivor 区。对象优先在 Eden 分配,经历若干次回收后仍存活的对象晋升到老年代。回收新生代时,GC 需要回答一个问题:Eden 里那些看似没有任何引用指向的对象,真的没有人引用它们吗?

答案是不一定。老年代中某个对象的字段可能正持有新生代对象的引用。举例来说,一个长期存活的集合对象存放在老年代,程序运行中不断往里 add 新创建的元素,这些元素本身位于 Eden。如果 GC 只扫描新生代的根,就会把这个集合错误地当作不可达对象回收掉,造成致命的程序错误。

最朴素的做法是在每次 Minor GC 时把老年代完整扫描一遍,找出所有指向新生代的引用。假设老年代有 8GB,按每秒几个 GB 的扫描速度估算,仅这一步就可能耗时数秒,完全无法接受。另一种思路是维护一个精确的记忆集,记录每一个跨代引用的确切位置,但这样维护成本极高,每次字段赋值都要更新复杂数据结构,应用吞吐会被严重拖累。

卡表正是在精度和成本之间取平衡的产物:它牺牲一定的位置精度,换来极低的空间占用和极快的更新速度。HotSpot 选择的卡页粒度是 512 字节,也就是说老年代被逻辑上划分成一系列 512 字节的小块,每个块对应卡表中的一个条目。

卡表的数据结构与写屏障标记过程

HotSpot 中卡表本质上是一个字节数组,定义在 CardTable 类中。每个字节对应老年代中一段 512 字节的连续内存区域,这段区域称为一个卡页。字节取值 0 表示干净,表示该卡页内当前不存在已知的指向新生代的引用;取值 1 表示脏,即该卡页内可能存在跨代引用,Minor GC 时需要额外扫描它。

byte[] card_table = new byte[old_gen_size / 512];

// 卡表条目与内存地址的映射关系
// card_index = (object_address - heap_base) >> 9;  // 512 = 2^9

void markCardDirty(Object obj) {
    // 老年代对象 obj 的某个字段被写入了一个新生代引用
    long addr = addressOf(obj);
    int index = cardIndex(addr);
    card_table[index] = 1;  // 标记为脏,DIRTY = 1
}

那么问题来了,JVM 如何知道某次字段赋值是跨代引用?答案是写屏障。写屏障可以理解为 JVM 在字段赋值指令周围插入的一段钩子代码,类似于 AOP 切面。每当一个引用类型字段被赋值时,这段代码就会执行判断逻辑。

在 JDK 7 Update 4 之后,HotSpot 引入了一项重要优化,称为条件写屏障,也就是著名的 G1 卡表压缩优化思路的前身。写屏障并不会判断被写入的引用是否真的指向新生代,而是先检查赋值目标对象本身是否位于老年代。如果目标对象在新生代,它里面的字段就算写入了新生代引用,也不构成跨代引用,直接跳过标记。只有当目标对象位于老年代时,才执行卡表置脏操作。这层判断大幅减少了写屏障的实际开销。

// 伪代码:条件写屏障的逻辑
void writeBarrier(Object target, Field field, Object newValue) {
    // 原始赋值
    field.set(target, newValue);
    // 写屏障部分
    if (inOldGeneration(target)) {
        if (cardIsClean(addressOf(target))) {
            markCardDirty(target);  // 对应卡页置为脏
        }
    }
}

注意上面伪代码中先检查卡页是否干净再置脏的小细节,这是一个经典优化:如果卡页已经是脏的,就没必要重复执行内存写操作。这个看似微不足道的优化,在高并发场景下能显著减少共享内存的写入频率,因为它降低了缓存行在多核之间来回失效的次数。

从空间成本看,卡表的开销极小。512 字节的卡页对应 1 字节的卡表条目,也就是说卡表只占堆大小的约五百分之一。一个 32GB 的老年代,卡表仅占约 64MB,这个代价换来的收益是巨大的。

Minor GC 执行时如何利用脏卡表

理解了卡表的维护过程,再看 Minor GC 的执行流程就清晰了。GC 开始时,首先做一次初始标记,这通常需要借助一次普通检查点或者 STW 暂停。然后执行清点阶段,即扫描卡表,找出所有值为脏的条目,把对应的卡页地址收集成一个待扫描列表。

接下来的扫描阶段是关键:GC 根据根集合(栈变量、寄存器、静态字段等)加上所有脏卡页中的对象字段,作为可达性分析的起点。对于每个脏卡页,GC 只需要扫描其中属于老年代的那些对象的引用字段,看它们是否指向新生代。指向新生代的引用会被记录下来,其目标对象在本轮回收中被视为存活,并复制到 Survivor 区。

这里要强调卡表设计中的一个聪明取舍:脏卡页内可能只有极少几个真正的跨代引用,甚至可能一个都没有(因为卡页可能被重复置脏,之前的跨代引用在后续运行中被覆盖为 null)。但 GC 不在乎这种精度损失,扫描一个 512 字节的卡页最多只需检查几个对象的字段,成本微乎其微。这种宁可多扫不可漏扫的策略,换来了写屏障的极致轻量。

// Minor GC 中的脏卡扫描伪代码
List<Object> scanRoots() {
    List<Object> roots = scanThreadStacksAndStatics();
    for (int i = 0; i < card_table.length; i++) {
        if (card_table[i] == DIRTY) {
            long cardStart = heapBase + ((long) i << 9);
            // 扫描该卡页内对象的引用字段
            scanCardPageForYoungRefs(cardStart, cardStart + 512, roots);
            card_table[i] = CLEAN;  // 处理完重置为干净,供下一轮使用
        }
    }
    return roots;
}

扫描结束后,卡表条目会被重置为干净状态,为下一轮回收做准备。整个流程中,老年代绝大部分内存根本不会被触碰,只有少数脏卡页参与了扫描。假设一次典型的业务运行后只有 2% 的老年代卡页变脏,那么 Minor GC 对老年代的扫描工作量就缩小到了原来的五十分之一,这正是毫秒级停顿的核心来源。

从卡表到 RSet:G1 的演进与伪共享问题

卡表机制并非完美无缺,它有一个典型的硬件层面问题,称为伪共享。现代 CPU 的缓存一致性以缓存行为单位,通常一行 64 字节。卡表是连续的字节数组,连续 64 个卡表条目共享一个缓存行。如果两个线程分别修改的对象恰好落在相邻的不同卡页,它们会频繁写同一个缓存行中的不同字节,导致缓存行在两个核心之间来回传递,性能急剧下降。

JDK 8 引入了一个参数 -XX:+UseCondCardMark,它的思路是在写屏障里增加一次条件判断:只有当卡页当前是干净时才执行置脏写操作。虽然多了一次读操作,但避免了无意义的重复写入,在特定负载下能缓解伪共享。G1 则采用了另一种方案,在卡表初始化时把相邻条目错开到不同缓存行,牺牲一点内存来换取缓存友好性。

G1 还对卡表做了更激进的改造。在 G1 的堆布局中,Region 是回收的基本单位,分代不再由连续地址空间表达。简单的一维卡表无法表达跨 Region 的引用关系,因此 G1 在卡表之上又构建了 RSet,即记忆集。每个 Region 拥有自己的 RSet,其中存储的不是对象指针,而是卡页的索引:当某个 Region 外的对象引用了本 Region 内的对象时,那个外部卡页的编号会被记录到本 Region 的 RSet 中。这样回收单个 Region 时,只需要扫描它的 RSet 中列出的那些卡页,而不必扫描整个堆。

对比一下维护成本的差异:CMS 的卡表在每次 Minor GC 后整体重置,维护简单;G1 的 RSet 需要持续的增量维护,写屏障的逻辑也更复杂,分为前置屏障和后置屏障,后置屏障还会把卡页变更投入队列异步处理。G1 用更高的运行时开销,换来了对任意 Region 独立回收的能力,这是它能把停顿时间控制得更好的根本原因之一。

动手观察卡表行为

理论讲再多,不如实际看一眼。你可以通过 GC 日志中的几个关键指标来观察卡表的工作情况。开启 -XX:+PrintGCDetails(JDK 8)或使用 -Xlog:gc*(JDK 9 及以上)后,CMS 的日志中会出现类似 CMS-remarkYGC 阶段的耗时细分,其中扫描脏卡的时间被单独列出。

另一个实用技巧是观察写屏障本身的开销。你可以写一个简单的基准程序,一个循环只做 long 字段赋值,另一个循环做引用字段赋值且目标对象都在老年代,对比两者的吞吐差异,差值大致就是写屏障加上卡表置脏的成本。在多数场景下这个开销在个位数百分比,但在引用赋值极其密集的极端负载下也可能放大到百分之十几,这也是某些系统选择 ZGC 这类不依赖分代跨代追踪的回收器的原因之一。

最后需要澄清一个常见误解:卡表变脏的数量并不直接等于跨代引用数量。一次对象晋升、一次字段改写都可能让一个卡页变脏,而这个卡页里可能有几十个对象。所以监控脏卡数量时,看到的其实是老年代写活跃度的粗粒度指标,把它当作业务跨代引用强度的信号来分析趋势是可以的,拿来做精确计数则不行。

总结一下,卡表用 512 字节的粒度把老年代的扫描范围压缩到只有真正发生过跨代写入的区域,写屏障以极低成本维护这份索引,Minor GC 借助它把原本可能数秒的全堆扫描压缩到毫秒级。这个设计深刻体现了一个工程原则:在正确性允许的前提下,用可控的精度损失换取数量级的性能提升,值得每一个做系统设计的工程师细细品味。

JVM卡表Partial GC分代扫描修改时间:2026-09-12 02:50:50

免责声明:已尽一切努力确保本网站所含信息的准确性。网站作品多为原创整理与精心创作,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们进行处理Email:chomcom@qq.com。
引用或转载本作品时,请注明当前出处:https://www.ipipp.com/html/20260912/55063.html,基于非商业用途的前提下,欢迎转载或二创本作品。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。