FutureTask 管一个任务从提交到结束的全部状态,字段是 volatile int state,七个常量,靠 CAS 迁移。ExecutorServiceCompletableFuture 都建在它上面,JDK 没给它写七个状态类。
GoF 的状态模式走另一条路:一个状态一个类,事件交给多态处理,状态自己决定下一步。书上那个 TCP 连接例子好写,代价没有现成数字。
本文把同一个四状态状态机写成五种形式,在 JDK 21 与 25 上各跑 5033 万次事件,量耗时与分配,再用字节码和 JIT 日志解释差距从哪来。

一、状态放在哪里

状态的存储方式和迁移的决策方式可以拆开看。GoF 把两件事绑在一起:状态是一个对象,迁移由虚调用决定。JDK 里常见的是另一种组合:状态是一个 int,迁移是一串比较和位运算。

两边的差别用一台四状态、五事件的状态机就能摆出来,后面所有实验共用它:

终态有两个(DONEFAIL),对 RESET 的行为相同,对其他事件也相同,只有外部观察时才需要区分。4 个状态 × 5 个事件共 20 种组合,合法的只有 6 种,其余 14 种到达时什么都不做;事件流里非法事件比合法事件多。

五种写法

同一台机器,五种实现:

写法 状态表示 迁移决策 非空行数
枚举 + switch enum Phase 字段 lookupswitch 跳转表 11
状态对象(共享单例) 四个单例之一 虚调用 on(event) 29
状态对象(每次 new) 每个迁移新建对象 虚调用 onAlloc(event) 31
位标志 + 位运算 一个 int,四选一 掩码测试 + 掩码改写 18
位标志 + CAS 同上,volatile VarHandle.compareAndSet 循环 18

「共享单例」和「每次 new」的区别在于状态对象能不能复用。状态对象不带数据时可以共享,四个实例活到进程结束,每次迁移只是换一个引用,这是享元模式的做法:把不变的部分抽出来共享,只在必须区分时新建。状态对象带了数据(连接、重试计数、剩余超时)之后没法共享,每次迁移都要新建一个,分配量就是这么来的。

位运算版本的迁移表是两张长度为 5 的 int[]ALLOW[event] 是接受这个事件的状态掩码,NEXT[event] 是目标状态位。step 的全部逻辑是三行:

1
2
3
int a = ALLOW[e];
int s = state;
if ((s & a) != 0) state = (s & ~a) | NEXT[e];

(s & ~a) 清掉当前状态位,| NEXT[e] 写上目标状态位。整个数组结构里没有一个分支是按状态分的。

CAS 版本跟这个一模一样,只是把 state 改成 volatile,写入换成循环里的 CAS。这一版对应 FutureTask 的形态。

二、实验设计

事件流与驱动

事件流是一份 1,048,576 个 int 的数组,由固定种子的 xorshift 生成,五种取值按 4:5:2:2:3 抽:START 四个、OK 五个、ERR 两个、CANCEL 两个、RESET 三个。五种实现共享同一份数组,处理顺序逐元素相同。

驱动循环是外层 48 趟、内层 1,048,576 个元素,也就是每种写法每轮处理 50,331,648 次事件。每次事件调用一次 step,每 256 次事件把当前状态记进一个乘 31 的校验和。五种实现的校验和必须逐位相同,跑之前先验证这一点,任何一份实现的状态序列偏了就直接抛断言。

事件流里改变状态的比例是 28.1%,其余 71.9% 是状态位不匹配的非法事件。比例由状态机的结构决定:从 NEW 出发只有 STARTCANCEL 能走,从 RUN 出发只有 OKERR 能走,事件流的到达顺序跟状态无关,多数事件落在不接受它的状态上。

测量协议

  • 五种写法 × 2 个 JDK(21.0.8 与 25) × 3 轮预热 + 7 轮计时的循环,同一轮内五种写法轮流跑,避免 JIT 预热和 CPU 频率漂移只落在其中一种上。
  • 每轮记录 System.nanoTime() 差值除以事件数,得到 ns/次事件;分配量用 com.sun.management.ThreadMXBean.getThreadAllocatedBytes 在同线程前后取样,得到 B/次事件;同时记录 GC 次数与 GC 毫秒。
  • 计时期间持全局锁独占 CPU,同机并行在跑二十多个同类实验,不锁的话数字互相污染。
  • 报出的数字取中位数。

