JVM 垃圾收集器中的记忆集与读写屏障

在 JVM 垃圾收集器(尤其是分代收集器、G1/CMS 等并发收集器)的实现中,记忆集(Remembered Set,RS) 和读写屏障(Read/Write Barrier) 是解决跨代引用追踪 和并发标记一致性的核心技术。二者相互配合,既避免了全堆扫描的性能损耗,又保证了并发场景下垃圾标记的准确性,是理解现代 GC 机制的关键。

一、记忆集(Remembered Set):解决跨代引用的 "索引"

1. 为什么需要记忆集?

分代垃圾收集的核心假设是新生代对象朝生夕死,老年代对象存活时间长 ,因此新生代 GC(Minor GC)的频率远高于老年代 GC(Major GC)。但分代模型存在一个关键问题:老年代对象可能引用新生代对象(跨代引用)。

根据可达性分析算法,新生代 GC 时需要扫描GC Roots,而老年代的跨代引用属于 GC Roots 的一部分。如果每次 Minor GC 都全量扫描老年代,会导致 STW 时间急剧增加,完全违背分代收集的初衷。

记忆集的核心作用 :记录老年代对象指向新生代对象的引用(或跨 Region 引用),使 GC 时只需扫描记忆集,而非全量扫描老年代 / 其他 Region,从而大幅减少扫描范围。

2. 记忆集的定义与本质

记忆集是一种辅助数据结构 ,本质是 **"从非收集区到收集区的引用的集合"**(如老年代→新生代、Old Region→Eden Region)。它为每个收集区 (如新生代的 Eden/Survivor)维护一个索引,记录哪些非收集区的内存块中存在指向该收集区的引用。

3. 记忆集的实现粒度

记忆集的实现粒度决定了 GC 扫描的效率和内存开销,常见的粒度从粗到细分为三级:

粒度级别 描述 优点 缺点
卡表(Card Table) 将堆内存划分为大小固定的卡页(Card Page,通常 512 字节),用一个字节数组表示卡表,每个元素对应一个卡页,标记该卡页是否存在跨代引用。 实现简单、内存开销低(堆内存的 1/512)、操作高效 粒度较粗,可能扫描少量无关对象
段表(Segment Table) 按内存段(如更大的内存块)划分,记录段级别的跨代引用。 平衡粒度与开销 适用场景有限,不如卡表常用
精确表(Precise Table) 记录具体的对象引用地址,粒度精确到单个对象。 扫描无冗余,效率最高 内存开销大、更新成本高

**最主流的实现:卡表(Card Table)**JVM 中几乎所有收集器(CMS、G1、ParNew 等)都采用卡表作为记忆集的实现,以 HotSpot 为例:

  • 将堆内存按512 字节划分为一个个卡页;
  • 用一个unsigned char类型的数组(卡表)对应所有卡页,数组下标与卡页的内存地址一一映射;
  • 当某个卡页中存在老年代对象指向新生代对象的引用 时,将卡表中对应位置的字节标记为脏(Dirty,如 0x01);
  • 新生代 GC 时,只需扫描卡表中标记为 "脏" 的卡页,即可找到所有跨代引用,无需扫描整个老年代。

4. 记忆集的适用场景

  • 分代收集器:ParNew + CMS、Parallel Scavenge + Parallel Old 等,用于记录老年代→新生代的跨代引用;
  • G1 收集器:用于记录不同 Region 之间的引用(如 Old Region→Eden Region、Old Region→Other Old Region),因为 G1 的堆是由多个独立 Region 组成的,跨 Region 引用同样需要索引;
  • ZGC/Shenandoah:虽采用整堆收集,但仍需记忆集辅助追踪跨区域引用。

5. 记忆集的更新时机

记忆集的准确性依赖于引用变化时的及时更新 ,而这个更新操作正是由写屏障触发的(下文详细讲解)。当对象的引用字段被修改时(如老年代对象的字段指向新生代对象),写屏障会触发卡表的 "脏标记" 操作,确保记忆集能反映最新的引用关系。

二、读写屏障(Read/Write Barrier):拦截对象访问的 "钩子"

1. 屏障的本质

