2624 字
约 8 分钟
15
S05-X30 · Sea-of-Nodes:JIT 中间表示与漂浮的指令
2024-12-31
2026-11-09

JIT 把字节码翻成机器码,中间要过好几次表示转换。最关键的一层不是汇编,而是"图":方法被表示成一组只声明数据依赖的节点,没有固定顺序,执行次序由调度阶段决定。HotSpot C2 与 Graal 用的都是这族表示,名字叫 Sea-of-Nodes。理解它,才能看懂 -XX:+PrintIdeal 的输出,也才能解释"为什么这段代码 JIT 之后反而更慢"。


1. 从字节码到机器码的管线

阶段 输入 输出 主要动作
解析 字节码 高层 IR(HIR) 栈式字节码展开成节点图,插入类型与范围信息
优化 HIR HIR(变形后) 内联、常量折叠、GVN、逃逸分析、循环优化
调度 无固定序的节点图 有固定序的块 依据控制边把节点分配到基本块
降级 HIR 低层 IR(LIR) 节点降为机器指令、寄存器与栈槽
生成 LIR 机器码 寄存器分配、指令选择、发射

C2 里这几段的日志开关分属不同层次:

# 哪些方法被编译、耗时多少
java -XX:+PrintCompilation -jar app.jar

# 某个方法的 IR(需诊断/调试构建,产品版部分开关不可用)
java -XX:+UnlockDiagnosticVMOptions -XX:+PrintIdeal \
     -XX:CompileCommand=print,com.example.Demo::hot -jar app.jar

注意最后一条:PrintIdeal 只在带调试信息的构建里完整可用,产品版 JDK 常被裁掉。按具体 JDK 发行版核对,不要假设所有 -XX:+Print* 都出现在生产环境。


2. Sea-of-Nodes 的形状:节点、输入边、没有顺序

普通 IR 是"基本块序列 + 块内指令序列",天然带执行顺序。Sea-of-Nodes 把顺序也变成一种依赖:节点只在有副作用或控制流时才连控制边,纯计算的节点漂浮在图上,谁需要它就连一条数据边。

元素 含义 举例
数据边 "我的输入是它" Add(x, y) 的两个输入
控制边 "我在它之后执行" If、Region、Return 的前驱
副作用节点 必须保序 Load、Store、Call、Allocate
Phi 合并不同路径的值 循环变量、分支汇合
Region 控制流汇合点 对应基本块入口
浮动节点 无控制依赖的纯计算 算术、比较、类型转换
Start
  └─ If(cond) ─┬─ true ──┐
               └─ false ─┴─ Region ─ Phi(a, b) ─ Return

Add、Mul 这类节点在图里不挂在任何 Region 下,它们"属于"整个方法,直到调度阶段才被拉进具体块。这个特性直接带来一个优化:同一表达式在两个分支里各写一次,GVN 会发现它们是同一个节点,只算一次。


3. 与 CFG / SSA 的对照

维度 传统 CFG + 指令序列 SSA Sea-of-Nodes
顺序 显式,块内固定 块内固定 隐式,由调度决定
变量版本 允许重定义 Phi 合并 同 SSA,节点即值
纯计算位置 绑定在块内 绑定在块内 全局漂浮
公共子表达式 需局部扫描 需支配关系分析 天然共享,同一节点
调度自由度 低 低 高,可跨块后移
调试难度 低 中 高,图容易爆炸

最后一行是真实代价:节点图规模随内联深度增长,一个被内联十几层的方法能产出上万节点的图。所以 C2 有 -XX:NodeLimitFudgeFactor、Graal 有节点数上限,超限直接放弃编译回退解释执行。


4. 调度:把漂浮的节点钉到块里

调度分两个方向,语义不同:

策略 做法 好处 风险
尽早调度(early) 节点尽量靠近其控制依赖前驱 缩短活跃区间 提前计算可能白算
尽晚调度(late) 节点尽量靠近使用点 减少寄存器压力、便于消除冗余 可能跨分支重复计算

实际实现取折中:纯计算往后推(靠近使用点),有副作用或可能抛异常的节点往前定(保持语义)。判断依据是"这个节点会不会改变可观察行为"——Load 可能抛 NPE、Call 可能有副作用,它们必须有确定的控制位置。

// 调度会直接影响这段代码的寄存器压力
int hot(int[] a, int n) {
    int s = 0;
    for (int i = 0; i < n; i++) {
        int x = a[i] * 2 + 1;      // 若提前算,活跃区间拉长
        s += x;
    }
    return s;
}

循环里的 x 若被尽早调度,会跨整个迭代体占用一个寄存器;尽晚调度则只在 s += x 前算,寄存器压力立刻下降。这正是"同样的图、不同的调度、性能差一截"的来源。


5. 依赖 IR 才能成立的四类优化