这次测不出什么

  • 单线程、无争用。CAS 版本在无争用时才成立,竞争场景需要另一套实验。
  • 状态数和事件数都很小,迁移表进得了 L1,换成几百个状态的机器结论可能不同。
  • 事件流是随机的,分支预测器学到的东西有限。真实请求流如果是长尾的,分支和 BTB 的状态会不一样。
  • 只用 C2。-XX:+PrintInlining 的结论只对热点编译器成立。

三、实测:5033 万次事件

写法 JDK 21 ns/次 JDK 25 ns/次 分配 B/次 非空行数
枚举 + switch 2.804 2.914 0 11
状态对象(共享单例) 5.807 5.040 0 29
状态对象(每次 new) 5.402 5.229 4.49 31
位标志 + 位运算 2.959 2.978 0 18
位标志 + CAS 4.553 4.537 0 18

五种写法的耗时与非法迁移的代价

左边五个柱子是五种写法在 5033 万次事件上的耗时,右边三个柱子是非法迁移的三种表达方式。分配列取自 TLAB 计数,没有采样误差,0 就是一次都没分配。

两个 JDK 的差别很小:五种写法里跨版本差得最多的一项是共享单例的状态类,5.807 对 5.040,差 0.77 ns,方向还不一致(枚举和位运算在 JDK 21 上更快,两个状态类版本在 JDK 25 上更快)。跨版本迁移在这份实验里没有收益可谈,选型结论不受 JDK 版本影响。

四条结论

一、枚举 switch 和位运算在同一档。 JDK 25 上是 2.914 ns 对 2.978 ns,JDK 21 上是 2.804 ns 对 2.959 ns。两个 JDK 上都是枚举一侧略快,幅度 2% 到 5%,和它们跟状态类之间的 73% 不在一个量级。两份实现的共同点是把状态存成一个标量,迁移放在调用方的控制流里。

二、状态类慢在虚调用上,跟 Java 的面向对象没有关系。 共享单例版本在 JDK 25 上是 5.040 ns,是枚举版本的 1.73 倍。两个版本的状态字段都是一个对象引用,差别只在 step 的实现:枚举版本先读 ordinal() 再走跳转表,状态类版本走一次虚调用。

三、每次 new 一个状态对象要收 4.49 B/次事件。 每轮 5033 万次事件里约 1412 万次改变了状态,按每次一个 16 字节对象算,一轮就是 216 MiB。这些垃圾全落在年轻代,GC 次数和 GC 毫秒都记在原始日志里。耗时上它和共享单例版本差得不远,代价体现在分配和 GC 上。

四、CAS 版本处在中间。 4.537 ns(JDK 25),比位运算慢 1.52 倍,比状态类快。多出来的是每次迁移一次原子读改写,在无争用时也要走 ldaxr/stlxr 这对指令。

换算成吞吐

2.914 ns 一次事件是单核每秒 3.43 亿次,5.040 ns 是每秒 1.98 亿次。真实业务到不了这个吞吐,倍数在别处显现:step 不会单独待在热路径上,它前面有解析、后面有响应写出,多态调用点挡住的是顺着调用链下去的一整段内联。

分配与 GC

分配列里只有「每次 new」这一行不为零,量出来的值是每次真实迁移一个 16 字节对象(四个状态类都没有字段,对象头 12 字节加 4 字节的 tag)。一轮 5033 万次事件里有 1412 万次迁移,也就是 216 MiB 垃圾。GC 计数上,JDK 25 的那 7 轮里这一行触发了 2 次年轻代回收,累计 1 ms;其余四行都是 0 次、0 ms。

单次分配 16 字节的对象不贵,这个代价要在长时间运行里才看得见。一轮一秒内跑完的负载只够看见一两次回收;把这段循环放进一个常驻服务里,每秒上亿次的迁移会把年轻代填满几轮。

四、差距从哪来

四条 step 的字节码

javap -c 的输出解释了这些差距。枚举版本的 step 完整字节码 40 条指令,开头是这样:

1
2
3
4
5
6
7
8
0: aload_0
1: getfield // Field phase:LStateBench$Phase;
4: invokevirtual // Method StateBench$Phase.ordinal:()I
7: lookupswitch { // 2
0: 32
1: 61
default: 91
}

