State — Optimize¶
Each section presents a State pattern that works but is wasteful. Profile, optimize, measure.
Table of Contents¶
- Optimization 1: Singleton stateless states
- Optimization 2: Enum-based FSM for tight loops
- Optimization 3: Sealed types for compile-time exhaustiveness
- Optimization 4: CAS-based atomic transitions
- Optimization 5: Hierarchical states for shared behavior
- Optimization 6: Optimistic locking instead of transactions
- Optimization 7: Type-specialized branches for monomorphic dispatch
- Optimization 8: Bounded transition history
- Optimization 9: Drop State for trivial 2-state machine
- Optimization 10: Workflow engine for long-running FSMs
- Optimization Tips
Optimization 1: Singleton stateless states¶
Before¶
public void tick() {
state = state.next(); // each next() returns new state instance
}
class Red implements Light {
public Light next() { return new Green(); } // allocation per transition
}
For a high-frequency FSM (e.g., game character animation at 60 fps), allocations stack up.
After¶
class Red implements Light {
public static final Red INSTANCE = new Red();
public Light next() { return Green.INSTANCE; }
}
class Green implements Light {
public static final Green INSTANCE = new Green();
public Light next() { return Yellow.INSTANCE; }
}
Or use Java enum:
public enum Light {
RED { public Light next() { return GREEN; } },
GREEN { public Light next() { return YELLOW; } },
YELLOW { public Light next() { return RED; } };
public abstract Light next();
}
Measurement. Allocation rate drops to zero. GC pressure removed. ~ns saved per transition.
Lesson: Stateless states should be singletons. Allocation per transition is wasteful.
Optimization 2: Enum-based FSM for tight loops¶
Before¶
public sealed interface State permits Idle, Active, Done {}
// Multi-class hierarchy; vtable dispatch.
state.handle(this); // polymorphic; megamorphic if many states
For 10+ states in a tight inner loop, vtable cost is visible.
After¶
public enum State {
IDLE, ACTIVE, DONE;
}
State current = State.IDLE;
void handle() {
switch (current) {
case IDLE -> startActivity();
case ACTIVE -> tick();
case DONE -> noop();
}
}
Measurement. Switch on enum compiles to jump table. Each branch monomorphic; JIT inlines. ~2-3ns saved vs vtable dispatch.
Trade-off. Logic in switch statements, not in state classes. Less object-oriented; more procedural.
Lesson: For performance-critical inner loops with stable state set, enum + switch beats class hierarchy.
Optimization 3: Sealed types for compile-time exhaustiveness¶
Before¶
public interface State {}
public class Draft implements State {}
public class Published implements State {}
// Switch:
if (state instanceof Draft) ...
else if (state instanceof Published) ...
// missing case: silently does nothing
Adding a new state doesn't force code updates.
After (Java 17+)¶
public sealed interface State permits Draft, Moderation, Published {}
// Compile-time exhaustive:
String name = switch (state) {
case Draft d -> "draft";
case Moderation m -> "moderation";
case Published p -> "published";
};
Adding a new state to permits forces compile errors at all dispatch sites.
Measurement. Bugs caught at compile time, not runtime. Refactoring safe.
Lesson: Sealed types make State pattern type-safe and refactor-friendly. Use when language supports.
Optimization 4: CAS-based atomic transitions¶
Before¶
Lock per transition. Under contention, throughput collapses.
After¶
private final AtomicReference<State> state = new AtomicReference<>(new Cart());
public boolean pay() {
State current = state.get();
if (!(current instanceof Cart)) return false;
return state.compareAndSet(current, new Paid());
}
Lock-free. Failed CAS = concurrent transition; caller retries with current state.
Measurement. Throughput scales with cores; no lock contention.
Lesson: Lock-free transitions via CAS. Suitable for hot in-memory FSMs.
Optimization 5: Hierarchical states for shared behavior¶
Before (flat)¶
class Playing implements State {
public void powerOff(Player p) { p.setState(new Off()); }
public void play(Player p) { /* already playing */ }
public void pause(Player p) { p.setState(new Paused()); }
}
class Paused implements State {
public void powerOff(Player p) { p.setState(new Off()); } // duplicated
public void play(Player p) { p.setState(new Playing()); }
public void pause(Player p) { /* already paused */ }
}
class Standby implements State {
public void powerOff(Player p) { p.setState(new Off()); } // duplicated
public void play(Player p) { p.setState(new Playing()); }
public void pause(Player p) { /* nothing */ }
}
powerOff duplicated everywhere.
After (hierarchical)¶
public abstract class On implements State {
public void powerOff(Player p) { p.setState(new Off()); } // shared
}
public class Playing extends On {
public void pause(Player p) { p.setState(new Paused()); }
}
public class Paused extends On {
public void play(Player p) { p.setState(new Playing()); }
}
public class Standby extends On {
public void play(Player p) { p.setState(new Playing()); }
}
public class Off implements State {}
Measurement. Duplication eliminated. Adding a "powerOff" rule = one class.
Lesson: Hierarchical states extract shared behavior. Sealed types support this naturally.
Optimization 6: Optimistic locking instead of transactions¶
Before¶
@Transactional
public void pay(String id) {
Order o = repo.findById(id);
if (!"cart".equals(o.status())) throw new IllegalStateException();
o.setStatus("paid");
repo.save(o);
}
Long transaction holds locks; concurrent calls block.
After¶
public void pay(String id) {
int rows = jdbc.update(
"UPDATE orders SET status = 'paid', version = version + 1 " +
"WHERE id = ? AND status = 'cart'",
id
);
if (rows == 0) throw new ConcurrentModificationException();
}
Single atomic UPDATE. No transaction needed. Concurrent attempts fail; caller retries.
Measurement. Throughput rises sharply under contention. Lock-free at the DB level.
Lesson: For state transitions, optimistic locking via WHERE clause is cheaper than full transactions.
Optimization 7: Type-specialized branches for monomorphic dispatch¶
Before (megamorphic)¶
JIT can't inline; ~2-3ns per call.
After (group by state type)¶
Map<Class<?>, List<Order>> grouped = orders.stream()
.collect(Collectors.groupingBy(o -> o.state.getClass()));
for (var entry : grouped.entrySet()) {
State stateInstance = ...; // representative
for (Order o : entry.getValue()) stateInstance.process(o);
// Inner loop is monomorphic; JIT inlines
}
Measurement. Per-call cost drops from ~3ns (vtable) to ~0ns (inlined).
Trade-off. Outer loop adds overhead. Worth it for very large batches.
Lesson: Splitting megamorphic call sites into monomorphic ones unlocks JIT inlining.
Optimization 8: Bounded transition history¶
Before¶
private final List<State> history = new ArrayList<>(); // unbounded
public void transition(State next) {
history.add(state);
state = next;
}
Long sessions accumulate history; OOM.
After¶
private final Deque<State> history = new ArrayDeque<>();
private static final int MAX_HISTORY = 100;
public void transition(State next) {
history.push(state);
while (history.size() > MAX_HISTORY) history.removeLast();
state = next;
}
Measurement. Memory bounded. Old history dropped.
Lesson: State histories must be bounded. Always.
Optimization 9: Drop State for trivial 2-state machine¶
Before¶
public sealed interface ToggleState permits On, Off {
void toggle(Toggle t);
}
public final class On implements ToggleState {
public void toggle(Toggle t) { t.setState(new Off()); }
}
public final class Off implements ToggleState {
public void toggle(Toggle t) { t.setState(new On()); }
}
public final class Toggle {
private ToggleState state = new Off();
public void setState(ToggleState s) { this.state = s; }
public void toggle() { state.toggle(this); }
}
Lots of code for a binary flag.
After¶
public final class Toggle {
private boolean on = false;
public void toggle() { on = !on; }
public boolean isOn() { return on; }
}
Measurement. Less code. Less indirection. Easier to read.
Lesson: State pattern earns its weight at 3+ states with non-trivial logic. Two states with trivial transition = boolean.
Optimization 10: Workflow engine for long-running FSMs¶
Before¶
@Scheduled(cron = "0 0 * * *")
public void checkExpired() {
List<Order> expiring = repo.findExpiring();
for (Order o : expiring) {
if (o.status().equals("pending") && o.createdAt().isBefore(yesterday())) {
o.setStatus("expired");
repo.save(o);
}
}
}
Custom code for FSM transitions over time. Brittle; hand-rolled retries; no audit.
After (Temporal)¶
@Workflow.defn
public class OrderWorkflow {
@WorkflowMethod
public void run(Order order) {
try {
Workflow.await(Duration.ofDays(1), order::isPaid);
} catch (TimeoutException e) {
workflow.execute_activity(MarkExpired.class, order.id());
}
}
}
Temporal handles persistence, timeouts, retries, observability.
Measurement. Code drops dramatically. Reliability improves; tooling provided.
Trade-off. Operational complexity (running Temporal cluster).
Lesson: Long-running FSMs benefit from workflow engines. Don't reinvent persistence + retries.
Optimization Tips¶
- Singleton stateless states. Zero allocation per transition.
- Enum-based FSM for tight loops. Compiles to jump table.
- Sealed types for exhaustiveness. Refactor-safe; compile-time bug catching.
- CAS for lock-free transitions. Suitable for in-memory hot FSMs.
- Hierarchical states for shared behavior. Reduces duplication.
- Optimistic locking for DB-backed FSMs. Beats transactions under contention.
- Type-specialized batches for megamorphic call sites.
- Bound histories. Always.
- Drop State pattern when overkill. 2 states = boolean.
- Workflow engines for long-running FSMs. Outsource durability.
- Profile before optimizing. State dispatch is rarely the bottleneck.