Java 派发方式实测:enum switch 与函数表究竟差多少

0. 背景

在定价、行情一类系统里,经常需要按品种类型选择不同的取值逻辑,例如外汇走一套曲线,商品走另一套曲线。落地上常见的两种写法是:

  • 枚举 switch:编译期确定分支,直接调用静态方法。
  • 函数表:用数组或 Map 保存 lambda / 方法引用,运行时按枚举序号取出接口实现再调用。

围绕后者,工程上流传两个说法:

  1. lambda 在热路径上会走 invokedynamic,所以比 switch 慢;
  2. 函数表是 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 }

interface Fetcher {
double fetch(int x);
}

private static double viaSwitch(Kind k, int x) {
switch (k) {
case FX:
return fetchFx(x);
case COMMODITY:
return fetchCommodity(x);
default:
throw new IllegalArgumentException("unsupported: " + k);
}
}

用 javap -p -c 查看 viaSwitch,核心指令为:

0: getstatic     #10   // Field DispatchBench$1.$SwitchMap$DispatchBench$Kind:[I
3: aload_0
4: invokevirtual #11 // Method DispatchBench$Kind.ordinal:()I
7: iaload
8: lookupswitch { // 2
1: 36
2: 41
default: 46
}
36: iload_1
37: invokestatic #12 // Method fetchFx:(I)D
40: dreturn
41: iload_1
42: invokestatic #13 // Method fetchCommodity:(I)D
45: dreturn

javac 会为枚举 switch 生成一个合成类,其中包含 static final int[] $SwitchMap$Kind。这个数组在合成类的静态初始化块里,按枚举常量的 ordinal() 逐项写入 1、2、3 等序号:

0: invokestatic  #1   // Method DispatchBench$Kind.values:()[LDispatchBench$Kind;
3: arraylength
4: newarray int
6: putstatic #2 // Field $SwitchMap$DispatchBench$Kind:[I
...
12: getstatic #3 // Field DispatchBench$Kind.FX:LDispatchBench$Kind;
15: invokevirtual #4 // Method DispatchBench$Kind.ordinal:()I
18: iconst_1
19: iastore

之所以不直接对 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];

static {
TABLE[Kind.FX.ordinal()] = DispatchBench::fetchFx;
TABLE[Kind.COMMODITY.ordinal()] = DispatchBench::fetchCommodity;
}

private static double viaTable(Kind k, int x) {
return TABLE[k.ordinal()].fetch(x);
}

静态初始化块中确实出现了 invokedynamic:

14: getstatic     #22   // Field TABLE:[LDispatchBench$Fetcher;
17: getstatic #62 // Field DispatchBench$Kind.FX:LDispatchBench$Kind;
20: invokevirtual #11 // Method DispatchBench$Kind.ordinal:()I
23: invokedynamic #63, 0 // InvokeDynamic #0:fetch:()LDispatchBench$Fetcher;
28: aastore

常量池中的 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);
double value = curve.interpolate(tenor);

这一步的成本通常是:

  • Map 查找:哈希、equals、可能的装箱与字符串比较,几十纳秒量级;
  • 曲线插值:分段查找、边界处理、若干次乘加,几十到几百纳秒量级;
  • 派发:约 0.3 ns,且在很多实现里已经被内联成零。

也就是说,context.curve(code) 的查找与插值比派发贵 1~2 个数量级。把 switch 改成函数表,或者反过来,收益大概率落在测量噪声里;而把曲线对象按品种预解析、随请求上下文传递、缓存插值系数,收益是可直接观测的。

优化顺序应当是:

  1. 用 async-profiler、JFR 或 JMH 找到真正的热点;
  2. 确认热点在查找、插值、锁、分配还是 I/O;
  3. 优先消除数量级更大的成本;
  4. 最后才讨论派发形式。

这也是本文最想强调的一点:派发方式的选择,首先是一个可维护性问题,其次才是一个性能问题。在 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
java -cp . DispatchBench

# 查看字节码
javap -p -c DispatchBench
javap -p -c 'DispatchBench$1'

# 查看内联决策(需要 debug 版 JVM 或诊断选项)
java -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining -cp . DispatchBench

如果想进一步确认巨态退化,可以把枚举扩展到 3 个以上,并让同一个循环里三种实现交替出现,再对比 -XX:+PrintInlining 的输出。

附录:完整测试代码

以下代码与实测版本逻辑一致,仅将控制台输出改为 java.util.logging。运行环境为 JDK 8,日志默认输出到标准错误流。

import java.util.logging.Level;
import java.util.logging.Logger;

