JVM Method Dispatch — Find the Bug¶
10 buggy snippets, each illustrating a dispatch trap that compiles, looks fine in review, and only bites at runtime — wrong method invoked, throughput collapsed, deopt cascade, surprising
supersemantics. For each: read the code, decide which dispatch behaviour is violated, identify the runtime symptom (stack trace, slow throughput, wrong output), and write down the fix.
Bug 1 — Megamorphic call site killing throughput¶
public interface Handler { void handle(Event e); }
public final class EventBus {
private final List<Handler> handlers = new ArrayList<>();
public void register(Handler h) { handlers.add(h); }
public void publish(Event e) {
for (Handler h : handlers) {
h.handle(e); // hot loop, single call site
}
}
}
// In application startup:
bus.register(new MetricsHandler());
bus.register(new AuditHandler());
bus.register(new EmailHandler());
bus.register(new ArchiveHandler());
bus.register(new SlackHandler());
bus.register(new DataLakeHandler());
// 12 handlers total
Symptom. Throughput is roughly half of what a fresh prototype showed with one handler. The flame graph collected via async-profiler shows ~8% CPU in itable stub under EventBus.publish. JFR shows EventBus.publish compiling, deoptimizing, recompiling — three times during a 60-second window.
Violation. The call site h.handle(e) is megamorphic — 12 distinct receiver classes flow through one location. The polymorphic inline cache evicts after a few misses; subsequent calls fall back to a real invokeinterface walking the itable. CHA cannot help: Handler is an open interface with many implementations.
Fix. Two complementary moves:
- Seal the interface and
finalthe implementations. CHA now knows the type set is closed, but the call site is still megamorphic. - Specialize per concrete handler class. Group handlers by type at registration; build one
handlecall site per concrete class instead of one shared site.
public final class EventBus {
private final List<MetricsHandler> metrics = new ArrayList<>();
private final List<AuditHandler> audits = new ArrayList<>();
// ... etc.
public void publish(Event e) {
for (var h : metrics) h.handle(e); // monomorphic on MetricsHandler
for (var h : audits) h.handle(e); // monomorphic on AuditHandler
// ...
}
}
Each call site is now monomorphic. C2 inlines every handle body directly into the loop.
Bug 2 — final method called via reflection becomes virtual again¶
public final class TaxRule {
public final BigDecimal apply(BigDecimal base) {
return base.multiply(new BigDecimal("0.20"));
}
}
public class TaxEngine {
public BigDecimal compute(Object rule, BigDecimal base) throws Exception {
Method m = rule.getClass().getMethod("apply", BigDecimal.class);
return (BigDecimal) m.invoke(rule, base);
}
}
Symptom. The team marked TaxRule.apply final for performance, expecting the JIT to inline it. Profiling shows compute is still slow — much slower than a direct call. The flame graph shows time in sun.reflect.NativeMethodAccessorImpl.invoke and Method.invoke.
Violation. Reflection bypasses the bytecode entirely. Method.invoke performs a generic dispatch through internal accessor objects; CHA and final are irrelevant because the actual call site is inside Method.invoke, not at the source-level m.invoke(...). The static-binding benefit of final is wasted.
Fix. Don't dispatch through reflection on a hot path. Either:
- Statically type the parameter.
public BigDecimal compute(TaxRule rule, BigDecimal base) { return rule.apply(base); }. Direct call, fully inlined. - Use a
MethodHandle. If reflective indirection is truly necessary, build aMethodHandleonce at construction and invoke it repeatedly — the JIT inlines MethodHandle invocations.
private static final MethodHandle APPLY;
static {
try {
APPLY = MethodHandles.lookup().findVirtual(
TaxRule.class, "apply", MethodType.methodType(BigDecimal.class, BigDecimal.class));
} catch (Exception e) { throw new ExceptionInInitializerError(e); }
}
Bug 3 — super.method() not calling the expected ancestor¶
class Sensor {
public void calibrate() { System.out.println("Sensor.calibrate"); }
}
class TempSensor extends Sensor {
@Override public void calibrate() {
System.out.println("TempSensor.calibrate");
super.calibrate();
}
}
class HighPrecisionTempSensor extends TempSensor {
@Override public void calibrate() {
System.out.println("HighPrecisionTempSensor.calibrate");
super.calibrate();
}
}
// Someone adds a calibration audit by overriding Sensor.calibrate in a fourth subclass:
class AuditingSensor extends Sensor {
@Override public void calibrate() {
System.out.println("AuditingSensor.calibrate");
super.calibrate();
}
}
The team expects new HighPrecisionTempSensor().calibrate() to also run audit logic.
Symptom. No AuditingSensor.calibrate ever runs. Stack trace shows:
AuditingSensor is never on the call chain even though it "extends Sensor".
Violation. super.calibrate() is invokespecial — statically bound to the immediate superclass's method. Inheritance is single; AuditingSensor is a sibling, not an ancestor, of HighPrecisionTempSensor. There is no way for super.calibrate() to thread through a parallel class.
Fix. This is a design mistake masquerading as a dispatch bug. The pattern you want is composition with explicit cross-cutting concerns:
public final class CalibrationPipeline {
private final List<CalibrationStep> steps;
public CalibrationPipeline(CalibrationStep... steps) { this.steps = List.of(steps); }
public void run() { for (var s : steps) s.calibrate(); }
}
new CalibrationPipeline(new AuditingStep(), new HighPrecisionStep(), new TempStep()).run();
Each step is independent. The chain is data, not class inheritance. See ../../03-design-principles/02-composition-over-inheritance/.
Bug 4 — @FunctionalInterface call site with multiple lambda targets¶
public final class Cache<K, V> {
private final Map<K, V> map = new HashMap<>();
public V getOr(K key, Function<K, V> loader) {
return map.computeIfAbsent(key, loader);
}
}
// Many call sites pass different lambdas:
cache.getOr(id, this::loadUser);
cache.getOr(id, this::loadOrder);
cache.getOr(id, this::loadInvoice);
cache.getOr(id, k -> heavyComputation(k));
cache.getOr(id, k -> remoteFetch(k));
Symptom. Throughput regressions appear after switching from explicit anonymous-class Function implementations to lambdas. JFR shows HashMap.computeIfAbsent recompiling repeatedly. -XX:+PrintInlining shows the inner call loader.apply(k) as (megamorphic).
Violation. Each lambda compiles to a distinct synthetic class. Five callers means five Function implementations at the same computeIfAbsent call site. The call site inside HashMap.computeIfAbsent is megamorphic. CHA cannot help because the synthetic lambda classes are open — and they're loaded lazily, triggering deopt as each new one appears.
Fix. Three options:
- Inline the
computeIfAbsentper caller. Don't share agetOrmethod; each caller writes the smallif (!map.containsKey) map.put(...)directly. Each caller'scomputeIfAbsentis monomorphic on its own lambda. - Force eager loading of all lambda types at startup. A no-op call site warm-up at app boot loads each lambda class; the JIT sees all five before compiling the hot path. The site is still megamorphic, but at least the deopt cascade is bounded.
- Replace the lambda with a stable function-pool object. If five distinct loaders are needed, define five named classes implementing
Function. The JIT still sees five types at the call site, but at least the deopts don't keep happening at random.
This bug is common in functional-Java codebases. Lambdas are cheap to write, expensive when they fan in to a shared utility.
Bug 5 — CHA invalidation cascade after class load¶
public interface Encoder { String encode(Object o); }
public class JsonEncoder implements Encoder { public String encode(Object o) { ... } }
public final class ApiResponder {
private final Encoder encoder;
public ApiResponder(Encoder encoder) { this.encoder = encoder; }
public byte[] respond(Object payload) {
return encoder.encode(payload).getBytes(StandardCharsets.UTF_8);
}
}
The service runs fine for an hour. Then a plugin loads:
// Plugin loaded reflectively at hour 1:
public class XmlEncoder implements Encoder { public String encode(Object o) { ... } }
Symptom. A latency spike of ~50ms appears in 99th-percentile response time exactly when the plugin loads. The histogram of response times shows a bimodal distribution for the next minute, then settles back to the prior level — but ~10% slower than before.
Violation. CHA had devirtualized encoder.encode(...) in compiled ApiResponder.respond based on the assumption "only JsonEncoder implements Encoder". When XmlEncoder loaded, HotSpot deoptimized every method depending on that assumption — ApiResponder.respond and possibly its callers transitively. The interpreter handled requests during the deopt window (the latency spike). After recompilation, the call site is now bimorphic instead of fully devirtualized, hence the steady ~10% slowdown.
Fix. Two complementary moves:
- Eagerly load all plugins at startup, before the JIT compiles hot paths. Walk the plugin directory at boot, instantiate each implementer once. CHA sees the full set before optimizing.
- Seal the interface if the implementer set is small and known:
sealed makes CHA's invariant permanent. A new implementer is a compile-time edit, not a runtime surprise.
Bug 6 — Static method called via instance reference¶
public class IdGenerator {
public static long next() { return System.nanoTime(); }
}
public class OrderFactory {
private final IdGenerator gen;
public OrderFactory(IdGenerator gen) { this.gen = gen; }
public Order make() {
return new Order(gen.next(), ...); // looks like a normal instance call
}
}
A teammate reviews OrderFactory and says: "you can mock IdGenerator for tests because you're injecting it through the constructor."
Symptom. A test attempts to substitute a deterministic IdGenerator:
IdGenerator fake = mock(IdGenerator.class);
when(fake.next()).thenReturn(42L);
Order o = new OrderFactory(fake).make();
assertEquals(42L, o.id()); // FAILS: o.id() is some real System.nanoTime() value
Violation. gen.next() looks like an instance call, but next is static. The bytecode is invokestatic IdGenerator.next, not invokevirtual. The gen receiver is evaluated for null-check only; the static method runs regardless of which IdGenerator instance gen points to. Mocking is impossible because there's no dispatch happening.
Fix. Two options:
- Make
nextan instance method. It now dispatches;mock(IdGenerator.class)works. - If
nexttruly is a pure function (no per-instance state), don't inject it. A static utility called asIdGenerator.next()is honest about its lack of polymorphism. Tests that need to control IDs use a different abstraction (aClock-likeIdProviderinterface).
The general rule: obj.staticMethod() is a code smell — it pretends to be virtual dispatch but isn't. The IDE warning ("static method called through instance reference") exists for this exact reason; turn it into an error in your style guide.
Bug 7 — "Override" of a private method silently creates a new method¶
class Auditor {
public void audit(Event e) {
beforeAudit(e);
record(e);
afterAudit(e);
}
private void beforeAudit(Event e) { System.out.println("default before"); }
private void afterAudit(Event e) { System.out.println("default after"); }
private void record(Event e) { /* write to file */ }
}
class StrictAuditor extends Auditor {
private void beforeAudit(Event e) {
if (e.severity() < 3) throw new IllegalStateException("low severity rejected");
}
}
A StrictAuditor is supposed to reject low-severity events before recording.
Symptom. Low-severity events are recorded normally — the rejection logic in StrictAuditor.beforeAudit never runs.
new StrictAuditor().audit(lowSeverityEvent);
// prints "default before" then records — no exception thrown
Violation. Auditor.beforeAudit is private. Its call from Auditor.audit is invokespecial, statically bound to Auditor.beforeAudit. StrictAuditor.beforeAudit is a new method, not an override — private methods cannot be overridden (JLS §8.4.8, "a private method cannot override anything"). The two beforeAudit methods are unrelated; only one is ever called via audit.
Fix. Either:
- Make
beforeAuditprotected. Now it's overridable, the call site usesinvokevirtual, andStrictAuditor.beforeAuditactually overrides. - Use a template method pattern with an explicit hook interface.
Auditortakes aBeforeHookcollaborator and callshook.run(e). Composition replaces inheritance.
Use @Override on StrictAuditor.beforeAudit and javac would have caught this at compile time with method does not override. The @Override annotation isn't decoration; it's a static check.
Bug 8 — Default method diamond resolution surprise¶
interface Greeter {
default String hello() { return "Greeter says hello"; }
}
interface Farewell {
default String hello() { return "Farewell says hello"; }
}
class Chatty implements Greeter, Farewell {
// forgot to override hello()
}
Symptom. Compilation fails:
The team "fixes" it:
class Chatty implements Greeter, Farewell {
@Override public String hello() { return Greeter.super.hello(); }
}
Now another developer adds a new interface for "polite chat" and Chatty extends it:
interface PoliteGreeter extends Greeter {
@Override default String hello() { return "Greetings, kind soul."; }
}
class Chatty implements PoliteGreeter, Farewell {
@Override public String hello() { return Greeter.super.hello(); } // still
}
Symptom. new Chatty().hello() returns "Greeter says hello", not "Greetings, kind soul." — even though Chatty implements PoliteGreeter.
Violation. Greeter.super.hello() is invokespecial pointing at Greeter.hello, not at the maximally specific default. The super.hello() walks the direct superinterface as named, not the inheritance chain. PoliteGreeter's override is irrelevant because the source code says Greeter.super.hello().
Fix. Either:
- Call
PoliteGreeter.super.hello()explicitly. The most specific superinterface for this method. - Implement
hellodirectly inChatty. Most maintainable; no surprises when the interface hierarchy grows.
The general principle: Interface.super.method() is statically bound to that interface's method, just like class super.method(). Don't write code that depends on the lookup walking further.
Bug 9 — Bridge method invocation surprise¶
public class StringList extends ArrayList<String> {
@Override
public boolean add(String s) {
System.out.println("StringList.add(String): " + s);
return super.add(s);
}
}
class Caller {
void use() {
List rawList = new StringList(); // raw type
rawList.add(new Integer(42)); // legal due to raw type
}
}
Symptom. The code prints StringList.add(String): 42 then crashes with ClassCastException:
java.lang.ClassCastException: Integer cannot be cast to String
at com.example.StringList.add(StringList.java:5)
But the add line is super.add(s) — there's no obvious cast.
Violation. Because ArrayList<String>.add erases to add(Object), the JVM generates a bridge method boolean add(Object) that casts the argument to String and forwards to add(String). The cast happens in the synthetic bridge, not in your source. With a raw List reference, the bridge add(Object) is invoked, the cast fails before add(String) even runs (or in some compilers, fails inside add(String)'s entry).
javap -c StringList shows two add methods: the one you wrote, and a synthetic bridge add(Object) with a checkcast to String.
public boolean add(java.lang.String);
...
public boolean add(java.lang.Object); // <-- synthetic bridge
Code:
0: aload_0
1: aload_1
2: checkcast #2 // class java/lang/String
5: invokevirtual #3 // Method add:(Ljava/lang/String;)Z
8: ireturn
Fix. Don't use raw types. The bridge method is correct; the bug is that a List reference allowed unchecked insertion of Integer. Use List<String> and javac rejects the Integer argument.
The bridge-method mechanism is necessary for generics + erasure to work; it's a dispatch detail that mostly stays out of sight. Raw types are how it leaks. See ../03-covariant-returns-and-bridge-methods/.
Bug 10 — Inline cache thrashing under load¶
public interface RequestFilter { boolean accept(Request r); }
public final class FilterChain {
private RequestFilter filter;
public void setFilter(RequestFilter f) { this.filter = f; }
public boolean check(Request r) { return filter.accept(r); }
}
The production setup:
// At runtime, an admin endpoint can swap the filter:
POST /admin/filter { "type": "RegionFilter" }
POST /admin/filter { "type": "RateFilter" }
POST /admin/filter { "type": "WafFilter" }
POST /admin/filter { "type": "RegionFilter" }
// ... swapped every few seconds during incident response
Symptom. Under steady-state traffic, throughput is fine. During the incident — when ops keeps swapping filters — throughput collapses by 40%. -XX:+PrintCompilation shows FilterChain.check being marked not-entrant and recompiled repeatedly:
1234 12 % 4 FilterChain::check (12 bytes)
1240 4 made not entrant FilterChain::check
1250 13 % 4 FilterChain::check (12 bytes)
1260 4 made not entrant FilterChain::check
1280 14 % 4 FilterChain::check (12 bytes)
Violation. The inline cache for filter.accept(r) keyed on the call site sees a different concrete type after each swap. Initially monomorphic, it goes bimorphic, then megamorphic, then HotSpot recompiles to a megamorphic stub — which is slower than the original monomorphic version. After enough swaps, the IC may stabilize, but each transition costs a deopt.
Fix. Three options:
- Pre-load all filter classes at startup. All concrete types are visible to CHA from the start; the IC stabilizes earlier.
- Use
MutableCallSiteviainvokedynamic. AMutableCallSitelets you swap the target MethodHandle atomically. The JIT knows the call site is mutable from day one and doesn't speculate on monomorphism. The first compile assumes "this is a mutable call site"; swaps don't trigger deopt cascades. - Don't swap in production. If the admin endpoint exists for incident response, accept the perf hit during incident response — it's a feature, not a bug.
MutableCallSite is the canonical answer to "I need runtime swappable dispatch without deopt thrashing". See JVMS §6.5.invokedynamic and the java.lang.invoke Javadoc.
Pattern summary¶
| Trap | What to look for |
|---|---|
| Megamorphic call site (Bugs 1, 4) | One call site, many concrete receiver types, slow throughput |
| Static binding misread as virtual (Bugs 6, 7) | obj.staticMethod(), private "overrides" with no @Override |
invokespecial semantics (Bugs 3, 8) | super.m() not threading through unexpected siblings; default method super. resolution |
| CHA invalidation (Bugs 5, 10) | Latency spikes correlated with class loads; deopt cascades in logs |
Reflection vs final (Bug 2) | Method.invoke on a final method — final doesn't reach reflection |
| Bridge methods (Bug 9) | Raw types + ClassCastException in a method without an explicit cast |
These bugs usually compile cleanly and pass shallow tests. They surface as throughput regressions, latency spikes, deopt cascades, and "the wrong method ran". Train your eye to read the bytecode (javap -c -v) and the compile log (-XX:+PrintCompilation); the compiler won't catch them, the runtime will, expensively.