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 相关的性能问题。

相关推荐
feathered-feathered4 小时前
Redis【事务】(面试相关)与MySQL相比较,重点在Redis事务
android·java·redis·后端·mysql·中间件·面试
大大大大物~4 小时前
JVM 之 内存溢出实战【OOM? SOF? 哪些区域会溢出?堆、虚拟机栈、元空间、直接内存溢出时各自的特点?以及什么情况会导致他们溢出?并模拟溢出】
java·jvm·oom·sof
仪***沿4 小时前
探索三相、五相电机的容错控制奥秘
java
码界奇点4 小时前
基于Spring MVC与JdbcTemplate的图书管理系统设计与实现
java·spring·车载系统·毕业设计·mvc·源代码管理
⑩-5 小时前
拦截器注册InterceptorRegistry 实现讲解
java·spring
DKunYu5 小时前
3.负载均衡-LoadBalance
java·运维·spring cloud·微服务·负载均衡
第二只羽毛5 小时前
外卖订餐管理系统
java·大数据·开发语言·算法
毕设源码-赖学姐5 小时前
【开题答辩全过程】以 高校篮球社团管理系统 为例,包含答辩的问题和答案
java·eclipse
挫折常伴左右5 小时前
初学HTML2
java·开发语言