ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从Zero移植空实现看Shenandoah屏障与JVM可移植性设计

从Zero移植空实现看Shenandoah屏障与JVM可移植性设计

如果第一次在 JDK 源码树里沿着src/hotspot/cpu/zero/gc/shenandoah/这个路径往下走,你会和我一样产生一种错位感:Shenandoah 明明是依赖读写屏障做并发整理的 GC,而cpu/zero又恰恰是 HotSpot 里“连汇编都没有”的移植版本。这两个东西凑到一起,这份shenandoahBarrierSetAssembler_zero.hpp看起来就像一个矛盾集合体。等真正把文件打开,发现里面很可能只有几十行声明和空方法体,这时候你会更困惑:一个空实现为什么还能留在官方源码树里?它到底承担了什么职责?

这篇文章就从这个“矛盾文件”出发,把 Zero 移植的定位、Shenandoah 屏障机制、BarrierSetAssembler 的分层设计全部串起来。适合正在啃 HotSpot 源码的开发者、想理解 JVM 可移植性设计的工程师,以及准备把新 GC 迁移到非主流架构上的同学。看懂这份空文件,比看懂一份 500 行的 x86 汇编屏障更值钱,因为它是理解 JVM 平台抽象层最好的切片之一。

1. 从路径看设计:Zero 移植与 Shenandoah 是怎么被塞进同一个文件的

1.1cpu/zero到底是一个什么样的“CPU 移植”

HotSpot 源码里的cpu/目录按真实架构划分:x86、aarch64、ppc、riscv,每个目录下都有对应的汇编器、寄存器定义、模板解释器生成器、反汇编支持。zero目录和它们平级,但它不是一个真实 CPU 架构,而是一个用 C++ 模拟所有底层行为的移植版本。名字里的“zero”指的就是“零汇编”,Zero 移植的作者设计它的初衷,是让 HotSpot 核心逻辑可以在一台没有现成 JIT 后端、没有模板解释器支持的机器上跑起来。

具体到实现层面,Zero 移植做这几件事:字节码解释器是 C++ 写的 switch 分发解释器,不是 HotSpot 常见的模板解释器;汇编器相关类MacroAssembler提供了“看起来能用”的接口,但内部不做任何真正的机器码生成;寄存器类定义也全部是占位符。这样一来,HotSpot 的 runtime、GC、类加载、JIT 编译框架等模块都能通过编译,但实际执行时始终跑解释器解释执行。

这带来一个直接后果:凡是“给 JIT 编译代码插入机器码片段”的组件,在 Zero 上全都失去了意义。JIT 根本不存在,自然不需要真正生成屏障汇编。但源码层面不能让链接器和编译器报错,所以这些组件仍然要存在,只是实现退化成空操作或极简逻辑。

1.2 Shenandoah 需要汇编层做什么

Shenandoah 是 OpenJDK 里的低延迟并发垃圾收集器,核心思路是并发转移对象:GC 线程在 Java 线程继续跑的同时,把存活对象从当前集合复制到另一块区域,然后修正引用。这里有个经典问题——Java 线程手里可能早就拿到一个指向旧地址的引用,如果它在这个引用上做了读操作,需要立刻把它修正到新地址,否则轻则读到过期数据,重则和 GC 的并发移动冲突。Shenandoah 的解法是读屏障:每次从堆里加载引用时检查一下,如果该对象正在被并发移动,就用转发指针(forwardee)跳到新地址,并把当前引用的值也修复掉,这就是“self-healing”。

读屏障的插入位置,在 JIT 编译生成的本地代码里。编译器在生成getfield、getstatic、数组元素读取等字节码的汇编序列时,会调用一个屏障生成入口,在合适的位置插入一段固定模式的汇编指令序列。x86 上这段汇编要检查 GC 状态位、判断对象的标记位、必要时跳转到一个慢路径去执行转发解析。这个“屏障生成入口”在 HotSpot 中的面向对象抽象,就是BarrierSetAssembler。

ShenandoahBarrierSetAssembler则是 Shenandoah 对BarrierSetAssembler这个抽象的具体实现,文件名的构成规则是“GC 名 + BarrierSetAssembler + 平台名”。x86 版本叫shenandoahBarrierSetAssembler_x86.hpp,aarch64 版本叫shenandoahBarrierSetAssembler_aarch64.hpp,Zero 版本自然就叫shenandoahBarrierSetAssembler_zero.hpp。

