JVM 跨代引用与 Card Table 机制

目录

1、跨代引用

1.1、现象介绍

1.2、问题本质

[2、Card Table机制](#2、Card Table机制)

2.1、介绍

2.2、内存结构

[2.5、写屏障(Write Barrier)](#2.5、写屏障(Write Barrier))

[3、Remembered Sets](#3、Remembered Sets)

3.1、介绍

3.2、使用原因

3.3、内部结构

3.4、操作流程

[3.5、Remembered Set vs Card Table](#3.5、Remembered Set vs Card Table)

4、实际应用

4.1、工作流程

4.2、流程图解

[4.3、JVM 参数调优](#4.3、JVM 参数调优)


前沿

在现代 Java 应用日益复杂、堆内存规模不断扩大的背景下,垃圾回收(Garbage Collection, GC)的性能与稳定性成为系统高可用性的关键因素。为提升 GC 效率,主流 JVM 实现普遍采用分代收集(Generational Collection) 策略,将堆内存划分为年轻代(Young Generation)与老年代(Old Generation),并基于"大多数对象朝生夕死"的经验假说,对不同代采用差异化的回收策略。

如下所示:

然而,分代模型在带来性能优势的同时,也引入了一个关键挑战:跨代引用(Inter-Generational Reference) ------即老年代中的对象可能持有对年轻代对象的引用。若在仅回收年轻代的 Minor GC 过程中忽略此类引用,将导致被引用的年轻代对象被错误回收,进而引发程序崩溃或数据不一致等严重问题。

为解决这一矛盾,JVM 设计了一套高效且低开销的机制------卡表(Card Table)配合写屏障(Write Barrier)

该机制通过在对象引用赋值时动态标记"可能包含跨代引用"的内存区域,并在 Minor GC 期间仅扫描这些"脏卡"(Dirty Cards),从而在避免全堆扫描的前提下,确保 GC 的正确性与完整性

通过理解这一底层机制,开发者不仅能更精准地进行 JVM 调优,也能在设计高性能 Java 应用时做出更合理的内存管理决策。


1、跨代引用

1.1、现象介绍

当老年代(Old Generation)对象引用了年轻代(Young Generation)对象,当发生 Minor GC(只回收年轻代)时,如何避免错误地回收这些被老年代引用的年轻代对象?如果不处理,会导致 "引用丢失" ------ 被老年代引用的年轻代对象被误判为垃圾而回收,程序崩溃!

如下所示:

JVM 如何在 Minor GC 中安全处理这种跨代引用,避免错误回收?这个问题触及了 JVM 垃圾回收(GC)中最核心的优化机制之一 ------ 分代收集(Generational Collection)的完整性保障

如果没有 Card Table 会怎样?

  • Minor GC 必须 扫描整个老年代 找跨代引用

  • 老年代通常很大(几百 MB ~ 几 GB)

  • Minor GC 停顿时间剧增(从几 ms 到几百 ms)

  • 违背分代 GC 的初衷!

1.2、问题本质

一、背景:为什么需要特殊处理?

1.1 分代假说(Generational Hypothesis)

JVM 将堆内存分为:

  • 年轻代(Young Generation):存放新创建的对象

  • 老年代(Old Generation):存放长期存活的对象

基于经验观察:

  • 绝大多数对象"朝生夕死" → 在年轻代就死亡

  • 老年代对象很少引用年轻代对象(跨代引用是少数)

因此,Minor GC 只回收年轻代,效率高、停顿短。

如下所示:

1.2 问题:跨代引用(Inter-Generational Reference)

代码如下所示:

java 复制代码
// 老年代对象(长期存活)
Object oldObj = new Object(); // 经过多次 GC 后进入老年代

// 年轻代对象(新创建)
Object youngObj = new Object();

// 老年代对象引用年轻代对象!
oldObj.ref = youngObj;

此时,youngObj 虽然在年轻代,但被老年代对象引用不能被回收

但如果 Minor GC只扫描年轻代的 GC Roots(如栈、寄存器),就会漏掉 oldObj.ref 这个引用 → 误判 youngObj 为垃圾 → 程序崩溃!

这就是 "引用丢失"(Missing Reference)问题


2、Card Table机制

2.1、介绍

JVM 通过 "写屏障(Write Barrier)" + "卡表(Card Table)" 机制高效解决跨代引用问题。

如下所示:

JVM 的核心思想是:

不扫描整个老年代,只记录"哪些老年代区域可能引用了年轻代",Minor GC 时只扫描这些区域。

实现这一思想的关键数据结构是:

  • Card Table(卡表)

  • Remembered Set(记忆集)(G1/ZGC 中更复杂,但 CMS/Parallel 用 Card Table)

JVM 将 整个堆内存(主要是老年代)划分为固定大小的块 ,称为 Card(卡片),通常 512B(通过 -xx:CardTableEntrySize 调整,但一般不改)。

每个 Card 对应一个 "脏位(DirtyBit) ,记录该 Card 是否包含 指向年轻代的引用

老年代内存(1GB) → 划分为 1GB / 512B ≈ 200 万个 Card

如下所示:

bash 复制代码
老年代内存: [Card0][Card1][Card2]...[CardN]
卡表(Card Table): [0]   [1]   [0] ... [1]   ← 1 表示"脏"(有跨代引用)

2.2、内存结构

  • Card Table 是一个字节数组,每个元素对应一个 Card

  • 每个字节(8 位)中,最低位(bit 0)表示"脏位"(Dirty Bit)

    • 0:干净(Clean)→ 该 Card 没有指向年轻代的引用

    • 1:脏(Dirty)→ 该 Card 可能有指向年轻代的引用

bash 复制代码
内存布局:
┌───────────────┬───────────────┬───────────────┐
│   Card 0      │   Card 1      │   Card 2      │  ← 老年代内存(每块 512B)
└───────────────┴───────────────┴───────────────┘
        ↑               ↑               ↑
        │               │               │
┌───────┴───────┬───────┴───────┬───────┴───────┐
│     0x00      │     0x01      │     0x00      │  ← Card Table(每个字节对应一个 Card)
└───────────────┴───────────────┴───────────────┘
        Clean           Dirty           Clean

注意⚠️:

Card Table 只记录 "可能有" 跨代引用,不是精确记录。这是为了性能牺牲一点精度(后续会修正)。

如下所示:

2.5、写屏障(Write Barrier)

每当 老年代对象修改引用(如 obj.field = youngObj) 时,JVM 会触发 写屏障

java 复制代码
// 伪代码:老年代对象引用年轻代对象
oldObject.ref = youngObject; // ← 触发写屏障

作用:实时维护卡表

写屏障的作用

  • 计算 oldObject 所在的 Card 地址

  • 将对应 Card 标记为 "脏"(Dirty)

  • 即:cardTable[cardIndex] = Dirty

💡 写屏障是 JIT 编译器插入的额外指令,对性能有轻微影响,但远小于全堆扫描。

3、写屏障

  • 写屏障是在对象引用赋值时插入的一段额外代码

  • 由 JIT 编译器在编译期自动插入

  • 对程序员透明,但对 GC 至关重要

关键点 :只有 老年代 → 年轻代 的引用才会标记 Card 为 Dirty。

写屏障如何工作?

当执行:

obj.field = ref; // obj 是老年代对象,ref 是年轻代对象

JVM 会插入类似这样的伪代码:

java 复制代码
// 1. 执行实际赋值
obj.field = ref;

// 2. 触发写屏障(Write Barrier)
if (obj 在老年代 && ref 在年轻代) {
    // 计算 obj 所在的 Card 地址
    intptr_t card_index = (intptr_t)obj >> 9;  // 512 = 2^9
    // 标记 Card 为 Dirty
    card_table[card_index] = DIRTY;  // 通常设为 0x01
}

关键点 :只有 老年代 → 年轻代 的引用才会标记 Card 为 Dirty。

写屏障的性能影响

  • 每次引用赋值都多几条指令

  • 但现代 CPU 分支预测 + 卡表缓存友好,开销很小(通常 < 5%)

  • 远小于"每次 Minor GC 扫描整个老年代"的代价


3、Remembered Sets

3.1、介绍

Remembered Set(记忆集)是一个"反向引用表"

每个 Region 都有一个 RSet,记录"哪些其他 Region(或对象)引用了本 Region 中的对象"。

如下所示:

Region X: [obj1, obj2] ← 被 Region Y 和 Region Z 引用
Region Y: [ref → obj1]
Region Z: [ref → obj2]

Region X 的 RSet 内容为:

RSet[X] = { Y, Z }

当 G1 要回收 Region X 时:

  • 不需要扫描整个堆
  • 只需扫描 RSet[X] 中列出的 Region(Y 和 Z)
  • 从中找出指向 X 的引用,作为 GC Roots

这样就避免了全堆扫描,实现局部回收(Partial Collection)

Remembered Set(记忆集) 是现代 JVM 垃圾回收器(尤其是 G1、ZGC、Shenandoah 等并发/分区式 GC)中用于高效处理跨区域引用(Inter-Region Reference) 的核心数据结构。

它是对传统 Card Table(卡表)机制的演进与精细化

G1 GC 中,概念升级为 Remembered Set(RSet)

  • 每个 Region 有自己的 RSet

  • RSet 精确记录 "哪些 Region 引用了本 Region"

  • 使用 Concurrent Refinement Threads 异步维护 RSet,减少写屏障开销

但核心思想一致:避免全堆扫描,只追踪跨区域引用

3.2、使用原因

1.1 分代 GC 的局限性

在传统的 CMS 或 Parallel GC 中:

  • 堆被简单分为 年轻代 + 老年代

  • 使用 Card Table 记录"老年代中哪些 Card 可能引用了年轻代"

但这种模型在以下场景遇到瓶颈:

  • 堆非常大(几十 GB 甚至上百 GB)

  • 需要更细粒度的回收(如只回收部分老年代区域)

  • 并发标记与回收要求更高精度的引用追踪

1.2 G1 GC 的分区思想

G1(Garbage-First)将整个堆划分为 固定大小的 Region(默认 1~32MB),不再严格区分年轻代/老年代,而是:

  • 每个 Region 可以是 Eden、Survivor 或 Old

  • GC 时选择 "回收价值最高"的若干 Region(即 Garbage-First)

🎯 问题来了:Region A 中的对象可能引用 Region B 中的对象,如何在回收 Region B 时,知道哪些外部 Region 引用了它?

这就是 Remembered Set(RSet) 要解决的问题。

3.3、内部结构

RSet 并非简单列表,而是多级缓存结构,兼顾空间效率查询速度。以 HotSpot G1 为例:

典型层级(由快到慢):

当引用数量少时,用 Sparse Table;引用多时,升级为 Fine-grained Table。

3.4、操作流程

写屏障 + 并发精化

4.1 写屏障(Write Barrier)

当执行 obj.field = ref 时:

  • 如果 objref 不在同一个 Region

  • JVM 插入写屏障,将 (from_region, to_region) 记录到 Dirty Card Queue

java 复制代码
// 伪代码
if (region_of(obj) != region_of(ref)) {
    enqueue_into_dirty_card_queue(region_of(obj), card_of(obj));
}

4.2 并发精化线程(Concurrent Refinement Threads)

  • 后台线程(G1 Concurrent Refinement)持续消费 Dirty Card Queue

  • 将粗粒度的 Card 信息 精化(Refine) 为 RSet 中的精确条目

  • 避免在 Mutator 线程(应用线程)中做 heavy work,减少 STW 时间

✅ 这是 G1 实现低延迟的关键:引用更新的开销被摊销到并发阶段

3.5、Remembered Set vs Card Table

RSet 是 Card Table 的泛化与升级,支持更灵活的回收策略。

RSet 的代价与优化

空间开销

  • RSet 通常占 堆内存的 5%~20%

  • 可通过参数控制:

bash 复制代码
-XX:G1RSetSparseRegionEntries=...   # 稀疏表容量
-XX:G1RSetRegionEntries=...         # 细粒度表容量

4、实际应用

4.1、工作流程

Minor GC 时如何利用 Card Table?

当发生 Minor GC 时,步骤如下:

步骤 1:识别所有 GC Roots

  • 栈帧中的局部变量

  • 寄存器

  • 静态变量

  • JNI 引用

  • + 来自老年代的跨代引用(通过 Card Table 获取)

步骤 2:扫描 Dirty Cards

  • 遍历 Card Table,找出所有 card_table[i]==Dirty 的 Card

  • 对每个 Dirty Card:

    • 扫描该 Card 内存区域中的所有对象

    • 检查这些对象的引用字段

    • 如果引用指向 年轻代 ,则将该引用加入 "根集合"(Root Set)

步骤 3:正常进行年轻代 GC

  • 从所有 GC Roots(包括跨代引用)出发,标记存活对象

  • 回收未标记对象

结果:youngObj 被正确标记为存活,不会被回收!

4.2、流程图解

如下所示:

bash 复制代码
老年代
┌───────────────┬───────────────┬───────────────┐
│   Card A      │   Card B      │   Card C      │
│ (干净)        │ (脏!有引用)  │ (干净)        │
└───────────────┴───────────────┴───────────────┘
        ↑
        └── Card Table: [0, 1, 0]

Minor GC 时:

  1. 扫描常规 GC Roots

  2. 扫描 Card B(因为 dirty=1)→ 发现 oldObj.ref → youngObj

  3. 将 youngObj 加入存活对象集合

  4. youngObj 不会被回收!

4.3、JVM 参数调优

如下所示:

参数 作用
-XX:+UseCondCardMark 避免并发写屏障重复标记(减少 cache contention)
-XX:CardTableEntrySize Card 大小(默认 512B,一般不调)
-XX:+PrintGCDetails 查看 GC 日志中是否有 "Dirty Cards" 相关信息

常见误区:


总结:JVM 如何安全处理跨代引用?

这就是 JVM 分代 GC 既高效又安全的核心秘密!

通过这种设计,Minor GC 的停顿时间保持在 毫秒级,即使老年代有几十 GB,也能快速完成年轻代回收。

相关推荐
向上的车轮3 小时前
为什么.NET(C#)转 Java 开发时常常在“吐槽”Java:checked exception
java·c#·.net
Dragon Wu3 小时前
Spring Security Oauth2.1 授权码模式实现前后端分离的方案
java·spring boot·后端·spring cloud·springboot·springcloud
跳动的梦想家h3 小时前
环境配置 + AI 提效双管齐下
java·vue.js·spring
坚持就完事了3 小时前
Java中的集合
java·开发语言
wjhx4 小时前
QT中对蓝牙权限的申请,整理一下
java·数据库·qt
YCY^v^4 小时前
JeecgBoot 项目运行指南
java·学习
人间打气筒(Ada)4 小时前
jenkins基于Pipeline发布项目
java·pipeline·jenkins·流水线·ci·cd·cicd
爬山算法4 小时前
Hibernate(88)如何在负载测试中使用Hibernate?
java·后端·hibernate
自不量力的A同学4 小时前
Solon AI v3.9 正式发布:全能 Skill 爆发
java·网络·人工智能
万岳科技系统开发4 小时前
食堂采购系统源码库存扣减算法与并发控制实现详解
java·前端·数据库·算法