在 JVM 中,屏障(Barrier) 是在对象的读、写操作前后插入的一段额外代码,用于拦截并处理特定逻辑(如更新记忆集、维护并发标记的一致性)。它类似于 AOP 的切面,在不修改业务代码的前提下,为对象访问操作增加额外的 "副作用"。

根据拦截的操作类型,分为读屏障(Read Barrier) 和写屏障(Write Barrier) ,其中写屏障是 GC 中最常用的,读屏障仅在少数收集器(如 ZGC、Shenandoah)中使用。

2. 写屏障(Write Barrier):GC 的核心 "触发器"

写屏障拦截的是对象引用字段的赋值操作 (如obj.field = newObj),是实现记忆集更新、并发标记一致性的核心机制。

(1)写屏障的主要作用
  1. 更新记忆集(卡表脏标记):当修改对象引用导致跨代 / 跨 Region 引用产生时,触发卡表的 "脏标记",维护记忆集的准确性;
  2. 维护并发标记的一致性:在 CMS、G1 等并发收集器中,写屏障用于记录引用的变化,解决并发标记时的 "标记遗漏" 问题(如增量更新、SATB 算法);
  3. 辅助其他 GC 机制:如在 G1 中记录 Humongous 对象的引用变化,在 ZGC 中维护颜色指针的一致性。
(2)写屏障的实现方式

HotSpot 中的写屏障分为预写屏障(Pre-Write Barrier) 和后写屏障(Post-Write Barrier),分别在赋值操作前后执行:

java 复制代码
// 原始赋值操作:obj.field = newObj
obj.field = newObj;

// 插入写屏障后的逻辑:
pre_write_barrier(obj, field, newObj); // 预写屏障:赋值前执行
obj.field = newObj;                     // 原始赋值
post_write_barrier(obj, field, oldObj); // 后写屏障:赋值后执行

**核心场景 1:卡表的脏标记(后写屏障)**当老年代对象的引用字段被修改为指向新生代对象时,后写屏障会触发以下操作:

  1. 计算该老年代对象所在的卡页地址;
  2. 将卡表中对应卡页的标记设为 "脏";

这个过程由 JVM 底层自动完成,无需开发者干预。例如 HotSpot 中的card_table::make_dirty函数就是实现脏标记的核心逻辑。

核心场景 2:并发标记的一致性保障在并发标记阶段,用户线程和 GC 线程并行执行,对象引用可能被修改,导致标记遗漏。写屏障通过以下两种算法解决该问题:

  • 增量更新(Incremental Update) :CMS 收集器采用,当对象的引用被修改时,写屏障将被修改的对象标记为 "需要重新标记",确保在重新标记阶段(Remark)能处理这些对象,避免遗漏;
  • SATB(Snapshot At The Beginning,初始快照) :G1 收集器采用,当对象的引用被修改时,写屏障将旧的引用对象记录到日志中,确保并发标记基于 "标记开始时的对象图快照",避免遗漏。
(3)写屏障的性能开销

写屏障会在每次对象引用赋值时插入额外代码,理论上存在性能开销,但实际影响极小:

  • HotSpot 对写屏障做了大量优化(如内联、编译器优化),使其开销降低到纳秒级别;
  • 写屏障的开销远小于其带来的 GC 性能提升(如避免全堆扫描)。

3. 读屏障(Read Barrier):少数收集器的 "特殊工具"

读屏障拦截的是对象引用的读取操作 (如Object obj = obj.field),由于读取操作的频率远高于写入操作,读屏障的开销更大,因此仅在追求极致低延迟的收集器中使用。

(1)读屏障的适用场景
  • ZGC/Shenandoah 收集器 :用于实现颜色指针(Colored Pointer) 机制,在读取对象引用时,检查指针的颜色标记,判断对象是否被移动或需要重新定位,从而实现几乎无 STW 的并发收集;
  • 低延迟场景:在并发整理阶段,读屏障可确保线程读取到的是对象的最新地址,避免访问已被移动的对象。
(2)读屏障的实现示例

以 ZGC 为例,当线程读取对象引用时,读屏障会检查指针的标记位:

  • 如果指针标记为 "已移动",则通过指针的元数据找到对象的新地址,返回新地址;
  • 如果指针正常,则直接返回原地址。

