Strategy — Optimize¶
Each section presents a Strategy that works but is wasteful. Profile, optimize, measure.
Table of Contents¶
- Optimization 1: Singleton stateless strategies
- Optimization 2: Hoist captured lambda
- Optimization 3: Enum-keyed lookup over HashMap
- Optimization 4: Split megamorphic call site
- Optimization 5: Replace dispatch with type-specialized branch
- Optimization 6: Cache strategy by config key
- Optimization 7: Use
finalto enable JIT inlining - Optimization 8: Snapshot reference once per request
- Optimization 9: Drop premature Strategy
- Optimization 10: Static dispatch via generics (Rust / C++)
- Optimization Tips
Optimization 1: Singleton stateless strategies¶
Before¶
public Money price(Cart cart, String mode) {
PricingStrategy s = switch (mode) {
case "standard" -> new StandardPricing();
case "student" -> new StudentPricing();
case "holiday" -> new HolidayPricing();
};
return s.price(cart);
}
Each call allocates a new strategy. Stateless instances are wasted garbage.
After¶
public final class PricingStrategies {
public static final PricingStrategy STANDARD = new StandardPricing();
public static final PricingStrategy STUDENT = new StudentPricing();
public static final PricingStrategy HOLIDAY = new HolidayPricing();
}
public Money price(Cart cart, String mode) {
PricingStrategy s = switch (mode) {
case "standard" -> PricingStrategies.STANDARD;
case "student" -> PricingStrategies.STUDENT;
case "holiday" -> PricingStrategies.HOLIDAY;
};
return s.price(cart);
}
Measurement. Allocation rate drops to zero for these. GC pause frequency goes down in allocation-heavy services.
Lesson: Stateless strategies should be singletons. Allocate once, reuse forever.
Optimization 2: Hoist captured lambda¶
Before¶
public List<Order> filterRecent(List<Order> orders, Instant cutoff) {
return orders.stream()
.filter(o -> o.createdAt().isAfter(cutoff)) // captures cutoff per call
.collect(Collectors.toList());
}
Each call to filterRecent allocates a new lambda capturing cutoff.
After (when the cutoff is fixed)¶
private static final Instant CUTOFF = Instant.parse("2025-01-01T00:00:00Z");
private static final Predicate<Order> IS_RECENT = o -> o.createdAt().isAfter(CUTOFF);
public List<Order> filterRecent(List<Order> orders) {
return orders.stream().filter(IS_RECENT).collect(Collectors.toList());
}
Or, when you can pass a non-capturing lambda:
Measurement. Lambda allocation eliminated. In hot paths, GC pressure decreases.
Lesson: Non-capturing lambdas / method references are singletons. Capturing lambdas allocate.
Optimization 3: Enum-keyed lookup over HashMap¶
Before¶
private static final Map<String, Strategy> MAP = Map.of(
"fastest", new FastestStrategy(),
"shortest", new ShortestStrategy(),
"scenic", new ScenicStrategy()
);
public Strategy of(String key) { return MAP.get(key); }
Hot path does a HashMap lookup per request: hash of the string + array probe + equality check.
After¶
public enum RouteMode {
FASTEST(new FastestStrategy()),
SHORTEST(new ShortestStrategy()),
SCENIC(new ScenicStrategy());
private final Strategy strategy;
RouteMode(Strategy s) { this.strategy = s; }
public Strategy strategy() { return strategy; }
}
Measurement. Lookup reduces from ~10-20 ns (HashMap) to a single field read (~1 ns). On hot paths with millions of dispatches, measurable.
Lesson: When the strategy set is bounded and known at compile time, enums beat maps.
Optimization 4: Split megamorphic call site¶
Before¶
The call site sees many handler types → megamorphic; JIT can't inline.
After¶
// Group events by type first.
Map<EventType, List<Event>> grouped = events.stream().collect(groupingBy(Event::type));
for (var entry : grouped.entrySet()) {
EventHandler h = handlerByType.get(entry.getKey());
for (Event e : entry.getValue()) {
h.handle(e); // now monomorphic per inner loop
}
}
Measurement. Per-event dispatch cost drops from ~3 ns (megamorphic vtable) to ~0 ns (inlined). In high-throughput dispatchers (e.g., 10M events/s), this is ~30% throughput.
Lesson: The JIT loves monomorphic call sites. Splitting one mega site into many mono sites unlocks inlining.
Optimization 5: Replace dispatch with type-specialized branch¶
Before¶
Megamorphic. Strategy lives behind an interface; JIT can't inline.
After (when the family is small and stable)¶
switch (req.type()) {
case CARD -> CARD_STRATEGY.pay(req);
case CRYPTO -> CRYPTO_STRATEGY.pay(req);
case BANK -> BANK_STRATEGY.pay(req);
}
Each branch is monomorphic. JIT inlines each. The switch itself compiles to a jump table.
Measurement. ~2-3 ns saved per call. For a service doing 100K req/s, ~0.2 ms CPU per second per server.
Trade-off: The dispatcher now knows the family. Adding a type means editing this switch. Use only when (a) the family is stable, (b) the path is hot.
Lesson: Strategy is for flexibility. When that flexibility costs more than it earns, type-specialize.
Optimization 6: Cache strategy by config key¶
Before¶
public Strategy ofConfig(Config cfg) {
return new ConfiguredStrategy(cfg); // new instance every call
}
Each call allocates and (worse) might re-do expensive setup (parsing rules, opening DB connections).
After¶
private final Map<Config, Strategy> cache = new ConcurrentHashMap<>();
public Strategy ofConfig(Config cfg) {
return cache.computeIfAbsent(cfg, ConfiguredStrategy::new);
}
(Requires Config to have meaningful equals / hashCode.)
Measurement. Allocation rate drops dramatically; subsequent lookups are ~10 ns. For long-lived strategies with expensive init, the win is bigger.
Lesson: If strategy creation is expensive, cache by the parameters.
Optimization 7: Use final to enable JIT inlining¶
Before¶
public class FastestStrategy implements RouteStrategy { ... }
public class ShortestStrategy implements RouteStrategy { ... }
The JIT, in principle, has to consider that some subclass of FastestStrategy might exist. Inlining is harder.
After¶
public final class FastestStrategy implements RouteStrategy { ... }
public final class ShortestStrategy implements RouteStrategy { ... }
final tells the JIT no subclass exists. Inlining decisions are simpler.
Measurement. In tight loops, marginal speedup (~5-10% on dispatch). More importantly: cleaner IllegalAccessException-style errors when someone subclasses without thinking.
Lesson: final is a hint to the runtime and a discipline. Use it for strategies that aren't designed for inheritance.
Optimization 8: Snapshot reference once per request¶
Before¶
public final class Context {
private volatile Strategy strategy;
public Result process(Input a, Input b, Input c) {
Result r1 = strategy.run(a);
Result r2 = strategy.run(b);
Result r3 = strategy.run(c);
return merge(r1, r2, r3);
}
}
Each strategy.run(...) does a volatile read. Three reads total. If the strategy changes between calls, results are inconsistent.
After¶
public Result process(Input a, Input b, Input c) {
Strategy local = strategy; // one volatile read
Result r1 = local.run(a); // local field reads
Result r2 = local.run(b);
Result r3 = local.run(c);
return merge(r1, r2, r3);
}
Measurement. Three volatile reads → one. Per-call cost drops by a few ns. Plus you fix the consistency bug.
Lesson: Snapshot once per logical operation. Saves volatile reads and ensures consistent behavior.
Optimization 9: Drop premature Strategy¶
Before¶
public interface DiscountStrategy { Money apply(Money m); }
public class NoDiscount implements DiscountStrategy {
public Money apply(Money m) { return m; }
}
public final class Cart {
private DiscountStrategy d = new NoDiscount();
public Money total() { return d.apply(subtotal()); }
}
There is exactly one strategy. The interface, the class, the wiring — all overhead.
After¶
Drop the abstraction. Add it back when a second algorithm appears.
Measurement. Less code, less indirection, faster.
Lesson: Strategy with one implementation is an anti-pattern: the boilerplate of the pattern without any benefit. Add abstractions when needed, not before.
Optimization 10: Static dispatch via generics (Rust / C++)¶
Before (dynamic dispatch)¶
&dyn Strategy is a vtable pointer; compute is an indirect call. Inlining is impossible.
After (static dispatch via generics)¶
The compiler monomorphizes: one specialized version per concrete S. The call is direct; the body inlines.
Measurement. Zero-cost abstraction. The Strategy compiles down to inline code.
Trade-off: Code bloat (one specialization per type). Use when dispatch is hot and the family is small.
C++ equivalent:
Same monomorphization model.
Lesson: In statically-dispatched languages, generics turn Strategy from "polymorphic indirect call" into "direct call." Both expressive and fast — but with code-size cost.
Optimization Tips¶
- Profile first. Strategy dispatch is rarely the bottleneck. Don't optimize what hasn't shown up in a flame graph.
- Stateless = singleton. Always. No exceptions. No allocations per call.
- Watch for closure capture. Capturing lambdas allocate; non-capturing don't. In Java, Kotlin, Go, and JavaScript, it matters.
- Enum-keyed lookups beat string-keyed. Compile-time exhaustiveness as a free bonus.
finalhelps the JIT. And documents intent.- Megamorphic call sites cost. Split them by type or input partitioning.
- Snapshot strategy refs. Once per operation, not once per call. Saves volatile reads, prevents mid-call swaps.
- Cache configured strategies. When
newis expensive (parsing rules, opening connections), key by config. - Drop one-strategy abstractions. They're pure tax. Re-introduce when a second variant appears.
- Static vs dynamic dispatch. In Rust / C++, generics give you Strategy at compile time, free of indirect calls — at the cost of code size.