G1 引用位置管理与对象疏散:从句柄、栈根到转发指针 📅 发布时间:2026/8/22 23:10:54 👁 浏览次数: 引言在 HotSpot 的底层实现里GC 要移动一个对象前提永远是同一件事找到所有指向该对象的引用并在对象搬走之后把这些引用改写为新地址。引用在内存里无处不在——它既可能藏在 JVM 内部 C 代码的句柄里也可能躺在 Java 线程栈帧的局部变量中还可能存在于对象体内的某个字段或某个全局数据结构。HotSpot 的垃圾收集器里G1 是并发标记 STW 疏散的设计并发标记的大部分环节与应用线程并行但对象的实际搬迁发生在一次停顿之内。更晚进入 OpenJDK 主线的 Shenandoah、ZGC 追求在对象搬迁期间也不冻结应用线程于是用转发指针与读屏障实现低延迟——不过二者都要等到 JDK 11 / 12 之后才进入主线本仓库jdk8u HotSpot的源码中并不存在。本文聚焦本仓库里真实存在的 G1。这一根本差异决定了 G1 处理引用位置的方式它不需要用转发指针加读屏障去绕开枚举所有引用这一步而是在停顿期间直接把每一条引用都找出来、就地改写。本文沿着句柄 → 栈根 → 转发指针 → 地址重映射 → 堆转储根这条线索把 G1 在 jdk8u 里的真实实现讲清楚。贯穿全文的一条主线是槽位slot“无论引用住在句柄、栈帧、全局结构还是对象字段里G1 都把它们统一视作一个存放引用、对象移动时须被覆写的位置”。一、句柄Handle就是一类槽位HotSpot 内部 C 代码通过Handle访问 Java 对象。Handle本质上是一个指针的指针它内部持有oop* _handle这个二级指针才真正指向堆中对象的首地址HandleArea以 bump-the-pointer 方式在线程私有内存池里分配句柄HandleMark借 RAII 在作用域结束时批量回退水位线。句柄机制最大的价值是在 GC 移动对象时为 Native 代码提供稳定的引用对象被搬到新地址后只需更新_handle指向的新地址外层 C 持有的Handle本身无需变动。从 GC 的视角看Handle的_handle与对象体内的一个 oop 字段没有任何区别——它都是一个存放引用、对象移动时须被覆写的位置也就是一个槽位。G1 把HandleArea当作强根的一部分来扫描对每个句柄内部那个oop*做疏散与改写用的正是同一套do_oop逻辑。换句话说句柄是槽位这个概念在 JVM 内部 C 层的体现。二、Java 栈上的直接指针最密集的槽位来源句柄Handle是 VM 内部 C 代码在跨 GC 安全点时持有引用所用的间接层Java 语言层的reference本身仍是直接指针并不经由 Handle 表示二者只是 GC 在根扫描时要改写的两种不同位置的来源。在 Java 语言层栈上的reference采用直接指针方式直接指向堆中对象头。这意味着线程栈帧里的局部变量、表达式栈槽都是一个个持有引用的位置——也就是槽位而且是数量最庞大的那一类。GC 要移动一个对象必须把栈槽里指向它的引用全部找出来并改写。G1 的做法是在疏散停顿期间枚举每一个线程栈帧里的 oop 槽位G1RootProcessor::process_java_roots通过Threads::possibly_parallel_oops_do让各工作线程并行遍历所有 Java 线程逐帧扫描其中的引用槽对每个槽位执行读引用 → 疏散目标 → 把新地址写回槽位。这里正是 G1 与 Shenandoah 的分野。Shenandoah 不能在搬迁时暂停并修改所有线程栈于是改用转发指针 读屏障——让引用继续指向旧对象、访问时再重定向从而省掉枚举并改写所有栈槽这一步。G1 则恰恰相反因为它把搬迁放在 STW 停顿里没有应用线程在并发改写引用它必须且能够枚举并修复每一条栈槽。槽位枚举是 G1 的正解转发指针加读屏障则是 Shenandoah 因为无法枚举槽位而选择的绕解。三、G1 的疏散边枚举边搬迁G1 的对象搬迁发生在疏散停顿Young GC 与 Mixed GC内由 root 处理、RSet 扫描、BFS 拷贝循环三段串成。其核心动作高度统一拿到一个槽位 → 读出里面的引用 → 若目标在待回收集合CSet中就把目标复制到新 Region → 把新地址写回原槽位。这个动作对根槽位、字段槽位、RSet 发现的引用槽位一视同仁区别只在于槽位从哪里来。写入新地址的那一行本质就是encode_store_heap_oop(p, forwardee)——把指针的指针p解引用一次存回转发后的新地址。因为整个搬迁在 STW 内完成没有应用线程在并发地读写这些引用所以枚举所有槽位并就地改写这一策略既安全又简单。这也解释了为什么 G1 不需要 Brooks 指针、不需要读屏障它不需要在应用线程访问旧对象时做拦截与重定向因为停顿期间根本就没有这样的访问发生。BFS 拷贝循环、任务队列与工作窃取如何把字段槽位持续送入各工作线程详见《G1 复制 EvacBFS 拷贝循环》。四、转发指针复用对象头 Mark Word对象被复制后旧地址不能直接释放——同一对象可能被多个槽位引用而多个工作线程可能先后通过不同槽位到达它。G1 需要一个这个对象已经搬走了、新家在哪的标记这就是转发指针。转发指针在 G1 里复用的是对象头的 Mark Word而不是额外预留 8 字节。复制对象时oopDesc::forward_to_atomic并行场景下的 CAS 版forward_to把新地址经encode_pointer_as_mark编码后写进旧对象的 mark word后续任何线程再处理该对象的某个槽位时通过is_forwarded()即mark()-is_marked()即可判定它已搬迁并用forwardee()解出新地址。这种设计零额外内存开销、对 CPU 缓存极度友好代价是旧对象自身的 Mark Word 被牺牲它不再承载哈希、分代年龄或锁状态而只剩一个转发指针。但这并不意味着这些信息丢失。复制一开始copy_to_survivor_spaceg1ParScanThreadState.cpp:216就把旧对象的原始 mark 暂存在old_mark参数里随后old-forward_to_atomic(obj)把旧对象头改写成转发指针紧接着的字节级整块复制Copy::aligned_disjoint_words会把这枚转发指针也一并抄进新对象——于是函数立刻用obj-set_mark(old_mark)把新对象的头覆盖回原始 mark。若对象仍留在年轻代且未到晋升阈值还会把分代年龄 1old_mark-set_age(age)若处于偏向锁的 displaced 状态对应的 displaced header 也原样保留。换言之哈希、年龄、锁状态都被搬到了新对象头上旧对象头里只剩一根指向新家的指针。疏散原子地发生在应用线程视角的停顿之中所有指向旧对象的槽位都会在这次停顿内被改写为新地址停顿结束后旧对象随 CSet Region 一并回收再也不会有人去读它的 mark。Shenandoah 确实会在每个对象头额外预留 8 字节作为 Brooks 指针那是因为它要在并发期保留旧对象当公告板并靠读屏障重定向G1 的 STW 语义让这套额外空间与读屏障都成为不必要。五、地址重映射的维护策略G1 为何不需要转发表与滑动指针对象搬走后旧引用必须被更新。不同收集器记录旧地址 → 新地址的方式各异而 G1 的选择是把映射信息直接挂在旧对象自身的 Mark Word 上——这就是上一节的转发指针。它不依赖任何外部表。之所以能这么做是因为 G1 的疏散是拷贝到新 Region、旧 Region 整体回收旧对象在停顿期间仍然存在并持有自己的转发信息直到整个 CSet 回收完成。每个待搬对象自己就说清了我搬去哪了自然无需一张全局转发表。另外两种常见策略在本仓库的 G1 中并不适用ZGC 的转发表 染色指针 自愈读屏障转发表把重映射集中管理、不碰对象头配合读屏障的自愈只查一次表。但 ZGC 是 JDK 11 才引入jdk8u 源码中不存在。Serial / Parallel Old 的滑动指针整理通过指针碰撞在 STW 内原地滑动压缩连哈希表都不需要。G1 不走原地整理而是拷贝式疏散到新 Region二者路线不同。G1 的取舍因而很清楚用 Mark Word 内的转发指针换取零表结构开销与缓存友好代价是疏散期间对象 mark 被占用——而 STW 语义使这个代价可忽略。六、原子 CAS 与复制而非移动GC 线程间的幂等Shenandoah 把 CAS 用作 GC 线程与并发应用线程争夺搬迁权的机制。在 G1 里CAS 仍然存在但它的对手不是并发应用线程而是其它 GC 工作线程。由于同一对象会被多个槽位引用多个工作线程可能几乎同时到达它。G1 用oopDesc::cas_forward_to保证只真的搬一次第一个成功把 Mark Word CAS 成转发地址的线程负责复制对象其余线程看到is_forwarded()为真直接读取forwardee()拿到新地址、跳过复制。这正是复制而非移动在 G1 里的含义对象被复制到新 Region旧对象头部留下一个转发标记作为我搬走了的公告前者是存活副本后者在停顿结束后随整个旧 Region 一起被回收。它与 Shenandoah 的关键区别是——G1 的旧对象保留只服务于 GC 工作线程之间的幂等协调不存在任何应用线程在并发地通过旧地址访问它停顿期间应用线程全部冻结因此完全不需要读屏障去兜底这次并发访问。这个 CAS 幂等正是 BFS 拷贝循环里重复入队、不重搬、不漏改能够成立的底层前提。七、HeapDump 中的线程栈根同一批槽位的另一个消费者生成堆转储时VM_HeapDumper::do_thread会遍历每个 Java 线程的调用栈把栈帧里的对象引用作为 GC Roots 写入 HPROF 文件。它提取的正是 G1 根扫描要处理的同一批栈槽非 Native 栈帧的局部变量、表达式栈引用以及 Native 方法经 JNI Handle 表持有的引用。二者的差异只在拿到槽位之后做什么HeapDump 是只读消费者——JNILocalsDumper::do_oop把每个找到的引用记录进 Dump 文件不改变任何东西G1 根扫描是读写消费者——它对每个槽位做疏散目标 就地写回新地址。这进一步说明槽位是一个贯穿 JVM 的普遍抽象根枚举、对象疏散、堆转储本质上都是在遍历存放引用的位置只是各自的处理动作不同。G1 的oops_do闭包体系正是这套遍历引用位置逻辑的统一定义。八、小结G1jdk8u对引用位置的整体处理可以归纳为一句话在 STW 停顿内枚举每一条引用槽位就地将其改写为对象疏散后的新地址并用对象头 Mark Word 内的转发指针 原子 CAS 保证搬迁的幂等。句柄、栈帧、全局结构、对象字段在 GC 眼里都是槽位HeapDump 与 G1 根扫描消费的是同一批栈槽只动作不同。与 Shenandoah、ZGC 相比G1 的设计哲学恰好相反后两者因为无法在搬迁时冻结并枚举所有引用才转而用转发指针加读屏障或染色指针加转发表去绕开槽位枚举并为此付出额外内存或读屏障开销G1 则借 STW 把枚举并改写所有槽位做成最简单、最确定的方案因此既不需要 Brooks 指针也不需要读屏障与转发表。本仓库的源码中Shenandoah 与 ZGC 均不存在相关机制不应套用到 G1。把 G1 与 jdk8u 中其余的 Serial、Parallel、CMS以及更晚进入主线的 Shenandoah、ZGC 放在一起比较见下一节。九、与其它收集器的实现对比HotSpot 在 jdk8u 里提供了 Serial、Parallel、CMS、G1 四套收集器加上更晚进入 OpenJDK 主线的 Shenandoah、ZGC。把它们的差异落到如何找到并改写引用、是否移动对象、何时停顿这几个维度上正好能把本文的主题串起来。Serial 与 Parallel纯 STW 的标记-压缩。Serial 是单线程的标记-清除-压缩Parallel 是它的多线程并行版本ParallelScavenge ParallelOld目标是通过并行缩短 STW 时间、提高吞吐量。两者都在一次停顿内完成全部工作先标记存活对象再把它们滑动压缩到空间一端。压缩阶段同样把对象头 Mark Word 当作转发指针——CompactibleSpace::scan_and_forward经forward_to把新地址写进 mark word随后调整所有引用。ParallelScavenge 的年轻代拷贝式提升promotion甚至和 G1 使用同一套cas_forward_to幂等原语。它们没有任何并发阶段也无需任何屏障因为停顿期间可以无遗漏地枚举并改写所有槽位。CMS并发标记但不并发搬迁。CMS 把标记与清除尽量并发化以降低停顿。它在并发标记期间不移动任何存活对象——老年代用空闲链表管理并发清扫只回收死亡对象留下的空隙、不做压缩。这就带来 CMS 著名的碎片问题以及晋升失败时的 Full GC 回退此时退化为 Serial Old 式的标记-压缩才用到转发指针。为了在并发标记时不漏掉应用线程修改的引用CMS 使用写屏障卡表 修改位图记录脏引用——注意是写屏障不是读屏障。CMS 与 G1 一样用写屏障跟踪引用变化但 CMS 从不并发搬移对象因此既不需要转发指针、也不需要读屏障来重定向访问。G1并发标记 STW 拷贝疏散。G1 的并发标记同样借助写屏障SATB 卡表与 CMS 异曲同工区别在于 G1 在 STW 疏散阶段真的把对象搬到新 Region并用 Mark Word 转发指针 原子 CAS 保证幂等。它兼具并发标记降低停顿与定期压缩消除碎片两方面的好处代价是疏散必须落在停顿内。Shenandoah 与 ZGC连搬迁也并发。更晚的 Shenandoah、ZGC 进一步把搬迁本身并发化对象在应用线程继续运行的同时被搬走旧对象靠 Brooks 指针额外 8 字节或染色指针当公告板访问时由读屏障重定向或查转发表。它们彻底绕开了枚举并改写所有槽位因而既没有 G1 那样的疏散停顿也不需要 STW 压缩。不过二者都要到 JDK 11 / 12 之后才进入 OpenJDK 主线本仓库源码中并不存在。把五者放在一张表里对照收集器并发标记并发搬迁转发指针重映射 / 整理屏障Serial否STW否STW 滑动压缩Mark Word压缩时forward_to滑动压缩到一端无Parallel否STW 并行否STW 并行压缩 / 年轻代拷贝Mark Wordcas_forward_to并行滑动压缩 / 年轻代拷贝无CMS是否并发周期不搬对象仅 Full GC 回退时Mark Word空闲链表不压缩碎片写屏障卡表 mod unionG1是否STW 拷贝疏散Mark Wordforward_to/cas_forward_to拷贝到新 Region写屏障SATB 卡表Shenandoah / ZGC是是Brooks / 染色指针并发拷贝 转发表读屏障 写屏障贯穿这张表的一条线是凡是搬迁发生在 STW 内的收集器Serial、Parallel、G1以及 CMS 的 Full GC 回退都可以放心地枚举并改写每一个槽位因此用 Mark Word 转发指针就足够无需读屏障只有把搬迁也并发化的 Shenandoah、ZGC才必须用读屏障去拦截每一次引用访问。G1 正是这条线上的并发标记 STW 疏散平衡点。