1.3 两个世界交汇后产生的奇怪结果

当一个依赖真实机器码的 GC 屏障层,遇上一个根本没有机器码生成能力的移植平台,结果就是现在这份文件里的样子:类的骨架完整保留,所有屏障方法的实现为空。它告诉读者的信息非常明确:在 Zero 平台上,Shenandoah 的汇编层屏障不做任何事情,屏障职责全部交给解释器和运行时顶层的 C++ 逻辑去完成。

这样设计不是偷懒,而是刻意为之。如果 Zero 移植里把load_reference_barrier写成should_not_reach_here(),等于直接放弃了 Zero 上跑 Shenandoah 的可能性;如果强行实现一段 C++ 伪汇编,那又毫无意义,因为根本没有消费方。源码保守地把所有方法留空,同时保证整个 GC 子系统可以编译链接通过,这是一种“显式申明不支持”的安全占位。

2. 打开文件前必须搞懂的两个机制:读屏障和 BarrierSetAssembler

2.1 Shenandoah 读屏障要解决什么问题

假设 Java 线程执行到Object v = obj.field;,JIT 编译后就是一条load指令从堆内存取数据。如果 GC 此时正在并发移动obj指向的对象,线程拿到的可能还是旧地址。对应的对象头里会被写入一个特殊标记和转发指针,转发指针直接指向复制好的新对象,读屏障的任务就变成了:检查加载出来的引用,发现它处于被转发状态,就用新地址把它替换掉。

这套机制要在延迟和正确性之间找平衡,不能让每次普通读引用都付出沉重代价。所以 x86 平台的汇编实现会尽量精简快路径:先用一条指令检查 GC 是否进入标记/转移阶段,再检查对象头标记,大多数时候这些检查会直接穿过,只有极少数遇到正在移动对象的线程才会进入慢路径做完整转发。整个过程必须侵入编译代码,这也就是为什么屏障必须由汇编器层在 JIT 代码生成阶段介入。

理解这一点后,再看 Zero 平台的空实现就会明白:没有 JIT 生成的机器码,没有需要埋入屏障指令的“编译代码”,汇编层屏障自然无事可做。屏障逻辑只能放在解释器逐条字节码的处理函数里,因为解释器本来就是 C++ 函数在执行字节码,它天生就可以在 C++ 层面直接调用 GC 的屏障函数。

2.2 BarrierSetAssembler 在 HotSpot 里的位置

HotSpot 的 GC 屏障体系抽象得比较细,常见的几个类是BarrierSet、BarrierSetAssembler、BarrierSetC1、BarrierSetC2。BarrierSet是运行时层面的大管家,负责屏障策略决定、是否启用某类屏障、运行时慢路径处理。BarrierSetAssembler则专门处理汇编生成层面,把“在哪个地址加载、用哪个寄存器保存结果、加载之后怎么修”等具体事情变成机器码片段。BarrierSetC1和BarrierSetC2分别给 C1 和 C2 编译器提供高层语义,它们最终也会调用BarrierSetAssembler的接口去发射真实汇编。

所以ShenandoahBarrierSetAssembler是链条的底层执行者。x86 平台上的实现会重写加载屏障、存储屏障、克隆屏障等方法,里面是 XML 都描述不清的寄存器操作序列,属于 HotSpot 移植里最容易写崩的代码区。而 Zero 移植的做法是直接把这个执行者留空,让上层 C1/C2 即使调用了也得不到任何指令输出。

2.3 为什么官方源码一定要保留这个空文件

很多人会问:既然 Zero 上没有 C2,C1 也是残缺状态,为什么不直接删掉这个文件?原因有两层。第一,构建系统是按文件清单编译的,很多平台无关代码的#include路径是硬编码的,比如某个头文件无条件包含gc/shenandoah/shenandoahBarrierSetAssembler_<CPU>.hpp,这个宏在 Zero 上必须展开出一个存在的文件才能继续。第二,HotSpot 源码经常通过 grep 或静态分析来检查跨平台接口完整性,每个移植目录下都要有对应文件才能保证子类覆盖检查通过。所以这个文件存在的第一使命是“满足工程构建与接口完整性”。