状态类版本的 step 一共 7 条指令,全部在这里:

1
2
3
4
5
6
7
0: aload_0
1: aload_0
2: getfield // Field s:LStateBench$St;
5: iload_1
6: invokevirtual // Method StateBench$St.on:(I)LStateBench$St;
9: putfield // Field s:LStateBench$St;
12: return

位运算版本 23 条指令,也没有隐藏的分支:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
0: getstatic     // Field StateBench.ALLOW:[I
3: iload_1
4: iaload
5: istore_2
6: aload_0
7: getfield // Field state:I
10: istore_3
11: iload_3
12: iload_2
13: iand
14: ifeq 32
17: aload_0
18: iload_3
19: iload_2
20: iconst_m1
21: ixor
22: iand
23: getstatic // Field StateBench.NEXT:[I
26: iload_1
27: iaload
28: ior
29: putfield // Field state:I
32: return

CAS 版本 28 条,比位运算多出来的部分就是 getfield 换成 VarHandle 的原子读和比较交换。

指令数从少到多是:状态类 7 条、位运算 23 条、CAS 28 条、枚举 40 条。耗时从少到多是:枚举、位运算、CAS、状态类。指令数和耗时反着排。

内联日志

-XX:+PrintInlining 给出的是同一个事实的另一面。驱动循环里,枚举版本和位运算版本的 step 都被内联进循环体:

1
2
@ 70   StateBench$SwitchMachine::step (104 bytes)   inline (hot)
@ 331 StateBench$BitMachine::step (33 bytes) inline (hot)

状态类版本的虚调用点被拒绝了五次:

1
2
@ 6   StateBench$St::on (0 bytes)   failed to inline: virtual call
@ 6 StateBench$St::on (0 bytes) failed to inline: no static binding

四个状态类共享一个调用点,类型剖面里有四种接收者,这个调用点成为 megamorphic 调用点,C2 不再内联它。内联失败之后,状态字段的读写要穿过一次间接调用,循环里每次事件都做一次目标地址不可预测的跳转,指令数上省下来的那点优势到这里消失。

状态类慢的机制只有这一条:多态调用点热起来之后内联不进去,跟封装、开闭原则无关。类型剖面里有几个接收者取决于状态数,本文只测了 4 个状态这一档。

位运算为什么没有更快

位运算版本的迁移只有一次分支,分支条件依赖 state 和事件的掩码,两个都不可预测,误判率和枚举版本差不多。省下的是跳转表和虚调用那两次间接跳转,代价是每轮多两次数组读取,两边刚好抵消,测出来是同一个量级。

五、JDK 自己怎么管状态

FutureTask:int 加 CAS 加序号比较

FutureTask 的状态字段与常量,来自 JDK 25 的 src.zip

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// java.base/java/util/concurrent/FutureTask.java:88-101(前六行是类 javadoc 的续行)
* Possible state transitions:
* NEW -> COMPLETING -> NORMAL
* NEW -> COMPLETING -> EXCEPTIONAL
* NEW -> CANCELLED
* NEW -> INTERRUPTING -> INTERRUPTED
*/
private volatile int state;
private static final int NEW = 0;
private static final int COMPLETING = 1;
private static final int NORMAL = 2;
private static final int EXCEPTIONAL = 3;
private static final int CANCELLED = 4;
private static final int INTERRUPTING = 5;
private static final int INTERRUPTED = 6;

七个常量是序数编号,源码把这个选择用到了两处。一是终态判断可以写成不等式:

1
2
3
4
5
6
7
8
// java.base/java/util/concurrent/FutureTask.java:158-164
public boolean isCancelled() {
return state >= CANCELLED;
}

public boolean isDone() {
return state != NEW;
}

state >= CANCELLED 一次性覆盖三个终态,不用布尔或、不用位掩码。前提是常量按「越靠后越终态」排序,CANCELLED 后面的 INTERRUPTINGINTERRUPTED 也要是终态。二是迁移用 CAS 做,只有一条写入路径:

1
2
3
4
5
6
7
8
// java.base/java/util/concurrent/FutureTask.java:292-296
protected void set(V v) {
if (STATE.compareAndSet(this, NEW, COMPLETING)) {
outcome = v;
STATE.setRelease(this, NORMAL); // final state
finishCompletion();
}
}

VarHandle 是在静态块里查出来的:

