Skip to content

Java · Object-Oriented Programming

Java Polymorphism Explained — Overriding vs Overloading & Dynamic Dispatch

8 min read Updated 2026-06-20 Share:

Practice Polymorphism interview questions

Polymorphism: one reference, many forms

Polymorphism means "many forms" — a single reference type that behaves differently depending on the actual object behind it. It is the mechanism that lets a List<Shape> call each shape's own area() without the caller knowing or caring which concrete class it holds. Interviewers probe polymorphism because it separates people who recite "the four pillars" from people who understand how dispatch actually happens — and because the edge cases (hidden statics, covariant returns, overload resolution) are where real bugs live. Java gives you two distinct flavors, decided at two different times.

Animal a = new Dog();
a.sound();        // runtime form: Dog's override is chosen by the object

print(5);         // compile-time form: print(int) chosen by the argument type
print("hi");      // compile-time form: print(String)

Runtime vs compile-time polymorphism

The two flavors differ in when the call is resolved:

  • Runtime (dynamic) polymorphism is method overriding. The JVM picks the method from the actual object's class during execution.
  • Compile-time (static) polymorphism is method overloading. The compiler picks the method from the declared argument types, fixed before the program runs.
void print(int x) { }
void print(Object o) { }
print(5);              // compile-time: print(int) — chosen by the literal's type

Animal a = pickAnimal();
a.sound();             // runtime: depends on what pickAnimal() actually returned

When an interviewer says "polymorphism" with no qualifier, they almost always mean the runtime kind — overriding plus dynamic dispatch. Overloading is the lesser cousin; it's really just a naming convenience the compiler resolves statically.

Method overriding and its rules

Overriding is when a subclass replaces an inherited instance method with its own implementation. To genuinely override (and not accidentally overload), the subclass method must obey four rules:

  1. Same name and parameter list — the signature must be identical.
  2. Same or covariant return type — you may narrow it to a subtype, never widen it.
  3. Same or wider access — you can loosen protected to public, never tighten it.
  4. Same or fewer/narrower checked exceptions — you can throw less, never more.
class Base {
  protected Number make() throws IOException { return 1; }
}
class Sub extends Base {
  @Override public Integer make() {     // wider access, covariant return,
    return 2;                            // and no checked throws — all legal
  }
}

Always write @Override. It costs nothing and turns a silent mistake — a typo'd signature that quietly becomes a brand-new overload — into a compile error. The annotation is the single cheapest correctness tool in the language.

Method overloading and how the compiler resolves it

Overloading offers several methods with the same name but different parameters. Resolution is the compiler's job, and it follows a strict phased search for the most specific applicable candidate using the static (declared) types of the arguments:

  1. Exact match and widening primitive conversions (intlong).
  2. Autoboxing/unboxing (intInteger).
  3. Varargs.
void m(Object o)   { }
void m(Integer i)  { }
void m(int... xs)  { }

m(5);                 // m(Integer) wins via boxing — preferred over varargs
Integer n = null;
m(n);                 // m(Integer) — direct reference match, no boxing
m((Object) n);        // m(Object) — the *cast* changes the static type

The decisive insight: overloads are chosen by the reference type you pass, not the runtime value. m((Object) myInteger) calls m(Object) even though the object is an Integer. Ambiguous or near-overlapping overloads breed subtle bugs — when in doubt, give the methods distinct names.

Dynamic dispatch: the engine of overriding

Dynamic (virtual) method dispatch is how the JVM decides at runtime which overridden method to invoke — based on the actual object's class, never the reference type. Every object carries a pointer to its class's method table (a vtable); a virtual call looks up the override there.

class Shape  { double area() { return 0; } }
class Circle extends Shape { @Override double area() { return Math.PI; } }

Shape s = new Circle();
s.area();             // Circle.area() — the object decides, not the Shape reference

In Java, instance methods are virtual by default — there is no virtual keyword because everything non-static, non-final, non-private already is. The runtime type always wins. This is precisely why a for (Shape sh : shapes) total += sh.area(); loop computes the right area for every concrete shape without a single instanceof check.

Covariant return types

When you override, the subclass may return a subtype of the original return type. This covariant return lets specialized classes hand back specialized objects without forcing callers to cast.

class Animal { Animal reproduce() { return new Animal(); } }
class Dog extends Animal {
  @Override Dog reproduce() { return new Dog(); }   // narrower return — legal
}

Dog puppy = new Dog().reproduce();   // no cast needed — return type is already Dog

Covariant returns are the backbone of fluent builder patterns and well-typed clone() overrides. Note the asymmetry: parameters are not covariant. Change a parameter type and you have written an overload, not an override — which is exactly the mistake @Override is there to catch.

Why fields and static methods are hidden, not overridden

This is the classic trick question. Overriding applies only to instance methods. Fields and static methods are resolved by static binding — the compiler picks them from the declared type of the reference. Redeclaring them in a subclass hides the parent version rather than overriding it.

class A { static String who() { return "A"; } int f = 1; void v() { } }
class B extends A { static String who() { return "B"; } int f = 2; @Override void v() { } }

A x = new B();
x.v();       // "B" via the override — dynamic binding (instance method)
x.who();     // "A" — static method resolved by declared type A (hiding)
x.f;         // 1   — fields are not polymorphic; declared type A wins

Because x.who() resolving to "A" surprises almost everyone, always call static methods on the class (A.who()), never through an instance reference. Fields should be private anyway, which sidesteps the hiding trap entirely.

Upcasting, downcasting, and instanceof

Polymorphism rests on upcasting — treating a subclass object as its supertype. It is implicit and always safe because a Dog genuinely is an Animal. Downcasting goes the other way, narrowing a supertype reference back to a subtype; it is explicit and can fail at runtime with a ClassCastException, so you guard it with instanceof.

Animal a = new Dog();              // upcast — implicit and always safe

if (a instanceof Dog d) {          // pattern matching (Java 16+) tests *and* binds
  d.fetch();                       // safe: d is a Dog inside this block
}

Cat c = (Cat) a;                   // unguarded downcast — ClassCastException at runtime!

Reach for downcasting sparingly. A program littered with instanceof/cast chains is often a program that should have pushed the behavior into a virtual method. The modern pattern-matching instanceof removes the redundant cast, but the design smell remains: if you're switching on type, a polymorphic method usually expresses it better.

A constructor caveat worth remembering

Dynamic dispatch is so eager it can bite you mid-construction. When a superclass constructor calls an overridable method, the subclass override runs before the subclass's own fields are initialized — so it sees default null/0 values.

class Base { Base() { init(); } void init() { } }
class Sub extends Base {
  String name = "set";
  @Override void init() { System.out.println(name); }   // prints null!
}
new Sub();   // null — Base() runs init() before name is assigned

The rule: never call overridable methods from a constructor. Make such helpers final or private so dispatch can't reach a not-yet-built subclass.

Recap

Java polymorphism comes in two forms resolved at two times: overloading (compile-time, chosen by the compiler from declared argument types) and overriding (runtime, chosen by the JVM from the actual object via dynamic dispatch). Overriding follows strict rules on signature, return type, access, and exceptions — guard every one with @Override. Returns may be covariant; parameters may not. Remember the traps: fields and static methods are hidden, not overridden (static binding by declared type), overloads resolve by reference type not runtime value, and constructors must never call overridable methods. Lean on upcasting and virtual calls instead of instanceof chains, and your designs stay open to extension and free of type-switching clutter.

More ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel