如何用 Java 枚举实现线程安全的状态机?
用 Java 枚举定义状态与事件,以 EnumMap 静态转移表实现纯函数 next(),再用 AtomicReference 的 CAS 保护读取当前状态、计算下一状态、写回的复合操作,构建线程安全状态机;枚举常量不可存可变状态,next
学完这篇,你能写出一个用 Java 枚举承载状态转移规则、用 CAS 保证并发安全的状态机,并知道哪些写法会让它在多线程下悄悄失效。
第一步:用 enum 定义状态和事件,得到两份可穷举的清单
状态机的第一步是把「有哪些状态」和「有哪些事件」固定下来。用 enum 的好处是:每个常量在 JVM 里只有一份实例,初始化由类加载机制加锁完成,天然线程安全,而且 switch 能穷举检查。
public enum OrderState { CREATED, PAID, SHIPPED, FINISHED, CANCELED }
public enum OrderEvent { PAY, SHIP, FINISH, CANCEL }
环境用 JDK 17(switch 表达式需要 14+,只用 switch 语句的话 JDK 8 也可以)。
注意:不要在枚举里放可变的实例字段(比如
private int count)。枚举常量是全局单例,任何一处改值都会影响所有线程,状态机的「当前状态」必须放在上下文对象里,不能放在枚举常量上。
第二步:把转移表挂到枚举上,得到一个纯函数的 next()
这一步的目标是让 当前状态 + 事件 能直接查出下一个状态,查不到就代表非法转移。用静态块构建 EnumMap 是最直观的写法。
import java.util.*;
public enum OrderState {
CREATED, PAID, SHIPPED, FINISHED, CANCELED;
private static final Map<OrderState, Map<OrderEvent, OrderState>> TABLE
= new EnumMap<>(OrderState.class);
static {
for (OrderState s : values()) {
TABLE.put(s, new EnumMap<>(OrderEvent.class));
}
allow(CREATED, OrderEvent.PAY, PAID);
allow(CREATED, OrderEvent.CANCEL, CANCELED);
allow(PAID, OrderEvent.SHIP, SHIPPED);
allow(SHIPPED, OrderEvent.FINISH, FINISHED);
}
private static void allow(OrderState from, OrderEvent e, OrderState to) {
TABLE.get(from).put(e, to);
}
/** 纯函数:无副作用、不改任何状态;无合法转移时返回 null */
public OrderState next(OrderEvent event) {
return TABLE.get(this).get(event);
}
}
为什么不用 CREATED(PAID) 这种构造参数写法?因为 Java 编译器不允许在枚举常量的构造参数里引用同类其他常量,会报 illegal reference to static field from initializer。静态块在全部常量初始化之后才执行,所以安全。
注意:
next()必须是纯函数。后面 CAS 失败会重试,如果它内部顺手写日志、扣库存、发消息,重试就会重复执行。
第三步:用 AtomicReference + CAS 组装线程安全的状态机
真正需要保护的是「读当前状态、算下一状态、写回」这三步组成的复合操作,AtomicReference 的 compareAndSet 正好能让它变成原子的。
import java.util.concurrent.atomic.AtomicReference;
public final class OrderStateMachine {
private final AtomicReference<OrderState> ref;
public OrderStateMachine(OrderState initial) {
this.ref = new AtomicReference<>(Objects.requireNonNull(initial));
}
public OrderState state() { return ref.get(); }
public OrderState fire(OrderEvent event) {
for (;;) {
OrderState cur = ref.get();
OrderState next = cur.next(event);
if (next == null) {
throw new IllegalStateException("非法转移: " + cur + " --" + event + "-->");
}
if (ref.compareAndSet(cur, next)) {
return next; // CAS 成功才返回
}
// CAS 失败说明别的线程刚改过状态,重新读一次再算
}
}
}
FINISHED、CANCELED 这类终态在转移表里没有任何出边,next() 自然返回 null,无需额外判断。
注意:不要在
fire()里加synchronized再套 CAS,两者混用只会带来锁竞争,没有额外收益;如果一定要用锁,就老老实实只用锁,别在锁里做远程调用。
第四步:监听器放在 CAS 成功之后,避免重复触发
如果业务需要在状态变更后发通知、写审计日志,把监听器接在 CAS 成功的那一行之后即可,失败重试的路径不会碰到它。
if (ref.compareAndSet(cur, next)) {
for (Consumer<OrderState> l : listeners) { // listeners 用 CopyOnWriteArrayList
try { l.accept(next); } catch (RuntimeException ignore) { /* 不影响主流程 */ }
}
return next;
}
注意:监听器在并发下可能乱序执行(A 线程先 CAS 成功但被调度延后,B 线程后成功却先回调)。需要严格有序的话,把监听器改成投递到单线程
ExecutorService队列里串行消费。
第五步:用并发测试确认只成功一次
写完必须验证。启动 16 个线程同时发 PAY,正确结果是恰好 1 次成功、其余全部抛非法转移。
ExecutorService pool = Executors.newFixedThreadPool(16);
CountDownLatch start = new CountDownLatch(1);
AtomicInteger ok = new AtomicInteger(), bad = new AtomicInteger();
for (int i = 0; i < 100; i++) {
pool.submit(() -> {
start.await();
try { m.fire(OrderEvent.PAY); ok.incrementAndGet(); }
catch (IllegalStateException e) { bad.incrementAndGet(); }
return null;
});
}
start.countDown();
pool.shutdown();
pool.awaitTermination(5, TimeUnit.SECONDS);
System.out.println(ok.get() + " / " + bad.get()); // 期望 1 / 99
跑十遍都稳定输出 1 / 99,才说明状态机是安全的。
小结
- 状态、事件用
enum承载,靠类加载保证单例与初始化安全,但不要往枚举里塞可变字段。 - 转移规则用静态块构建
EnumMap表,绕开「构造参数不能引用同类常量」的编译限制。 next()写成纯函数,无副作用,才敢在 CAS 失败时重试。- 复合操作交给
AtomicReference.compareAndSet循环,不叠加多余的锁。 - 监听器只在 CAS 成功后触发,并注意并发下的乱序问题。
- 用
CountDownLatch起并发压测,确认只有一次转移成功,别只靠肉眼检查代码。
转载请注明出处,版权归原作者所有。