在 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)写屏障的主要作用
- 更新记忆集(卡表脏标记):当修改对象引用导致跨代 / 跨 Region 引用产生时,触发卡表的 "脏标记",维护记忆集的准确性;
- 维护并发标记的一致性:在 CMS、G1 等并发收集器中,写屏障用于记录引用的变化,解决并发标记时的 "标记遗漏" 问题(如增量更新、SATB 算法);
- 辅助其他 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:卡表的脏标记(后写屏障)**当老年代对象的引用字段被修改为指向新生代对象时,后写屏障会触发以下操作:
- 计算该老年代对象所在的卡页地址;
- 将卡表中对应卡页的标记设为 "脏";
这个过程由 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)为例,二者的协同过程如下:
- 对象赋值阶段 :当老年代对象的字段指向新生代对象时,写屏障触发卡表的脏标记,将该老年代对象所在的卡页标记为 "脏";
- Minor GC 触发:当新生代 Eden 区满时,触发 Minor GC;
- GC Roots 扫描 :
- 扫描线程栈、全局静态变量等传统 GC Roots;
- 扫描记忆集(卡表) 中标记为 "脏" 的卡页,找到所有老年代→新生代的跨代引用,作为扩展的 GC Roots;
- 可达性分析:基于上述 GC Roots,遍历新生代对象图,标记存活对象;
- 垃圾回收:回收新生代的死亡对象,将存活对象复制到 Survivor 区或老年代;
- 卡表清理:GC 完成后,清理卡表中的脏标记,为下一次 GC 做准备。
再以G1 的并发标记阶段为例:
- 初始标记(STW):标记 GC Roots 直接关联的对象,写屏障开始记录引用变化;
- 并发标记 :GC 线程遍历对象图,用户线程并行执行,写屏障通过 SATB 算法记录旧引用到日志中;
- 最终标记(STW):处理写屏障记录的日志,修正标记结果;
- 筛选回收 :根据记忆集记录的跨 Region 引用,优先回收垃圾比例最高的 Region,同时通过写屏障维护记忆集的准确性。
四、关键总结
| 组件 | 核心作用 | 实现方式 | 典型使用场景 |
|---|---|---|---|
| 记忆集(卡表) | 记录跨代 / 跨 Region 引用,避免全堆扫描 | 卡表(主流)、段表、精确表 | CMS、G1、ParNew 等几乎所有现代收集器 |
| 写屏障 | 触发记忆集更新、维护并发标记一致性 | 预写屏障、后写屏障;增量更新、SATB | 所有收集器的记忆集维护;CMS/G1 的并发标记 |
| 读屏障 | 实现无 STW 的并发收集、颜色指针机制 | 读取引用时检查指针状态 | ZGC、Shenandoah |
核心结论
- 记忆集是空间换时间的典型设计,通过维护引用索引,大幅减少 GC 的扫描范围;
- 读写屏障是逻辑插桩的实现,为 GC 提供了引用变化的 "感知能力",是并发收集的基础;
- 二者的配合是现代 GC 能在高并发、大内存场景下保证性能的关键,也是理解 G1、ZGC 等先进收集器的核心。
对于开发者而言,无需手动操作这些机制,但理解其原理有助于更好地调优 GC 参数、排查 GC 相关的性能问题。