写屏障是JVM在跨代赋值时自动插入的底层指令,用于标记卡表脏页,使年轻代GC仅扫描相关老年代对象而非全堆;它不提供API调用,由JIT编译器生成,确保回收正确性。

OpenJDK 的写屏障(Write Barrier)不是供你调用的 API,而是 JVM 在执行对象字段赋值(如 a.field = b)时,由 JIT 编译器自动插入的一小段底层逻辑——它的唯一作用是:在引用关系变更的瞬间,帮 GC 捕获那些“可能影响回收正确性”的跨代/跨区写操作。
为什么年轻代 GC 不扫整个老年代?靠写屏障记 dirty 卡页
年轻代 GC(比如 G1 的 Young GC)必须确保不误收仍被老年代对象引用的新生代对象。但全堆扫描老年代成本太高。写屏障在这里干了一件极简却关键的事:当老年代对象 A 的字段被赋值为新生代对象 B 时,它立即计算出 A 所在内存地址对应的卡页(card)索引,并把 CARD_TABLE[i] 标为 1(dirty)。后续 GC 只需扫描这些 dirty 卡页里的老年代对象,就能收全根引用。
常见错误现象:
- 没开写屏障(或 GC 算法不支持),会导致
java.lang.NullPointerException—— 因为本该活的对象被漏标回收了 - 卡表精度太粗(如卡页大小设为 4KB),会多扫无用对象,拖慢 Young GC 速度,但不会破坏正确性
G1 的 SATB 写屏障和 CMS 的增量更新写屏障行为不同
写屏障的具体动作取决于 GC 算法设计目标:
-
G1用SATB(Snapshot-At-The-Beginning):在赋值发生前记录旧引用(pre-value),写入线程本地SATB buffer,等并发标记阶段再批量处理;它容忍“多标”(浮动垃圾),但杜绝“漏标” -
CMS用增量更新式:赋值后直接把目标卡页标脏,同时可能立刻修正三色标记状态(比如把黑色对象重新变灰);它更激进,但重标记阶段压力大 -
ZGC / Shenandoah基本不用写屏障做跨区记录,转而依赖读屏障 + 彩色指针,在load时检查引用有效性
参数差异:-XX:G1SATBBufferSize 控制每个线程 SATB buffer 大小,默认 1KB;高吞吐场景下可调到 4KB 减少 flush 频次,但会略增内存占用。
写屏障本身不维护记忆集(RSet),但它是 RSet 更新的触发器
记忆集(Remembered Set,RSet)是逻辑结构,用来快速知道“哪些 Region 里有指向当前 Region 的引用”。写屏障只是那个“敲门的人”:
- 它不直接往 RSet 里插数据,而是先标脏卡页
- GC 的“初始标记”或“并发标记”阶段,会解析 dirty 卡页,遍历其中的老年代对象,提取出所有指向新生代的引用,再汇总构建 RSet
- 所以 RSet 的实时性有延迟——它反映的是“上一轮写屏障触发以来发生的跨区引用”,不是强实时
性能影响:写屏障指令本身只有几条汇编(如 store + test + branch),但高频写(如缓存对象频繁更新字段)仍会带来约 1%~3% 的吞吐下降;JVM 已默认过滤掉同 Region 内引用、空引用等无效 case,无需手动干预。
真正容易被忽略的是:写屏障只对“老→新”或“old→young”这类跨代写生效;而“新→老”的引用(比如新生代对象持有数据库连接)完全不触发写屏障——因为这不影响年轻代 GC 的正确性,也正因如此,RSet 只建在新生代 Region 上,而不是双向维护。


