它是一个工程妥协的产物,但恰恰是这个妥协点,把 JVM 可移植设计里最微妙的部分暴露出来了。空文件不代表功能为空,只代表该功能在当前平台的实现策略为“由其他层承担”。

3. 文件内容拆解:几十行声明里藏着的设计意图

3.1 类声明方式与继承关系

这段分析基于常见的跨版本结构,以某个主流 OpenJDK 版本为例。shenandoahBarrierSetAssembler_zero.hpp内部通常先做双重 include guard,然后包含平台无关的shenandoahBarrierSetAssembler.hpp,再声明一个class ShenandoahBarrierSetAssembler。它一般继承自BarrierSetAssembler,而不是像 x86 那样直接紧密耦合到特定 CPU 的寄存器布局。

继承关系决定了一个关键行为:所有虚方法在基类里已有默认实现,Zero 版本可以只覆盖需要的方法,剩下的保持基类默认。这样设计的好处是升级 GC 功能时,如果某个新屏障方法在 Zero 上没有意义,代码里可以完全不写,编译器不会报错。源码阅读者看到这种继承关系,应当立刻意识到:要判断一个方法到底做什么,不能只看 Zero 文件,要去查基类BarrierSetAssembler里的默认实现和 x86 版本里的覆盖实现。

3.2 方法清单里的每个名字都是什么意思

这类文件里出现频率最高的方法有三个:load_reference_barrier、storeval_barrier、keep_alive_barrier。load_reference_barrier负责在读引用后做转发检查,是 Shenandoah 的核心屏障,在 x86 上它的实现最复杂。storeval_barrier负责存储值屏障,防止把旧地址写回堆中对象字段,尤其在 GC 并发移动期间要保证存储到堆里的引用都是最新地址。keep_alive_barrier负责在对象生命周期结束时保持对象存活,防止并发回收误判。

另外还可能看到clone或arraycopy相关屏障。这些方法在 x86 版本中是一条条真实汇编指令和跳转标签,在 Zero 版本里全部只有空函数体或者返回语句。看到这些空函数体,第一反应不应是“这平台不支持 Shenandoah”,而应是“这平台不支持通过 JIT 汇编插入屏障这种方式”。语义上的区别很重要:运行时仍然可以处理并发转移,只是入口不同。

3.3 空实现的真正含义是重定向职责

既然汇编层不做任何事,那谁来做屏障?答案是解释器访问器和运行时函数。Zero 的解释器在处理getfield字节码时,会走到一段 C++ 逻辑,那里会调用ShenandoahBarrierSet::resolve_forwarded或者类似的运行时函数,先判断 GC 状态,再判断引用对应的对象头,决定是否解析转发指针。这段 C++ 逻辑天然工作在 Java 语义层,不需要寄存器分配,不需要生成机器码,但效果和汇编屏障完全一致。

所以 Zero 版头文件的空方法是在向 reader 传递一条信息:屏障责任被转发到ShenandoahBarrierSet的运行时 C++ 方法。这也是为什么很多初读源码的人会误判“Shenandoah 在 Zero 上不可用”,实际上解释器模式下它可以运行,只是并发转移带来的一部分性能优化(比如汇编屏障快路径)享受不到。

4. 空文件之外的完整执行路径:Zero 上 Shenandoah 屏障的真实落地

4.1 解释器字节码分派时的屏障调用链

Zero 的解释器是 switch 解释器,每个字节码是一个switch-case分支。比如处理getfield时,解释器先从常量池拿到字段偏移,再从对象内存中加载引用字段。加载动作发生在 C++ 函数内部,在这条加载语句前后可以顺便插入 GC 屏障逻辑。官方实现一般会在解释器访问器(InterpreterMacroAssembler或直接解释器 entry)层面调用BarrierSet接口。

具体到流程上:解释器先查询BarrierSet::barrier_set()->barrier_set_assembler(),拿到一个汇编器对象,然后调用它的load_reference_barrier方法。在 Zero 上这个方法什么都不做,接着解释器又会走一遍自身的显式屏障代码路径,即直接调用ShenandoahBarrierSet的静态方法判断与修复引用。

