如何通过JVM卡表分代扫描,让Partial GC在毫秒级内完成改写?

2026-04-24 17:192阅读0评论SEO基础
  • 内容介绍
  • 相关推荐

本文共计962个文字,预计阅读时间需要4分钟。

如何通过JVM卡表分代扫描,让Partial GC在毫秒级内完成改写?

卡表本质上是用于空间换时间的索引结构。它不保证100%的精确度(可能会存在错误标记),但极大缩小了扫描范围。在测试中,一次Minor GC对老年代扫描量的通通常只有几百KB到几MB,而非整个GB级的老年代。

卡表怎么被更新?写屏障是幕后推手

卡表不会自动同步,靠的是写屏障(Write Barrier)——每次 JVM 执行类似 obj.field = otherObj 这样的赋值操作时,如果 otherObj 在新生代、而 obj 在老年代,JVM 就会触发写屏障,把 obj 所在卡页对应的位置在卡表中设为 dirty(比如置为 1)。

注意几个关键点:

  • 写屏障只在**老年代对象引用新生代对象**时触发,反向不触发
  • HotSpot 默认使用的是「后写屏障」(Post-write barrier),开销极小,现代 CPU 流水线可很好掩盖
  • 如果关闭写屏障(如用 -XX:-UseCondCardMark),卡表可能漏标,导致 Minor GC 漏掉存活对象,最终引发错误回收

卡表失效的典型场景:CMS 和 G1 的不同应对

卡表不是万能的。当发生并发修改(比如用户线程一边写、GC 线程一边扫描卡表),就可能出现“漏标”。CMS 和 G1 的处理方式直接体现了卡表的局限性:

CMS 使用增量更新(Incremental Update):一旦发现老年代对象新增了对新生代的引用,就把它重新标灰,后续再扫——这会增加 Minor GC 的扫描工作量,但保证不漏。

G1 改用 SATB(Snapshot At The Beginning):在标记开始前拍个快照,之后所有删除的跨代引用都会被记录进队列,最后统一补扫——这降低了扫描实时性压力,但需要额外内存存缓冲区。

所以如果你看到 Minor GC 时间突然变长,先查 GC log 里有没有大量 card table scanningremembered set scanning 耗时,再结合收集器类型判断是卡表膨胀还是写屏障竞争太激烈。

调优时最容易忽略的卡表相关参数

卡表本身不可配置大小,但它的行为受几个关键参数影响:

-XX:+UseCondCardMark:启用条件式卡表标记,避免重复写卡表位(推荐开启)

-XX:MaxGCPauseMillis=200:G1 下该参数会间接影响卡表扫描粒度——目标越短,G1 越倾向减少单次扫描的卡页数,分多轮做

-XX:G1RSetUpdatingPauseTimePercent=10:控制 RSet(Remembered Set,G1 对卡表的增强版)更新占用 STW 时间比例,超限会降频更新,可能导致后续 GC 扫描变重

真正危险的是手动调大 -XX:NewRatio 却没同步观察卡表扫描耗时——新生代变小后,对象更快进入老年代,跨代引用密度上升,卡表 dirty 卡页数可能翻倍,Minor GC 反而更慢。

卡表机制本身不难理解,但它的实际效果高度依赖写屏障的完整性、GC 算法对 dirty 卡的调度策略,以及应用层跨代引用的分布模式。很多“Minor GC 突然变慢”的问题,根源不在新生代对象数量,而在老年代某几个热点类频繁修改指向新生代的字段,把对应卡页反复刷 dirty。

本文共计962个文字,预计阅读时间需要4分钟。

如何通过JVM卡表分代扫描,让Partial GC在毫秒级内完成改写?

卡表本质上是用于空间换时间的索引结构。它不保证100%的精确度(可能会存在错误标记),但极大缩小了扫描范围。在测试中,一次Minor GC对老年代扫描量的通通常只有几百KB到几MB,而非整个GB级的老年代。

卡表怎么被更新?写屏障是幕后推手

卡表不会自动同步,靠的是写屏障(Write Barrier)——每次 JVM 执行类似 obj.field = otherObj 这样的赋值操作时,如果 otherObj 在新生代、而 obj 在老年代,JVM 就会触发写屏障,把 obj 所在卡页对应的位置在卡表中设为 dirty(比如置为 1)。

注意几个关键点:

  • 写屏障只在**老年代对象引用新生代对象**时触发,反向不触发
  • HotSpot 默认使用的是「后写屏障」(Post-write barrier),开销极小,现代 CPU 流水线可很好掩盖
  • 如果关闭写屏障(如用 -XX:-UseCondCardMark),卡表可能漏标,导致 Minor GC 漏掉存活对象,最终引发错误回收

卡表失效的典型场景:CMS 和 G1 的不同应对

卡表不是万能的。当发生并发修改(比如用户线程一边写、GC 线程一边扫描卡表),就可能出现“漏标”。CMS 和 G1 的处理方式直接体现了卡表的局限性:

CMS 使用增量更新(Incremental Update):一旦发现老年代对象新增了对新生代的引用,就把它重新标灰,后续再扫——这会增加 Minor GC 的扫描工作量,但保证不漏。

G1 改用 SATB(Snapshot At The Beginning):在标记开始前拍个快照,之后所有删除的跨代引用都会被记录进队列,最后统一补扫——这降低了扫描实时性压力,但需要额外内存存缓冲区。

所以如果你看到 Minor GC 时间突然变长,先查 GC log 里有没有大量 card table scanningremembered set scanning 耗时,再结合收集器类型判断是卡表膨胀还是写屏障竞争太激烈。

调优时最容易忽略的卡表相关参数

卡表本身不可配置大小,但它的行为受几个关键参数影响:

-XX:+UseCondCardMark:启用条件式卡表标记,避免重复写卡表位(推荐开启)

-XX:MaxGCPauseMillis=200:G1 下该参数会间接影响卡表扫描粒度——目标越短,G1 越倾向减少单次扫描的卡页数,分多轮做

-XX:G1RSetUpdatingPauseTimePercent=10:控制 RSet(Remembered Set,G1 对卡表的增强版)更新占用 STW 时间比例,超限会降频更新,可能导致后续 GC 扫描变重

真正危险的是手动调大 -XX:NewRatio 却没同步观察卡表扫描耗时——新生代变小后,对象更快进入老年代,跨代引用密度上升,卡表 dirty 卡页数可能翻倍,Minor GC 反而更慢。

卡表机制本身不难理解,但它的实际效果高度依赖写屏障的完整性、GC 算法对 dirty 卡的调度策略,以及应用层跨代引用的分布模式。很多“Minor GC 突然变慢”的问题,根源不在新生代对象数量,而在老年代某几个热点类频繁修改指向新生代的字段,把对应卡页反复刷 dirty。