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:
- Same name and parameter list — the signature must be identical.
- Same or covariant return type — you may narrow it to a subtype, never widen it.
- Same or wider access — you can loosen
protectedtopublic, never tighten it. - 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:
- Exact match and widening primitive conversions (
int→long). - Autoboxing/unboxing (
int→Integer). - 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.