这条链路里很多人漏掉一个细节:解释器实际上有两条可走的路。一条是通用 JIT 用的BarrierSetAssembler,Zero 里为空;另一条是解释器自己的barrier_set()运行时调用,这条在 Zero 上是真实工作的。所以一个引用加载在 Zero 解释器里经历了“空汇编方法调用 + 真实的运行时屏障检查”,运行结果和 x86 上 JIT 指令流里的检查一致,只是效率差很多。

4.2 GC 并发转移时 Java 线程如何自愈

假设一个 Java 线程在 Zero 上执行读引用操作,此刻 Shenandoah 的 GC 线程正好完成了某个对象的复制并把旧对象标记为转发状态。解释器在运行时屏障检查里会读对象头的标记字段,发现is_forwarded状态后,调用ShenandoahForwarding::get_forwardee拿到新地址。然后把寄存器里的旧引用替换成新地址,同时还可以把堆中某个包含旧地址的字段一起修正,这就是 self-healing 在解释器路径上的实现。

由于 Zero 没有汇编快路径,每一次引用加载都必须在 C++ 函数里做完整的分配检查、状态检查、对象头读取,开销比 x86 汇编快路径高出不止两个数量级。这也是为什么在生产环境里几乎没人会在 Zero 平台上启用 Shenandoah,性能模型完全不同。但如果只是做功能验证、研究 GC 语义正确性、或者用不了其他平台做测试时,这套路径提供了一个完整的参考实现。

4.3 哪些代码会真正引用这个类

虽然文件本身只有几十行,引用它的代码不少。JVM 编译器层的BarrierSetC1和BarrierSetC2在平台相关部分会包含shenandoahBarrierSetAssembler_<CPU>.hpp并调用它的接口。Zero 构建里 C1 和 C2 通常被关闭,但代码仍然要过语法与链接检查,所以文件必须给出完整的类定义。runtime 层也有一些工具类会通过barrier_set_assembler()拿到实例做类型判断或调试输出。

正因为它被引用,改动这个文件时反而要特别小心。如果某个团队想给 Zero 平台加一段伪汇编支持,或者把空方法改成fatal错误,必须同时检查所有调用点的前置条件。否则一个 null 检查没做好,可能导致整个 VM 在启动阶段就崩掉。源码阅读时关注“谁 include 了这个头文件”比关注文件本身更有效,推荐用 IDE 的 find usages 功能把所有引用过一遍。

5. 把三个平台摆在一起对比,JVM 可移植性的设计逻辑才真正浮出水面

5.1 同一组接口,三种完全不同的实现策略

拿load_reference_barrier这一个方法举例,三个平台的实现差距非常直观:

平台实现方式关键行为性能特征
x86_64手写汇编指令流快速状态检查 + 跳转慢路径快路径极短,CPU 流水线友好
aarch64ARM 指令流 + 内存屏障类似的快速检查 + 平台特有屏障指令快路径稍长,但匹配 ARM 体系
zeroC++ 空方法不做任何生成无汇编开销,但依赖解释器运行时

这里能看出 HotSpot 移植的一个习惯:同一份接口在不同平台上的“语义真实度”完全不同。x86 和 aarch64 会尽量把屏障做进编译代码里,追求低开销;Zero 则把语义完全移交给运行时层。开发者在阅读时不能默认所有平台实现质保一致,必须结合该平台的整体能力来评估。

5.2 BarrierSetAssembler、BarrierSetC1、BarrierSetC2 的分工边界

三者虽然都属于屏障体系,但各管一段。BarrierSetC1负责 C1 JIT 编译时的屏障逻辑,比如要分配哪些寄存器临时变量、在哪里插入状态检查点、慢路径代码如何生成。BarrierSetC2对应 C2 JIT,它处理更复杂的图优化,比如把屏障合并、消除、提升到循环外。这两个层都在编译器的 IR 层面工作,最后通过BarrierSetAssembler生成机器码。

