⭐ 推荐:社区规则条款 V1.0

如何用 Java 枚举实现线程安全的状态机?

GJ论坛站长
GJ论坛站长 管理员资深会员 👑黑卡会员
发布于 2026-10-23 07:07 ·8 浏览 ·0 回复
内容摘要

用 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 起并发压测,确认只有一次转移成功,别只靠肉眼检查代码。
本文转载自 GJ站长论坛,原文地址:https://www.gj0.com/thread-2403.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 0

还没有回复,来抢沙发~