1
2
3
4
5
6
7
8
// java.base/java/util/concurrent/FutureTask.java:582-588
// VarHandle mechanics
private static final VarHandle STATE;
private static final VarHandle RUNNER;
private static final VarHandle WAITERS;
static {
MethodHandles.Lookup l = MethodHandles.lookup();
STATE = MhUtil.findVarHandle(l, "state", int.class);

七个内部状态对外只剩四个。Future.state()int 映射成 Future.State 枚举,六个内部值合并成 RUNNING/SUCCESS/FAILED/CANCELLED

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// java.base/java/util/concurrent/FutureTask.java:251-269
public State state() {
int s = state;
while (s == COMPLETING) {
// waiting for transition to NORMAL or EXCEPTIONAL
Thread.yield();
s = state;
}
switch (s) {
case NORMAL:
return State.SUCCESS;
case EXCEPTIONAL:
return State.FAILED;
case CANCELLED:
case INTERRUPTING:
case INTERRUPTED:
return State.CANCELLED;
default:
return State.RUNNING;
}
}

内部的 int 有七个值,含 COMPLETINGINTERRUPTING 这种瞬时中间态;对外的枚举只有四个,中间态被吸收掉了。内部表示可以比外部 API 细,代价是要写一个映射方法。

Thread:位字段在里,枚举在外

Thread 把内外两层分得更开,对外只给枚举:

1
2
3
4
5
6
7
8
9
// java.base/java/lang/Thread.java:2331-2393(摘录,略去各常量之间的 javadoc)
public enum State {
NEW,
RUNNABLE,
BLOCKED,
WAITING,
TIMED_WAITING,
TERMINATED;
}

六个常量的名称、顺序与源码一致,每个常量上面原本有一段 javadoc 说明各自对应哪些操作。

内部字段是一个 int

1
2
3
4
// java.base/java/lang/Thread.java:243-245
volatile int priority;
volatile boolean daemon;
volatile int threadStatus;

threadStatus 里的值是按 JVMTI 规范设置的位标志,getState() 的实现是把位逐条测出来,测的顺序就是优先级:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// java.base/jdk/internal/misc/VM.java:318-345
public static Thread.State toThreadState(int threadStatus) {
if ((threadStatus & JVMTI_THREAD_STATE_RUNNABLE) != 0) {
return RUNNABLE;
} else if ((threadStatus & JVMTI_THREAD_STATE_BLOCKED_ON_MONITOR_ENTER) != 0) {
return BLOCKED;
} else if ((threadStatus & JVMTI_THREAD_STATE_WAITING_INDEFINITELY) != 0) {
return WAITING;
} else if ((threadStatus & JVMTI_THREAD_STATE_WAITING_WITH_TIMEOUT) != 0) {
return TIMED_WAITING;
} else if ((threadStatus & JVMTI_THREAD_STATE_TERMINATED) != 0) {
return TERMINATED;
} else if ((threadStatus & JVMTI_THREAD_STATE_ALIVE) == 0) {
return NEW;
} else {
return RUNNABLE;
}
}

private static final int JVMTI_THREAD_STATE_ALIVE = 0x0001;
private static final int JVMTI_THREAD_STATE_TERMINATED = 0x0002;
private static final int JVMTI_THREAD_STATE_RUNNABLE = 0x0004;
private static final int JVMTI_THREAD_STATE_BLOCKED_ON_MONITOR_ENTER = 0x0400;
private static final int JVMTI_THREAD_STATE_WAITING_INDEFINITELY = 0x0010;
private static final int JVMTI_THREAD_STATE_WAITING_WITH_TIMEOUT = 0x0020;

六个常量用的是位,一位可以同时为真(RUNNABLEALIVE)。位组合映射不出前面五种时,最后两个分支给出兜底:ALIVE 位为 0 返回 NEW,否则返回 RUNNABLE。用位做内部表示,就得配一个把位组合映射回单值的函数,这个函数是分支链。

本文实验里的位运算版本用的是「一位一状态」的互斥编码,Thread 用的是「一位一属性」的叠加编码。两边都用 int 装状态,差别在位之间能不能同时为真。

第三种编码:值本身携带状态

CompletableFuture 连状态常量都没有,用一个 Object 字段同时装状态和结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// java.base/java/util/concurrent/CompletableFuture.java:265-292
volatile Object result; // Either the result or boxed AltResult

final boolean internalComplete(Object r) { // CAS from null to r
return RESULT.compareAndSet(this, null, r);
}

/* ------------- Encoding and decoding outcomes -------------- */

static final class AltResult { // See above
final Throwable ex; // null only for NIL
AltResult(Throwable x) { this.ex = x; }
}

/** The encoding of the null value. */
static final AltResult NIL = new AltResult(null);

字段为 null 表示未完成,AltResult 表示异常完成,别的对象表示正常完成,AltResult.NIL 专门用来装「正常完成、结果是 null」这个值。三个公开查询各自是一次读加一次判断:

1
2
3
4
5
6
7
8
9
10
11
// java.base/java/util/concurrent/CompletableFuture.java:2075-2077
public boolean isDone() {
return result != null;
}

// java.base/java/util/concurrent/CompletableFuture.java:2565-2568
public boolean isCancelled() {
Object r;
return ((r = result) instanceof AltResult) &&
(((AltResult)r).ex instanceof CancellationException);
}

状态和结果放进同一个字段,发布就是一次 CAS,读完这个字段就同时拿到了状态和值。代价是状态判断变成类型判断,「取消」和「异常完成」要靠 CancellationException 类型区分。

三个类对应本文实验里的三个位置:FutureTaskint 加 CAS 是 bits+cas 那一行,Thread 的位字段是 bits 那一行,CompletableFuture 的「一个字段装两件事」在这五种写法之外,它的每次判断是一次引用比较加一次类型检查。

一条共同做法

FutureTaskThread 都把状态存成 int,都对外暴露枚举或等价类型,都用一次读或一次比较完成状态判断。JDK 里没有哪个类把状态做成对象:这些状态待在热路径上,多个线程还要一起读它。

六、非法迁移怎么告诉调用方

第二个实验把「状态不对时怎么办」单独拿出来量。机器缩到三状态(NEWRUNDONERESETNEW),三种表达方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// A:抛异常
void step(int e) {
if (e == START) {
if (state != NEW) throw new IllegalStateException("illegal transition");
state = RUN;
} else if (e == OK) { ... }
}

// B:返回布尔
boolean step(int e) {
if (e == START) {
if (state == NEW) { state = RUN; return true; }
} else if (e == OK) { ... }
return false;
}

// C:sealed 结果类型
sealed interface Outcome permits Moved, Rejected {}
Outcome step(int e) {
if (e == START) {
if (state == NEW) { state = RUN; return new Moved(state); }
} else if (e == OK) { ... }
return new Rejected(state);
}

调用方相应是三段:

1
2
3
4
5
6
7
8
9
10
11
// A
try { m.step(e); ok++; } catch (IllegalStateException x) { caught++; }

// B
if (m.step(e)) ok++; else rejected++;

// C
switch (m.step(e)) {
case Moved mv -> { ok++; pay += mv.to(); }
case Rejected rj -> { rejected++; pay += rj.from(); }
}

C 的 switch 没有 default 分支,Outcome 是 sealed 接口且只允许两个实现,编译器检查穷尽性。B 的调用方忘了 if 也不会被拦下,A 的调用方不写 catch 编译不过,但 catch 里写什么编译器不管。

两组负载

  • 合法负载:事件流按当前状态现场生成,两亿次事件全部合法。
  • 混合负载:四种事件等概率,25% 的事件合法,其余因为状态不符或事件永远非法被拒。
表达方式 合法 ns/次
21 / 25
混合 ns/次
21 / 25
混合 B/次 非法路径的运行期动作 非空行数
A 抛异常 1.02 / 1.01 244.73 / 236.03 546.25 两次比较 → 构造异常 → 抛出 → 异常表跳转 31
B 返回布尔 0.85 / 0.85 5.87 / 5.90 0 两次比较 → 返回 false 23
C sealed 结果 1.59 / 1.07 7.76 / 7.63 3.99 两次比较 → 构造 Rejected → indy 分派 → 查表 30

三份实现的运行期检查次数一样:每次事件做一到两次事件比较,再做一次状态比较。检查通过之后做什么也一样。差别全在检查失败之后的那几步。

混合负载下 A 是 B 的 40 倍(JDK 25)。这个倍数里包含两件事:一次异常构造要走 fillInStackTrace(栈有多深就翻多少帧),以及每次抛出都把 JIT 对调用方的优化打散。分配列更直白,A 每次非法迁移分配 546.25 字节,B 是 0。异常对象本身加上栈回溯数组,一个都不能省。

240 ns 是下限

A 的 236.03 ns(JDK 25)里,大头是 fillInStackTrace 按栈回复制帧。实验里的栈只有两层,驱动方法直接调 step;真实服务里一次抛出的栈深在几十层量级,代价随栈深上涨。把这个数字当成「异常的代价是 240 ns」会低估它。

JIT 侧也留了痕迹。-XX:+PrintInlining 对三个 step 给出的是不同的结论:

1
2
3
@ 39   RejectBench$ThrowMachine::step (102 bytes)   failed to inline: callee is too large
@ 39 RejectBench$ThrowMachine::step (102 bytes) many throws
@ 39 RejectBench$BoolMachine::step (60 bytes) inline (hot)

many throws 是 C2 拒绝内联的理由之一:方法里抛出点太多,内联进去会拖累调用者的编译结果。热路径上这么写,C2 会把整个方法从调用链里切出去,这是耗时之外的第二个代价。

责任链和状态机对「没人接受」的处理

事件到达时状态机不接受,本文的机器把事件丢掉;责任链会把它交给下一个处理器,全部处理器都不接受时才抛出或返回失败。两种结构对「没人管」的默认动作不同:状态机的丢弃是显式的,拦截器链的传递是隐式的。

编译期检查的运行期代价

C 的 switch 是编译期穷尽的,运行期照样要分派。javap -c 出来的调用点:

1
2
3
4
5
6
7
8
9
10
11
12
13
42: invokevirtual // Method RejectBench$SealedMachine.step:(I)LRejectBench$Outcome;
45: dup
46: invokestatic // Method java/util/Objects.requireNonNull
59: invokedynamic #47, 0 // InvokeDynamic #0:typeSwitch:(Ljava/lang/Object;I)I
64: lookupswitch { // 2
0: 102
1: 127
default: 92
}
92: new // class java/lang/MatchException
101: athrow
102: aload 10
104: checkcast // class RejectBench$Moved

分派是一个 invokedynamic 引导的 typeSwitch,拿到一个序数再 lookupswitch。后面那个 default: 92 分支永远不会执行,是编译器为「穷尽性被破坏」留的兜底,里面抛 MatchException。sealed 接口加穷尽 switch 是 Java 17 之后做分发的主力工具,访问者模式里同一套机制可以替代双分派。

合法负载下 C 是 1.59 ns(JDK 21)与 1.07 ns(JDK 25),B 是 0.85 ns 与 0.85 ns,差 0.22 ns(JDK 25)到 0.74 ns(JDK 21)。混合负载下两者的差是 1.89 ns 与 1.73 ns,两个 JDK 上一致。耗时上这个设计不吃亏,风险在分配量依赖编译器的优化状态。

合法负载下的分配量两个 JDK 不一致:JDK 25 是 0.00 B/次事件,C2 把结果对象全部标量替换掉了;JDK 21 同样一份代码是 16.00 B/次事件,每个结果对象都真的分配出来。混合负载下两个 JDK 都只留下一部分,3.99 B/次,正好是 25% 的合法路径那次分配(3.99 ÷ 16 ≈ 25%)。

分配量对逃逸分析的状态敏感。加上 -XX:-DoEscapeAnalysis,混合负载下 C 的分配量从 3.99 涨到 16.00 B/次,耗时从 7.76 涨到 10.87 ns(JDK 21)、从 7.63 涨到 8.65 ns(JDK 25)。16 字节一次的对象在默认参数下大部分会被消掉,这依赖编译器的优化结果,语言层面没有这个保证。结果对象一旦逃出调用方、或者换一个逃逸分析实现不同的 JVM,这份开销就回来了。

三种表达各自的适用面

  • 异常:非法迁移是「程序写错了」时才发生的,一年也遇不到几次,抛出去触发告警比继续跑下去好。放进每秒百万次的热路径,等于把栈回溯当成常规控制流,这里的实测差是 40 倍。
  • 布尔:调用方只需要知道有没有发生。状态机内部的迁移表、解析器里的「这个 token 吃不吃」,都属于这种。
  • sealed 结果:调用方需要知道为什么没发生,或者这个 API 是给别人用的。类型系统会把「忘了处理」变成编译错误,Rejected 里还能带上拒绝时的状态和事件,日志里直接能看见。

七、什么时候用状态类

状态类的开销全部落在多态调用点上,判断顺序要看状态数量和迁移复杂度,「是不是面向对象」不在其中。

判断顺序里有两条是硬约束。

状态带数据,状态类才有意义。 状态对象能共享,是因为它没有字段。一旦每个状态要携带自己的数据,把数据留在机器对象里再用 switch 分派就写不动了;这时候多态是唯一能让代码保持线性的写法,代价是热路径上多一次间接跳转。状态没有数据时用状态类,代价是 11 行代码变成 29 行。

多线程读写状态时,状态必须是一个可以原子更新的变量。 多个 volatile boolean 字段拼出来的状态在并发下没法一次读完,读到一个「既不在运行、也没结束」的中间组合是常态。FutureTask 用一个 intCAS 就是为了这个。这一条跟状态类冲突:状态类方案的迁移是「读引用、算新引用、写引用」,原子性要靠 AtomicReference 的 CAS 循环自己搭。

什么时候不用状态类

  • 状态少于五个、事件少于十个。 迁移表小到能全部塞进一个屏幕,多态的间接层只有坏处。本文测出来的是 1.7 倍的耗时差。
  • 状态迁移在每秒百万次以上的路径上。 客户端请求解析、协议状态机、行情撮合,这些地方的 step 会被内联到调用者里,状态类版本的调用点会变成 megamorphic,内联失败。
  • 状态要跨线程被读。 换成 volatile int 或原子引用,读侧变成一次内存读,不需要加锁。

状态类仍然值得写的场景

  • 状态带数据,且数据只在某个状态里有意义。订单状态机里 PAID 带着支付流水号,SHIPPED 带着运单号,把这些字段平铺到机器对象上会让一半字段在任意时刻是空的。
  • 每个状态的处理逻辑长度悬殊。switch 里塞进三十行的状态处理,一个方法上百行,读起来比四个小类困难。
  • 状态迁移需要被测试覆盖到分支级别。四个状态类各自可以被单独实例化和测试,switch 版本只能通过机器对象间接测。

三个真实状态机落在哪一档

HTTP 请求生命周期(收到 → 解析 → 处理 → 响应 → 关闭)。状态 5 个,事件 6 个,迁移固定,step 每个请求跑一次。这一档用序数常量加 switch,状态还要跨阶段传递时用 int,用不上状态类。

订单状态机(待支付 → 已支付 → 已发货 → 已完成,另有取消和退款)。状态带数据:PAID 带支付流水号,SHIPPED 带运单号,迁移要落库。这一档用状态类,或者用 sealed 接口加记录类型。记录类型不可变,跟不可变与防御性拷贝里讨论的是同一件事:迁移产生新值,不修改旧值。

协议解码器(读帧头 → 读负载 → 校验 → 完成)。事件是字节,迁移由帧长决定,step 落在最内层循环里。这一档用位标志或序数常量加 switch,并且迁移路径上不该有任何分配,一次分配就是一个对象要穿过的年轻代。

总结

  • 「状态位」和「状态类」是两种指令序列:前者编译成跳转表和掩码运算,后者编译成一次虚调用。本文实测的差距是 1.73 倍(JDK 25,5033 万次事件),全部来自多态调用点没能内联。
  • 分配上的差别更大:共享单例的状态类是 0 B/次事件,每次 new 的状态类是 4.49 B/次事件。状态对象一旦带上数据就没法共享,这是设计上的取舍。
  • JDK 里管状态的两个核心类都选了 int 加原子更新。FutureTask 用序数常量配 state >= CANCELLED 这种不等式判断,Thread 用 JVMTI 的位标志配逐位测试,两个都把枚举留给外部 API。
  • 非法迁移的三种表达实测差 40 倍:抛异常 236.03 ns/次加 546.25 B/次,返回布尔 5.90 ns/次加 0 B。sealed 结果类型加上穷尽 switch 把「调用方忘了处理」变成编译错误,运行期代价与布尔相当,差 1.73 ns。
  • 选型顺序:先看状态是不是公开 API,再看状态带不带数据,最后看状态数和迁移复杂度。前两个问题定下大方向,第三个问题只影响要不要用表驱动。

参考资料

系列索引:设计模式系列