Java 派发方式实测:enum switch 与函数表究竟差多少
0. 背景
在定价、行情一类系统里,经常需要按品种类型选择不同的取值逻辑,例如外汇走一套曲线,商品走另一套曲线。落地上常见的两种写法是:
- 枚举
switch:编译期确定分支,直接调用静态方法。 - 函数表:用数组或 Map 保存
lambda/ 方法引用,运行时按枚举序号取出接口实现再调用。
围绕后者,工程上流传两个说法:
lambda在热路径上会走invokedynamic,所以比switch慢;- 函数表是 O(1) 查表,分支一多就比
switch快。
本文用同一台机器、同一份代码,把两种派发放到“轻体”和“重体”两个量级下测量,再结合字节码与 HotSpot 的内联行为解释结果。结论可能和直觉相反:在两个分支的场景下,两者没有可测差异;真正值得优化的地方,往往根本不在派发上。
1. 结论先行
- 2 个分支下,enum
switch与函数表没有可测差异,实测差距 0.001~0.017 ns/op,属于噪声。 - 派发本身相对“无派发”基线只值约 0.33 ns;一次
Math.exp量级的重体运算约 178 ns,派发占比不到 0.2%。 - 函数表 +
lambda的热路径不是invokedynamic。indy只在调用点链接时执行一次(本文代码中发生在类初始化),热路径是invokeinterface。 - HotSpot 对双态(bimorphic,2 个实现)调用点会分别内联两段实现;实现增至 3 个以上、退化为巨态(megamorphic)后,才会失去内联,变成间接调用。
- 分支少(不超过 4 个)优先
switch:调用目标编译期确定、没有手工维护的索引契约、新增枚举时行为可预期。分支很多时,也应该把行为下沉到枚举或策略对象,而不是维护一张与枚举数量强耦合的数组。 - 在真实定价热路径上,
context.curve(code)的 Map 查找与曲线插值通常比派发贵 1~2 个数量级。优化应落在曲线解析、缓存与插值上。
2. 两种派发的字节码
2.1 enum switch
被测代码如下:
enum Kind { FX, COMMODITY } |
用 javap -p -c 查看 viaSwitch,核心指令为:
0: getstatic #10 // Field DispatchBench$1.$SwitchMap$DispatchBench$Kind:[I |
javac 会为枚举 switch 生成一个合成类,其中包含 static final int[] $SwitchMap$Kind。这个数组在合成类的静态初始化块里,按枚举常量的 ordinal() 逐项写入 1、2、3 等序号:
0: invokestatic #1 // Method DispatchBench$Kind.values:()[LDispatchBench$Kind; |
之所以不直接对 ordinal() 做 switch,是因为枚举常量不是编译期常量,且常量顺序可能变化。通过 values() 在运行期按常量名建立映射,可以保证枚举重排后语义不变。映射不到的值保持为 0,落入 default;如果源码没有写 default,则落入 switch 之后的代码。$SwitchMap 的初始化还包了 NoSuchFieldError 的捕获,用于兼容“编译后枚举常量被删除”的二进制场景。
热路径上,一次派发的指令序列是:
getstatic $SwitchMap -> ordinal() -> iaload -> lookupswitch -> invokestatic |
其中数组访问路径稳定,分支也可预测;关键点是 invokestatic 的调用目标在编译期已经完全确定。
2.2 函数表 + 方法引用
函数表的写法如下:
private static final Fetcher[] TABLE = new Fetcher[2]; |
静态初始化块中确实出现了 invokedynamic:
14: getstatic #22 // Field TABLE:[LDispatchBench$Fetcher; |
常量池中的 BootstrapMethod 指向 LambdaMetafactory.metafactory。每个 invokedynamic 调用点只链接一次:第一次执行时由引导方法生成并绑定调用点,之后就是普通调用。非捕获的 lambda / 方法引用在 HotSpot 中通常被实现为单例,不会每次调用都分配对象;捕获变量的 lambda 则会在每次求值时分配,二者需要区分。
真正进入热路径的是:
getstatic TABLE -> ordinal() -> aaload -> aload_1 -> invokeinterface Fetcher.fetch:(I)D |
所以“函数表在热路径走 invokedynamic”是一个误解。热路径是 invokeinterface,它的开销与内联行为取决于调用点的类型 profile,而不是 indy。
需要额外说明,函数表按 ordinal() 索引,隐含了两条契约:
- 数组长度必须不小于枚举常量数量;
- 每个枚举常量对应的槽位都必须初始化。
新增枚举常量而忘记更新函数表,会在运行时得到 ArrayIndexOutOfBoundsException 或 NullPointerException。相比之下,switch 至少不会因为数组越界而崩溃;如果显式写了 default 并抛出异常,新增常量导致的问题会在第一次执行时暴露出来。
3. 测量方法与结果
3.1 环境与场景
- JDK:Oracle JDK 1.8.0_371,HotSpot 25.371-b11,Linux x86_64。
- 每场景 5000 万次迭代。
- 三种派发:
direct(无派发基线)、switch、table。 - 两种分布:
mono(调用点只出现一种实现)、bi(两种实现按i & 1交替)。 - 两种体量:
light(一次乘加)、heavy(一次Math.exp后乘加,模拟真实取值量级)。 - 先跑 8 轮全场景预热,再跑 9 轮测量取平均;每轮结果累加到静态字段,防止被优化掉。
测试代码把每个场景封装为独立方法,计时在方法内部完成,循环体累加返回值并写入静态字段,避免结果被优化掉;switch 与 table 走相同的枚举选择和累加路径,唯一区别是派发方式。
3.2 结果
| 场景 | ns/op | 相对 direct |
|---|---|---|
| light direct | 2.037 | 1.00 |
| light switch mono | 2.364 | 1.16 |
| light table mono | 2.363 | 1.16 |
| light switch bi | 2.349 | 1.15 |
| light table bi | 2.366 | 1.16 |
| heavy switch mono | 178.194 | — |
| heavy table mono | 179.389 | — |
| heavy switch bi | 179.051 | — |
| heavy table bi | 181.348 | — |
表中 heavy 的绝对值只用于判断量级;不同机器、不同频率、不同 JIT 编译顺序都会带来偏差。
3.3 结果解读
轻体场景下,无派发基线是 2.037 ns/op,两种派发都落在 2.35~2.37 ns/op:
switch与table的差距只有 0.001~0.017 ns/op,小于测量噪声;- 派发加函数调用的总成本约 0.33 ns,占轻体单次操作的 16% 左右;
- 单态与双态之间也没有可测差异,说明两个实现的调用点仍然被有效内联。
重体场景下,一次 Math.exp 把单次操作推到 178~181 ns,四种组合的最大差约 1.7%。这个差异可能来自编译顺序、内联决策与运行间波动,但无论原因是什么,都远小于 Math.exp 本身的成本。派发本身约 0.33 ns,占 178 ns 的 0.2% 以下;即使把派发优化到零,端到端也感知不到。
这里必须强调方法学边界:手写预热循环的循环自身开销约 2 ns,和轻体场景处于同一量级。这种测法只适合判断“有没有数量级差异”和“方向对不对”,不适合裁决 1% 级别的优劣。要得到可发布的精确结论,应当使用 JMH:独立 fork、Blackhole、分轮测量、置信区间,以及避免常量折叠的输入生成。
4. 为什么没有差异:JIT 的视角
switch 与函数表在轻体下打平,原因不在字节码指令数,而在 HotSpot 对调用点的处理方式:
graph TD
A[invokeinterface 调用点] --> B{类型 profile 中的接收者种类}
B -- 1 种 --> C[单态: 内联唯一实现]
B -- 2 种 --> D[双态: 生成类型判断, 内联两段实现]
B -- 3 种及以上 --> E[巨态: 间接调用, 放弃内联]
C --> F[派发成本接近零]
D --> F
E --> G[每次调用都要走查表/虚调用]
对于函数表:
- 当调用点只出现一种实现时,C2 按单态处理,直接内联该实现。
- 当调用点出现两种实现时,C2 按双态处理,生成“接收者是 A 还是 B”的判断,并分别内联两段实现。多出来的是一次可预测的类型判断,成本极低。
- 当调用点出现三种及以上实现时,C2 将其视为巨态,不再内联,退化为间接调用。此时函数表既要付数组访问,又要付一次无法内联的接口调用。
对于 switch:
- 每个
case里的调用目标都是静态绑定的invokestatic,不存在接收者类型不确定的问题。 - 因此无论枚举有多少个分支,单个分支的调用点始终是单态的,只要内联预算允许,就能内联。
- 代价是代码体积:分支越多,内联后的调用方越大。超过内联阈值时,C2 仍可能放弃内联,或者只内联其中一部分。
这也解释了为什么在“两个分支、两种实现交替”的测试里,函数表没有吃亏:它处在双态区间,仍然享受内联。如果把这套代码直接扩展到十几个品种,且同一个调用点混合出现,函数表就会进入巨态区间,此时它相对 switch 的优势(如果有)会被间接调用抵消。
还有一个容易忽略的细节:switch 的 $SwitchMap 是编译器生成的,长度来自 values().length,新增枚举常量时映射自动扩展;函数表则是手写的,长度和槽位都要人工维护。前者的“隐性契约”由编译器兜底,后者的契约由开发者兜底,这是选型时比 0.01 ns 更值得考虑的因素。
5. 选型建议
| 条件 | 建议 |
|---|---|
| 分支不超过 4 个 | 优先 enum switch |
| 分支 5~8 个,且每个调用点单态 | switch 仍可;函数表可以作为数据驱动方案 |
| 分支很多,且需要运行期增删 | 枚举抽象方法或策略对象,而不是手写数组 |
| 同一调用点混合多种实现 | 先解决巨态:拆分调用点、预解析目标、让对象携带行为 |
| 新增分支需要编译期提醒 | Java 17+ 使用 switch 表达式,穷尽性检查会在编译期报错 |
switch 的优势:
- 调用目标编译期确定,不依赖运行期类型 profile;
- 没有手写数组的长度与槽位契约;
- 新增枚举常量时,遗漏分支会落入
default;使用switch表达式时则直接编译失败; - 代码即文档,阅读者不需要跳转到静态初始化块确认映射关系。
函数表的优势:
- 数据驱动,适合插件化、可配置、运行期注册的场景;
- 新增实现不必修改派发代码;
- 在分支非常多且调用点单态时,可以避免
switch的体积膨胀。
但要注意,函数表并不是“分支多就一定快”。Java 的 switch 对密集的 int 键会生成 tableswitch,对稀疏键生成 lookupswitch;枚举 switch 只是多了一次 $SwitchMap 映射。分支数量增加时,switch 的字节码体积和内联压力确实会上升,但真正决定性能的仍是调用点是否单态、能否内联,而不是“表”这个形式本身。
如果分支数量已经多到需要认真考虑派发成本,通常说明行为本身已经足够复杂,应该把它们下沉为枚举的抽象方法、策略对象或密封接口实现,让“选择行为”和“实现行为”在模型层面合一。此时派发方式只是实现细节,不应该成为设计的主导因素。
6. 真实热路径:0.3 ns 与几十纳秒的差距
在真实的定价热路径上,派发之前通常还有一步:根据品种代码找到对应的曲线,再做插值。类似:
Curve curve = context.curve(code); |
这一步的成本通常是:
- Map 查找:哈希、
equals、可能的装箱与字符串比较,几十纳秒量级; - 曲线插值:分段查找、边界处理、若干次乘加,几十到几百纳秒量级;
- 派发:约 0.3 ns,且在很多实现里已经被内联成零。
也就是说,context.curve(code) 的查找与插值比派发贵 1~2 个数量级。把 switch 改成函数表,或者反过来,收益大概率落在测量噪声里;而把曲线对象按品种预解析、随请求上下文传递、缓存插值系数,收益是可直接观测的。
优化顺序应当是:
- 用 async-profiler、JFR 或 JMH 找到真正的热点;
- 确认热点在查找、插值、锁、分配还是 I/O;
- 优先消除数量级更大的成本;
- 最后才讨论派发形式。
这也是本文最想强调的一点:派发方式的选择,首先是一个可维护性问题,其次才是一个性能问题。在 2 个分支、0.3 ns 的量级上纠结,属于把工程注意力放错了位置。
7. 方法学局限
本文的测量有几个明确局限,引用结论时应当一并考虑:
- 手写循环的自身开销约 2 ns,轻体场景下与派发成本同量级,只能看量级,不能裁决 1% 级差异;
- 没有使用 JMH,缺少独立 fork、Blackhole、死代码消除控制和置信区间;
mode是编译期可传播的常量,JIT 可能对单态与双态路径做不同的常量折叠,双态组还多了一次KINDS[...]数组访问;- 双态交替是分支预测器最友好的模式之一;真实流量的类型分布如果随机或长尾,分支预测与类型 profile 的表现会不同;
- 结果依赖具体的 JDK 版本、CPU 微架构、频率策略和后台负载,不应直接外推到其他环境。
因此,本文的结论只限于:在 JDK 8u371、本机、该测试形态下,2 个分支的 switch 与函数表没有可测差异;invokedynamic 不在热路径;双态调用点仍可内联。至于“哪个快 1%”,请交给 JMH。
8. 复现方式
javac -d . DispatchBench.java |
如果想进一步确认巨态退化,可以把枚举扩展到 3 个以上,并让同一个循环里三种实现交替出现,再对比 -XX:+PrintInlining 的输出。
附录:完整测试代码
以下代码与实测版本逻辑一致,仅将控制台输出改为 java.util.logging。运行环境为 JDK 8,日志默认输出到标准错误流。
import java.util.logging.Level; |