The Trap That Opens Every Discussion
List<Integer> list = new ArrayList<>(List.of(10, 20, 30));
list.remove(1); // expected: removes the element valued 1?
The element removed is 20 — index one, not value one. Two remove overloads exist (remove(int index) and remove(Object o)), and the compiler picked by argument static type: 1 is an int, exact match wins before boxing is ever considered. Understanding why requires understanding overload resolution — resolved entirely at compile time, using declared types only; runtime type plays no part.
Resolution Ladder
Compiler tries, first match wins:
┌────────────────────────────────────────────┐
│ 1. Exact match │
│ 2. Primitive widening int → long → double│
│ 3. Autoboxing int → Integer │
│ 4. Varargs int → int... │
└────────────────────────────────────────────┘
Reference types follow the same spirit:
most-specific applicable signature wins.
Each rung down is a bigger transformation of the argument, so the compiler prefers the cheapest conversion available.
void f(int x) { } // f(5) → exact; f((short)5) → widening
void f(long x) { }
void f(Integer x){ } // boxing — reached only if widening can't fit
void f(int... x) { } // last resort
Classic Traps
| Call | Binds to | Why |
|---|---|---|
f(null) with f(String) / f(Integer) | compile error | both applicable, neither more specific |
list.remove(1) on List<Integer> | remove(int index) | exact primitive beats boxing |
sum(1,2) with only sum(long,long) | widens each arg | widening precedes boxing |
| subclass overload with parent-typed param | hides, doesn’t override | static binding on declared type |
Static Binding Demo
class P { void who(P p){ print("P-P"); } void who(C c){ print("P-C"); } }
class C extends P { void who(C c){ print("C-C"); } }
P ref = new C();
ref.who(new C()); // "P-P" — compiler chose from STATIC type P
((C) ref).who(new C()); // "C-C" — cast changed the static type
The cast does not change the object; it changes what the compiler sees — which is all overloading cares about.
Design Guidance
- Overloads should do exactly the same thing at different convenience levels (
valueOffamily); if overloads differ semantically, rename them. - Avoid autoboxed/unboxed pairs and varargs siblings among overloads — resolution becomes unpredictable for maintainers.
- JDK’s permanent warning:
List.remove(int)vsremove(Object)remains a recurring production bug source.
Interview Framing
- “Which method runs?” questions test compile-time reasoning: answer always cites declared/reference type, never the instance.
- Scoring contrast sentence: overloading = static/early binding on reference type; overriding = dynamic/late binding on object type.
The Mechanism Being Asked About
The line animal.speak() must behave differently for a Dog and a Cat, yet the code is identical and compiled once. So the decision of which method runs cannot happen at compile time — the compiler only knows the declared type. Overriding plus dynamic dispatch is the runtime machinery that defers that decision to the object itself.
Animal a = new Dog();
a.speak();
│ compile time: static type Animal has speak() → call permitted
▼ runtime: object header → class Dog → vtable slot 'speak' → Dog.speak()
┌────────────┐ header ┌───────────────┐
│ Dog object │──────────► │ Dog vtable │
│ + fields │ │ speak → Dog │ ← actual method executed
└────────────┘ │ eat → Dog │
└───────────────┘
Top half: what the compiler checks (signature exists on the static type). Bottom half: what the JVM resolves (the receiver’s actual class supplies the implementation). Every polymorphic call in Java — interface or superclass reference — travels this two-stage path.
Legality Rules
| Rule | Direction allowed |
|---|---|
| Signature | Must match exactly (name + params) |
| Return type | Same or covariant subtype (Dog get() over Animal get()) |
| Access | Widen only (protected → public); narrowing = compile error |
| Exceptions | Narrow only; no new broader checked exceptions |
static methods | Hidden, never overridden — binding is static |
final / private / constructors | Not overridable |
@Override | Optional but mandatory in practice — turns typos into compile errors |
Field Hiding (the #1 trap)
class P { String tag = "P"; String tag(){ return "P"; } }
class C extends P { String tag = "C"; String tag(){ return "C"; } }
P ref = new C();
ref.tag // "P" fields bind STATICALLY on reference type
ref.tag() // "C" methods bind DYNAMICALLY on object type
Same identifier, opposite binding rules. Fields are not polymorphic — mixing same-named fields across a hierarchy guarantees confusion.
Design Consequences
- Dynamic dispatch powers Strategy, Template Method, and every interface seam: one call site, many behaviors.
- Constructors must not call overridable methods: the subclass override runs before subclass fields initialize — it sees default values; classic NPE source.
equals/hashCode/toStringcontracts depend on consistent overriding; breaking symmetry poisons hash-based collections.
Interview Framing
- One-line discriminator: overloading = compiler, argument types, reference type; overriding = JVM, receiver’s actual type.
- Expect follow-ups straight from this page: covariant returns, why narrowing access fails, constructor-calls-override bug.
The Question This Answers
If overriding picks the method at runtime based on the actual object, how does the JVM find it — search every class on every call? That would be hopeless. The answer is a precomputed table: each class gets one virtual method table built once at class loading; every instance carries a pointer to its class’s table. Runtime dispatch becomes: follow pointer, index slot, jump.
Object (header) Class metadata
┌──────────────┐ ┌────────────────────────────────┐
│ mark word │ │ Dog vtable │
│ class ptr ───┼────────► │ [0] hashCode → Object.hashCode │
│ fields... │ │ [1] speak → Dog.speak ◄──┤ invokevirtual #1
└──────────────┘ │ [2] eat → Dog.eat │ loads vtable,
└────────────────────────────────┘ indexes slot, calls
The slot number is fixed per method signature across the hierarchy — speak is slot [1] in Dog and every Dog subclass — which is why dispatch needs no searching, just one indexed load.
Dispatch Bytecodes
| Bytecode | Binding | Mechanism |
|---|---|---|
invokevirtual | dynamic | vtable slot fixed per class at load time |
invokeinterface | dynamic | itable lookup — historically slower than vtable index |
invokespecial | static | constructors, private, super.m() |
invokestatic | static | no receiver |
invokedynamic | deferred | lambdas, string concat — linked once via bootstrap |
Performance Reality
- A virtual call is an indirect jump: order of 1–3 ns (illustrative figure for modern x86) — the jump itself is cheap.
- The real cost is lost inlining: JIT can only inline code it has proven reachable at a call site.
- HotSpot devirtualizes aggressively: Class Hierarchy Analysis proving a single implementer converts virtual → direct + inlined.
- Call sites are profiled by receiver type:
one call site over time:
mono: Dog,Dog,Dog → inlined, near-free
bi: Dog,Cat,Dog → inline cache with 2 branches
mega: Dog,Cat,Bird,Ox.. → full vtable every call, zero inlining
A megamorphic site (3+ receiver types) loses inlining entirely; measured penalties on hot paths are commonly quoted at 10–20× versus inlined monomorphic code (illustrative order of magnitude, workload-dependent).
Consequences for LLD
- Interface-per-method “clean” designs create megamorphic hot loops in latency-critical code (parsers, matching engines); sealed hierarchies keep sites monomorphic.
sealedinterfaces (Java 17+) hand the JIT exhaustive type knowledge → better devirtualization inside permitted sets.- Lambdas add no anonymous-class vtable entries:
invokedynamicspins a hidden class at first use — bootstrap cost once, then normal dispatch.
Edge Cases & Traps
privatemethods never enter the vtable — they cannot collide with superclass implementations nor be overridden.finalmethods occupy vtable slots but JIT direct-calls them unconditionally.- Turning a library’s non-virtual method virtual (or adding an override) shifts dispatch slots — a binary-compatibility event.
Interview Framing
- “Is overriding slow?” Correct shape: worst case single-digit nanoseconds, usually devirtualized away entirely; the real cost model is inlining loss at megamorphic sites, not dispatch.
Premium Content
Unlock Polymorphism and all premium lessons with a subscription.
From ₹199.99/year — See plans