一个热点分支被编译后反而变慢
假设一个高吞吐订单服务里有个热点方法:绝大多数请求走快速路径,只有约 1% 的请求需要走慢速路径做额外校验。方法体很短,返回两个常量之一。直觉上,这段代码应该被 C2 编译成一条条件判断加一次跳转,快速路径几乎总被预测命中。但把编译后的机器码反汇编出来,可能会看到一条 cmovne,也就是条件传送(CMov,conditional move)——两个分支的结果都被计算出来,再用一条指令根据条件选择。
如果慢速路径真的只有 1%,CMov 会让每次调用都多算一份本可跳过的结果,而分支预测器本来能近乎完美地预测这个分支。结果就是吞吐下降、每操作周期数上升。这类现象不是“C2 有 bug”,而是代价模型在分支概率、分支预测失败代价和 CMov 额外计算之间做权衡时,选择了对当前概率分布不利的一侧。
理解这个取舍,需要先弄清 C2 在哪个阶段做转换、它拿什么数据估计分支概率,以及 CMov 自身在什么条件下才划算。
分支与 CMov 在机器层的差别
在指令集层面,实现“根据条件从两个值中选一个”有两种做法。第一种是分支:比较条件,条件成立就跳到一段代码,否则落到另一段,两条路径各自只计算自己需要的结果。第二种是条件传送:先把两个候选值都算出来,再用一条 cmov 指令按条件把其中一个写进目标寄存器。
两者的成本结构完全不同。分支的开销取决于分支预测:预测正确时几乎零额外开销,预测错误时流水线要清空并重新取指,代价通常以几十个周期计。CMov 没有预测失败问题,但它要求两条路径的输入都先算好,这会带来额外的指令、寄存器压力和可能的依赖链延长。
Aleksey Shipilëv 在 JVM Anatomy Quark 第 30 篇里用一个 50%~50% 翻转的分支做了对照实验:关闭 CMov 转换后,分支版本反而更慢,而硬件分支预测器对规律翻转的分支几乎不产生预测失败。这说明 CMov 的收益并不总来自“消除预测失败”,有时来自消除控制流跳转本身——分支版本在关键路径上多了一条跳转指令。
这带来一个关键判断:CMov 是否划算,取决于“分支预测失败的概率与代价”和“无条件计算两侧结果的代价”谁更大。C2 的代价模型正是在估计这两者。
C2 在哪里把 if 变成 CMov
C2 在理想图(Ideal Graph)上做优化。Java 字节码里的 if 在理想图中表现为一个 If 节点,它分出两条控制流,各自计算一个值,最后在 Region 节点汇合,再由一个 Phi 节点根据来自哪条路径选择对应的值。
把这种菱形结构转成 CMov,本质上是用一个条件选择节点替换掉 If 加 Phi 的组合。OpenJDK 源码 src/hotspot/share/opto/ifnode.cpp 里的 PhaseIdealLoop::conditional_move 负责这个转换。它不会对所有菱形都做转换,而是先做一系列可行性检查,再用一个阈值判断是否值得转换。
转换的可行性前提包括:两条路径上的值必须都是可以无条件求值的简单表达式,路径中不能有副作用、异常抛出或内存写入;如果某条路径会抛异常,就不能提前计算,因为那会改变可观察行为。只有当两侧都能安全地提前求值时,CMov 才在语义上等价。
阈值判断则依赖分支概率。C2 在解释执行和早期编译阶段会收集分支走向的剖面数据,这些数据进入理想图后,If 节点上会带有 taken 和 not_taken 的概率估计。conditional_move 会看两条路径的概率分布:如果某条路径明显更可能执行,转换的收益就下降,因为分支预测器很可能已经能预测它。
分支概率从哪来,又有多准
C2 的分支概率不是凭空猜的,主要来自两类信息。一类是解释器和 C1 编译代码在运行中记录的分支剖面,包括每个分支被走向两个方向的次数;另一类是方法内联后继承的概率信息。这些数据在方法被 C2 编译时被读取,转成 If 节点上的概率。
概率估计的精度受采样窗口影响。剖面数据是累计的,如果服务启动初期流量模式与稳态不同,早期数据会稀释稳态分布。一个分支在压测阶段是 50%~50%,上线后变成 99%~1%,如果方法在这两个阶段之间没有被重新编译,C2 可能仍按旧概率做决策。
另一个限制是,C2 做 CMov 转换时看的是理想图上的静态结构,而不是运行时逐次的分支结果。它无法知道某个具体调用点的分支在最近一千次里是否连续命中。硬件分支预测器能看到更细的历史,C2 只能看到统计分布。这个信息差正是代价模型可能失准的根源之一。
代价模型里的两个量
PhaseIdealLoop::conditional_move 的阈值逻辑,核心是比较两个量:分支被错误预测的期望代价,以及无条件计算两侧结果的期望代价。
分支一侧的代价可以粗略写成“预测失败概率 × 预测失败惩罚”。预测失败概率与分支概率分布直接相关:概率越接近 50%~50%,预测器越难命中;概率越极端,预测器越容易命中,失败概率越低。
CMov 一侧的代价是“两侧计算都执行”带来的额外指令和资源占用。如果两条路径各自只是返回一个常量,额外代价很小;如果某条路径包含除法、数组访问或长依赖链,提前计算它的代价可能远高于一次预测失败。
OpenJDK 的 PR #16524(标题为 PhaseIdealLoop::conditional_move is too conservative)讨论的正是这个阈值。该 PR 指出,原实现把阈值设成接近 BlockLayoutMinDiamondPercentage,默认约 18%,理由是 BlockLayoutByFrequency 优化会把不常走的分支移出热路径,因此没必要做 CMov。PR 作者认为这个理由不成立,因为让跳转变贵的关键属性不是代码布局。PR 中给出的基准数据显示,当不常走路径的频率高于约 1% 时,CMov 版本表现更好;频率低于该水平时,分支版本更优。这些数字来自该 PR 作者本机上的 JMH 基准,属于特定硬件和特定基准的结果,不能直接外推到所有平台。
这个讨论揭示了代价模型的一个现实:阈值不是普适常量,它随微架构、分支预测器能力和两侧计算的相对代价变化。
用 PrintIdeal 和反汇编观察转换
判断一个热点分支是否被转成了 CMov,可以从两个层面观察。
第一个层面是理想图。用 -XX:+PrintIdeal 或 -XX:+PrintIdealGraphLevel 可以让 C2 输出编译过程中的理想图。在输出里搜索 CMove 节点,如果原本的 If 和 Phi 被一个 CMove 取代,说明转换发生了。理想图输出还能看到 If 节点上的概率标注,帮助判断 C2 当时认为分支有多不平衡。
第二个层面是最终机器码。用 -XX:+PrintAssembly 配合 -XX:+UnlockDiagnosticVMOptions 可以打印 JIT 编译后的汇编。在输出里搜索 cmov,如果热点方法里出现了 cmovne、cmove 等指令,说明该分支在机器层被转成了条件传送。
两个层面需要一起看。理想图上有 CMove,机器码上不一定有 cmov,因为后端可能再把它展开回分支;反过来,机器码上有 cmov,理想图上通常能找到对应的 CMove。
下面这个流程图展示了一次典型观察路径:从热点方法被识别,到 C2 编译、概率估计、转换决策,再到最终机器码与性能信号。
flowchart TD
A[热点方法被 C2 编译] --> B[读取分支剖面概率]
B --> C[理想图上 If 分出两条路径]
C --> D[conditional_move 检查可行性]
D -->|两侧可安全提前求值| E[比较分支失败代价与两侧计算代价]
D -->|有副作用或异常| F[保留 If 与 Phi]
E -->|CMov 更划算| G[生成 CMove 节点]
E -->|分支更划算| F
G --> H[后端生成 cmov 指令]
F --> I[后端生成条件跳转]
H --> J[PrintIdeal 看到 CMove]
I --> K[PrintIdeal 看到 If 与 Phi]
J --> L[PrintAssembly 看到 cmov]
K --> M[PrintAssembly 看到 jcc 跳转]
L --> N[对比吞吐与分支失败计数]
M --> N
概率极不平衡时 CMov 为什么变慢
回到贯穿场景:99% 快速路径、1% 慢速路径。分支预测器对这种极端分布几乎总能命中,预测失败概率很低。此时分支一侧的期望代价很小。
CMov 一侧则要无条件计算慢速路径的结果。如果慢速路径只是返回一个常量,额外代价可能只有一两条指令,影响有限;如果慢速路径包含一次数组访问、一次除法或一次缓存未命中概率较高的加载,那么每次调用都要多付这份代价,而 99% 的调用本可以完全跳过它。
PR #16524 的基准数据也指向同一方向:不常走路径频率在约 1% 以下时,分支版本更优;高于约 1% 时,CMov 版本开始占优。这个交叉点说明,CMov 的收益来自“分支预测失败概率随概率分布变平而上升”,一旦概率足够极端,失败概率低到不足以抵消 CMov 的额外计算。
需要注意的是,这个交叉点不是固定值。它取决于两侧计算的代价比、目标 CPU 的预测失败惩罚、以及代码布局。把它当成 1% 的硬阈值会误导决策。
与替代方案的对比
面对一个热点分支,工程上有几种处理方式,各自适用条件不同。
| 方案 | 吞吐表现 | 延迟特征 | 额外成本 | 适用条件 |
|---|---|---|---|---|
| 保留分支,依赖硬件预测 | 概率极端时最好 | 预测失败时出现尖峰 | 无额外计算 | 分支概率稳定且不平衡 |
| 让 C2 转成 CMov | 概率接近 50%~50% 时较好 | 延迟更平稳 | 两侧计算都执行 | 两侧计算代价低且可提前求值 |
| 手动改写为无分支算术 | 可预测性高 | 延迟稳定 | 代码可读性下降 | 两侧计算都是简单算术 |
| 拆分快慢路径为独立方法 | 快路径更短 | 快路径延迟低 | 代码重复 | 慢路径逻辑复杂且可独立 |
这张表里的“吞吐表现”和“延迟特征”是定性判断,具体数值取决于微架构和两侧计算。没有资料支持统一数字时,不应把某一列写成绝对结论。
手动改写为无分支算术(例如用掩码和位运算选择结果)与 CMov 有相似的成本结构,但把决策权从编译器移到了源码,牺牲可读性换取确定性。拆分快慢路径则把概率信息交给方法边界,让 C2 分别编译两条路径,代价是代码重复和可能的调用开销。
失败模式与诊断路径
CMov 相关的性能问题有几类典型表现。
第一类是概率估计失准。方法在流量模式变化前被编译,C2 按旧概率做了 CMov 转换,稳态下分支其实极不平衡。诊断方法是看 PrintIdeal 输出里的概率标注是否与实际流量一致;如果怀疑剖面过期,可以让方法重新进入解释执行再编译,观察转换是否改变。
第二类是两侧计算代价被低估。慢速路径里有缓存未命中概率高的加载或长依赖链,C2 的静态代价估计没有充分反映它。诊断方法是对比开启和关闭 CMov 转换后的吞吐,并观察 cmov 指令附近是否有高延迟操作。
第三类是转换后寄存器压力上升。两侧结果都要保留到选择点,寄存器分配器可能因此溢出到栈,增加内存访问。诊断方法是看反汇编里 cmov 前后是否出现额外的 mov 到栈或从栈加载。
第四类是理想图上有 CMove 但机器码上仍是分支,或反之。这通常说明后端做了二次决策,或者转换发生在内联之后又被其他优化改写。需要以最终机器码为准。
什么时候不该依赖 CMov 判断
CMov 的代价模型是 HotSpot C2 的实现细节,不是 JVM 规范保证的行为。不同 JDK 版本、不同 CPU 架构、不同 C2 优化开关下,同一个分支可能得到不同的转换结果。把某次 PrintAssembly 的输出当成跨版本结论,会在升级 JDK 后失效。
分支概率剖面也有边界。它反映的是方法被编译前的历史,不保证未来。对于流量模式周期性变化或突发的服务,静态概率估计可能长期偏离。
如果两侧计算不能安全提前求值——比如某条路径会抛异常、写内存或调用有副作用的函数——CMov 在语义上就不成立,C2 不会转换,这时讨论代价模型没有意义。
最后,CMov 只是 C2 众多分支相关优化之一。分支概率还会影响代码布局、循环展开和内联决策。单独盯着 cmov 指令,可能忽略同一概率数据在其他优化上的作用。判断一个热点分支是否适合 CMov,需要同时看概率分布、两侧计算的代价、目标微架构的预测失败惩罚,以及转换后机器码的实际形态。