这种机制使 ZGC 能在并发整理阶段移动对象,而无需暂停用户线程。

三、记忆集与读写屏障的协同工作流程

以ParNew + CMS的新生代 GC(Minor GC)为例,二者的协同过程如下:

  1. 对象赋值阶段 :当老年代对象的字段指向新生代对象时,写屏障触发卡表的脏标记,将该老年代对象所在的卡页标记为 "脏";
  2. Minor GC 触发:当新生代 Eden 区满时,触发 Minor GC;
  3. GC Roots 扫描 :
    • 扫描线程栈、全局静态变量等传统 GC Roots;
    • 扫描记忆集(卡表) 中标记为 "脏" 的卡页,找到所有老年代→新生代的跨代引用,作为扩展的 GC Roots;
  4. 可达性分析:基于上述 GC Roots,遍历新生代对象图,标记存活对象;
  5. 垃圾回收:回收新生代的死亡对象,将存活对象复制到 Survivor 区或老年代;
  6. 卡表清理:GC 完成后,清理卡表中的脏标记,为下一次 GC 做准备。

再以G1 的并发标记阶段为例:

  1. 初始标记(STW):标记 GC Roots 直接关联的对象,写屏障开始记录引用变化;
  2. 并发标记 :GC 线程遍历对象图,用户线程并行执行,写屏障通过 SATB 算法记录旧引用到日志中;
  3. 最终标记(STW):处理写屏障记录的日志,修正标记结果;
  4. 筛选回收 :根据记忆集记录的跨 Region 引用,优先回收垃圾比例最高的 Region,同时通过写屏障维护记忆集的准确性。

四、关键总结

组件 核心作用 实现方式 典型使用场景
记忆集(卡表) 记录跨代 / 跨 Region 引用,避免全堆扫描 卡表(主流)、段表、精确表 CMS、G1、ParNew 等几乎所有现代收集器
写屏障 触发记忆集更新、维护并发标记一致性 预写屏障、后写屏障;增量更新、SATB 所有收集器的记忆集维护;CMS/G1 的并发标记
读屏障 实现无 STW 的并发收集、颜色指针机制 读取引用时检查指针状态 ZGC、Shenandoah

核心结论

  1. 记忆集是空间换时间的典型设计,通过维护引用索引,大幅减少 GC 的扫描范围;
  2. 读写屏障是逻辑插桩的实现,为 GC 提供了引用变化的 "感知能力",是并发收集的基础;
  3. 二者的配合是现代 GC 能在高并发、大内存场景下保证性能的关键,也是理解 G1、ZGC 等先进收集器的核心。

对于开发者而言,无需手动操作这些机制,但理解其原理有助于更好地调优 GC 参数、排查 GC 相关的性能问题。

相关推荐
I'm Jie7 小时前
【开源】7 天,我用 Rust 复刻了 FinalShell!Rhost v1.0.0 发布
开发语言·rust·开源
木井巳7 小时前
【BFS 解决拓扑排序】课程表
java·算法·leetcode·广度优先·宽度优先·推荐算法·图搜索算法
kimnoic7 小时前
Python常用命令提示符使用方法详解
开发语言·python
Crazy________7 小时前
05Linux内存管理核心原理与运维实战
java·开发语言
Nebula_g7 小时前
JavaSE拓展:工具类Executors
java·开发语言·后端·spring·基础·javase
扶风ff7 小时前
练题簿在线免费刷题:会员线下组卷、Word 试卷与成绩导入,课堂检测更方便
java·开发语言·算法·小程序·word
鱼宵7 小时前
Spring AI 可观测与透明化:traceId 串起全链路,思考过程实时直播给用户
java·人工智能·spring·链路追踪·springai
程序员老陆7 小时前
C++ 多线程通信方式全景:从共享内存到结构化协调
开发语言·c++
Raas1008 小时前
AI网关能做语义缓存吗?MAI Gateway(魔芋企业级AI网关)实战能力深度解读
java·后端·spring
weixin199701080168 小时前
《1688消息服务落地方案:alibaba.message.* 与 Webhook 回调的可靠性与重试策略》(附Python源码)
开发语言·python