Zero 上 C1 和 C2 基本不运作,所以这三个类对 Zero 的意义主要是“编译框架完整性”。BarrierSetAssembler_zero的空方法则承载了链接与编译期占位功能。搞清楚这点后,再回头看这个文件,就明白它不是一个功能模块,而是一个接口完整性组件,它的存在让整个 GC 子系统能顺利编进 Zero 移植,而不是阻止功能。这种设计思路在其他子系统里也常见:宁可空实现,不可缺失入口。

5.3 一个危险话题:能不能给 Zero 实现真正的屏障汇编

从纯技术角度说,可以给 Zero 的load_reference_barrier填上真正生成调用序列的代码,但它没有消费方。没有编译器会调用这个汇编器发射指令,解释器也不需要它。这么做只会引入一个从未执行的代码分支,增加维护成本。所以官方选择留空是对的。

真正可能给 Zero 带来改进的方向,是优化解释器路径的屏障检查次数,而不是修复空方法。比如某些读引用指令可以合并检查,减少 C++ 函数调用次数;或者在解释器循环里预取 GC 状态标志,用一次公共检查替代多次重复检查。这些优化属于解释器与 BarrierSet 之间的协议层,不属于BarrierSetAssembler的职责范围。读源码时要注意避免把责任推给错误组件。

6. 如果参照这份文件去移植新 GC,你能学到什么

6.1 新 GC 移植到 Zero 平台要准备的文件清单

假设你正要把一个自研 GC 移植到 Zero 平台,照着 Shenandoah 的布局来做可以少走很多弯路。至少需要准备:一个xxxBarrierSetAssembler_zero.hpp,声明继承自屏障基类并保留空方法;一个xxxBarrierSet_zero.cpp或在平台无关文件里处理构建开关;解释器访问器文件里新增对xxxBarrierSet运行时方法的调用;构建脚本里把新平台的编译宏和排除逻辑配好。

这里有个容易踩的坑:不要以为空头文件就够了。Shenandoah 之所以能跑在 Zero 上,是因为它的运行时屏障逻辑是在ShenandoahBarrierSet的 C++ 代码里完整实现的。如果你的 GC 只在汇编层做屏障,移植到 Zero 后会遇到功能缺失。先确定“没有汇编之后屏障逻辑靠哪段 C++ 承担”,再动笔写空头文件,顺序不能反。

6.2 排查问题时的切入思路

遇到 Zero 平台上 GC 功能异常,第一反应不是查BarrierSetAssembler_zero,它多半是无辜的。先看解释器代码里是否调用了对应的barrier_set()方法,再确认 GC 的运行时转发函数是否被正确触发,最后用日志验证。Shenandoah 相关的日志开关一般有-Xlog:gc+shenandoah=debug、-Xlog:gc+barrier=debug,可以观察读屏障命中情况、转发次数和停顿时间。

常见的一个坑是误以为空方法会导致 null 指针或者漏屏障。实际上由于解释器路径会再次调用运行时屏障,空汇编方法不会造成正确性错误。真正需要警惕的是配置了 C1 编译且某个平台代码分支试图访问BarrierSetAssembler的非空实现,这时候可能跳到一个未初始化区域。遇到这种情况优先关闭该平台的 JIT 编译,而不是调试空方法本身。

6.3 从这份文件里学到的“空实现”哲学

在大型开源项目里,很多人不敢删空文件,生怕删出问题;也有很多人看到空文件就以为没用,想直接干掉。这两种极端都不对。正确态度是先把调用链梳理完,理解空实现承担的“接口占位”职责,再决定去留。这份文件的核心作用不是提供代码,而是保持系统的抽象一致性,让平台无关代码对每个平台都有可预期的编译入口。

做平台移植的经验告诉我:一个平台最危险的地方,往往不是复杂实现,而是看似空无一物却被到处引用的占位代码。它安静地待在源码树里,一旦有人误以为它没有价值而改动依赖关系,整个构建立刻出问题。读 HotSpot 源码的时间越长,越觉得这类“空文件”反而比大型汇编文件更考验功底。

如果你最近也在研究 GC 移植或者 JVM 跨平台实现,建议先从这个文件入手,把它的引用链、基类设计、解释器路径全部走通,再回头读 x86 版本的汇编屏障,理解深度会完全不一样。我做移植时就是先用空文件建立整体认知,再逆着调用链去补具体平台的机器码实现,这条路走下来比单纯逐行读汇编快很多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进