/**
* 微基准:enum switch + 直接调用 vs 函数表(数组) + 接口调用。
*
* <p>对比三种场景:direct(无派发基线)/ mono(同一调用点只出现一种标的)/ bi(两种标的交替,
* 接口调用点呈双态)。轻体放大派发占比,重体模拟真实取值(一次 exp)量级。
*
* <p>注意:手写循环微基准只适合判断量级与有无差异,1% 级结论应使用 JMH。
*/
public final class DispatchBench {

private static final Logger LOG = Logger.getLogger(DispatchBench.class.getName());

enum Kind { FX, COMMODITY }

interface Fetcher {
double fetch(int x);
}

private static final Fetcher[] TABLE = new Fetcher[2];
private static final Fetcher[] TABLE_HEAVY = new Fetcher[2];

static {
TABLE[Kind.FX.ordinal()] = DispatchBench::fetchFx;
TABLE[Kind.COMMODITY.ordinal()] = DispatchBench::fetchCommodity;
TABLE_HEAVY[Kind.FX.ordinal()] = DispatchBench::fetchFxHeavy;
TABLE_HEAVY[Kind.COMMODITY.ordinal()] = DispatchBench::fetchCommodityHeavy;
}

private static double fetchFx(int x) {
return x * 1.0000001 + 1.0;
}

private static double fetchCommodity(int x) {
return x * 1.0000002 + 2.0;
}

private static double fetchFxHeavy(int x) {
return Math.exp(-0.0001 * x) * 1.0000001 + 1.0;
}

private static double fetchCommodityHeavy(int x) {
return Math.exp(-0.0001 * x) * 1.0000002 + 2.0;
}

private static double viaSwitch(Kind k, int x) {
switch (k) {
case FX:
return fetchFx(x);
case COMMODITY:
return fetchCommodity(x);
default:
throw new IllegalArgumentException("unsupported: " + k);
}
}

private static double viaTable(Kind k, int x) {
return TABLE[k.ordinal()].fetch(x);
}

private static double viaSwitchHeavy(Kind k, int x) {
switch (k) {
case FX:
return fetchFxHeavy(x);
case COMMODITY:
return fetchCommodityHeavy(x);
default:
throw new IllegalArgumentException("unsupported: " + k);
}
}

private static double viaTableHeavy(Kind k, int x) {
return TABLE_HEAVY[k.ordinal()].fetch(x);
}

private static final Kind[] KINDS = {Kind.FX, Kind.COMMODITY};

private static double sink;

public static void main(String[] args) {
final int n = 50_000_000;
double guard = 0;

for (int r = 0; r < 8; r++) {
guard += loopDirect(n, 0);
guard += loopSwitch(n, 0);
guard += loopTable(n, 0);
guard += loopSwitch(n, 1);
guard += loopTable(n, 1);
guard += loopSwitchHeavy(n, 0);
guard += loopTableHeavy(n, 0);
guard += loopSwitchHeavy(n, 1);
guard += loopTableHeavy(n, 1);
}

double[] res = new double[9];
for (int r = 0; r < 9; r++) {
res[0] += loopDirect(n, 0) / (double) n;
res[1] += loopSwitch(n, 0) / (double) n;
res[2] += loopTable(n, 0) / (double) n;
res[3] += loopSwitch(n, 1) / (double) n;
res[4] += loopTable(n, 1) / (double) n;
res[5] += loopSwitchHeavy(n, 0) / (double) n;
res[6] += loopTableHeavy(n, 0) / (double) n;
res[7] += loopSwitchHeavy(n, 1) / (double) n;
res[8] += loopTableHeavy(n, 1) / (double) n;
}

String[] names = {
"light direct ", "light switch mono ", "light table mono ",
"light switch bi ", "light table bi ",
"heavy switch mono ", "heavy table mono ",
"heavy switch bi ", "heavy table bi "
};
for (int i = 0; i < res.length; i++) {
LOG.log(Level.INFO, "{0} : {1} ns/op",
new Object[]{names[i], String.format("%.3f", res[i] / 9.0)});
}
LOG.log(Level.INFO, "guard={0}", String.format("%.3f", guard));
}

private static double loopDirect(int n, int mode) {
long t0 = System.nanoTime();
double s = 0;
for (int i = 0; i < n; i++) {
int x = i;
s += x * 1.0000001 + 1.0;
}
sink += s;
return System.nanoTime() - t0;
}

private static double loopSwitch(int n, int mode) {
long t0 = System.nanoTime();
double s = 0;
for (int i = 0; i < n; i++) {
s += viaSwitch(KINDS[mode == 0 ? 0 : (i & 1)], i);
}
sink += s;
return System.nanoTime() - t0;
}

private static double loopTable(int n, int mode) {
long t0 = System.nanoTime();
double s = 0;
for (int i = 0; i < n; i++) {
s += viaTable(KINDS[mode == 0 ? 0 : (i & 1)], i);
}
sink += s;
return System.nanoTime() - t0;
}

private static double loopSwitchHeavy(int n, int mode) {
long t0 = System.nanoTime();
double s = 0;
for (int i = 0; i < n; i++) {
s += viaSwitchHeavy(KINDS[mode == 0 ? 0 : (i & 1)], i);
}
sink += s;
return System.nanoTime() - t0;
}

private static double loopTableHeavy(int n, int mode) {
long t0 = System.nanoTime();
double s = 0;
for (int i = 0; i < n; i++) {
s += viaTableHeavy(KINDS[mode == 0 ? 0 : (i & 1)], i);
}
sink += s;
return System.nanoTime() - t0;
}

private DispatchBench() {
}
}