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. 自测
- Sea-of-Nodes 里"没有顺序"具体指什么没有顺序,副作用节点为何例外?
Region与Phi分别解决什么问题?- 为什么同一表达式写在两个分支里,在 Sea-of-Nodes 上可能只算一次?
- 尽晚调度能降低什么压力,代价是什么?
PrintIdeal在产品版 JDK 上为什么常常看不到完整输出?- 加了日志之后代码变快,从 IR 角度看最可能发生了什么?