优化 依赖 IR 的什么特性 失效条件
GVN(全局值编号) 纯节点天然共享 节点带副作用、或含不可比较的浮点语义
方法内联 图可无限拼接 节点数超限、方法太大、调用点为 invokevirtual 多态
逃逸分析 + 标量替换 需看到对象的全部使用边 对象被存入字段、传给未知方法、被同步块捕获
循环展开与向量化 需识别循环的 Phi 与 Region 结构 循环体有异常路径、迭代次数不可判定
# 观察内联决策(为什么某个调用没被内联)
java -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining -jar app.jar | grep -A2 hotSpot

输出里 too big、already compiled、not inlineable 各自指向不同原因:方法体超阈值、递归、含 synchronized 或异常表。这类信息只能从内联日志拿到,读源码猜不出来。


6. Graal 与 OpenJ9:同一族思路的两种实现

Graal 用 Java 写 JIT,节点体系是公开 API,写起来像这样:

// Graal 风格:节点声明输入,顺序交给调度
public final class MyAddNode extends BinaryNode {
    public static final NodeClass<MyAddNode> TYPE = NodeClass.create(MyAddNode.class);

    public MyAddNode(ValueNode x, ValueNode y) {
        super(TYPE, StampFactory.forKind(JavaKind.Int), x, y);
    }

    @Override
    public void generate(NodeLIRBuilderTool gen) {
        gen.setResult(this, gen.getLIRGeneratorTool()
                .getArithmetic(Op.ADD, JavaKind.Int, false)
                .emit(gen.operand(getX()), gen.operand(getY())));
    }
}

开启 Graal 与 IR dump(需要 JVMCI):

java -XX:+UnlockExperimentalVMOptions -XX:+EnableJVMCI \
     -XX:+UseJVMCICompiler -Dgraal.Dump=:2 -Dgraal.PrintGraph=file \
     -jar app.jar

生成的图可以用 Ideal Graph Visualizer 打开,节点按控制边分层,Phi 与 Region 一眼可见。

OpenJ9(也就是常说的 J9)走的是另一条路:Testarossa 编译器用"扩展基本块树"表示,每个 TR::Node 挂在所属基本块下,顺序是显式的。所以它没有"漂浮节点 + 调度"这一步,优化以树变换为主。

维度 HotSpot C2 Graal OpenJ9 Testarossa
IR 形态 Sea-of-Nodes Sea-of-Nodes(Java 实现) 树 + 扩展基本块
调度 独立阶段 独立阶段 构建时即有序
可观测手段 PrintIdeal / PrintOptoAssembly IGV + graal.Dump -Xjit:verbose
调试构建要求 部分开关需 debug 版 需 JVMCI 需 -Xjit 选项

7. 排错:编译相关的六种异常

现象 根因 手法
方法跑很久也没被编译 调用计数未达阈值,或方法太大 PrintCompilation 看是否有该方法的编译记录
编译了但性能没变 关键循环在内联失败的方法里 PrintInlining 查 too big
性能周期性抖动 反复去优化(deopt) -XX:+TraceDeoptimization(诊断版)看 deopt 原因
加了日志后变快 日志改变了内联与逃逸分析结果 用 -XX:CompileCommand 单独控制目标方法
同一段代码两种耗时 分层编译不同层(C1/C2)差异 固定 -XX:-TieredCompilation 做对照,仅用于定位
Graal 下编译很慢 图规模大、编译线程不足 调 -XX:CICompilerCount、-Dgraal.CompilerConfiguration

第四条值得单独说明:观测会改变被观测对象。插入 System.out 或 -verbose:gc 之外的输出,会让逃逸分析判定对象逃逸、让内联预算被日志调用吃掉,结果"加了日志反而更快"。想保持语义不变地看 IR,应当用 CompileCommand 定向输出,而不是改业务代码。


8. 边界:什么时候别指望 IR 层面的优化

场景 更有效的手段 理由
单次执行的启动代码 AOT / CDS / 预热 JIT 需要调用计数,冷启动吃不到优化
大量小对象、短生命周期 减少分配、对象复用 标量替换有边界,逃逸分析不保证生效
IO 或锁竞争为主 异步化、减小临界区 JIT 优化的是 CPU 指令,不是等待
超长方法 拆分方法 节点数超限直接放弃编译
多态调用点 类型收敛 / 单态化 内联失败时优化链整条断掉

最后一条的核心机制:Sea-of-Nodes 的所有重优化都建立在"能看见被调用方的方法体"之上。invokevirtual 命中多个实现时,C2 只能内联其中一部分并插入类型检查,一旦运行时类型不符就退化成虚调用——所以"接口多实现"在热点路径上往往是性能陷阱,与算法无关。


9. 自测

  1. Sea-of-Nodes 里"没有顺序"具体指什么没有顺序,副作用节点为何例外?
  2. Region 与 Phi 分别解决什么问题?
  3. 为什么同一表达式写在两个分支里,在 Sea-of-Nodes 上可能只算一次?
  4. 尽晚调度能降低什么压力,代价是什么?
  5. PrintIdeal 在产品版 JDK 上为什么常常看不到完整输出?
  6. 加了日志之后代码变快,从 IR 角度看最可能发生了什么?
S05-X30 · Sea-of-Nodes:JIT 中间表示与漂浮的指令
https://www.clxhxhhr.top/posts/6023/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。