Menu

Earn Premium with Referrals

Invite your friends and earn Premium rewards through our referral program.

See how it works and start inviting friends.

Polymorphism
LLD

Polymorphism

Understand polymorphism, method overriding, interfaces, and how objects can exhibit different behaviors.

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

CallBinds toWhy
f(null) with f(String) / f(Integer)compile errorboth 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 argwidening precedes boxing
subclass overload with parent-typed paramhides, doesn’t overridestatic 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 (valueOf family); 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) vs remove(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

RuleDirection allowed
SignatureMust match exactly (name + params)
Return typeSame or covariant subtype (Dog get() over Animal get())
AccessWiden only (protectedpublic); narrowing = compile error
ExceptionsNarrow only; no new broader checked exceptions
static methodsHidden, never overridden — binding is static
final / private / constructorsNot overridable
@OverrideOptional 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/toString contracts 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

BytecodeBindingMechanism
invokevirtualdynamicvtable slot fixed per class at load time
invokeinterfacedynamicitable lookup — historically slower than vtable index
invokespecialstaticconstructors, private, super.m()
invokestaticstaticno receiver
invokedynamicdeferredlambdas, 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.
  • sealed interfaces (Java 17+) hand the JIT exhaustive type knowledge → better devirtualization inside permitted sets.
  • Lambdas add no anonymous-class vtable entries: invokedynamic spins a hidden class at first use — bootstrap cost once, then normal dispatch.

Edge Cases & Traps

  • private methods never enter the vtable — they cannot collide with superclass implementations nor be overridden.
  • final methods 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.

My Private Notes

Notes are auto-saved locally to this device.