Java Polymorphism
Polymorphism is one of the four core pillars of object-oriented programming, alongside encapsulation, inheritance, and abstraction. The word literally means “many forms,” and in Java it means a single method call, variable, or reference type can behave differently depending on the actual object involved. Polymorphism is what lets you write code against a general type like Animal or Shape and have it automatically do the right thing for every specific subtype you create, today and in the future, without ever touching the calling code again.
Overview: How Polymorphism Works
Java supports two distinct kinds of polymorphism, and it is important to keep them separate because they are resolved at completely different times.
Compile-time polymorphism (method overloading)
This happens when a class defines multiple methods with the same name but different parameter lists (different number, order, or types of parameters). The compiler decides, at compile time, exactly which version to call based on the argument types you pass. This is called static binding because the decision never changes at runtime — it is baked into the compiled bytecode.
Runtime polymorphism (method overriding)
This happens when a subclass provides its own implementation of a method that is already defined in its superclass, using the exact same signature. When you call that method through a superclass reference, the Java Virtual Machine does not look at the reference’s declared (static) type — it looks at the object’s actual (runtime) type and calls the version that belongs to that object. This is called dynamic binding or dynamic method dispatch, and it is the mechanism most people mean when they say “polymorphism” in Java.
Under the hood, every object carries a reference to metadata for its actual class. Non-private, non-static, non-final instance methods are invoked using the invokevirtual bytecode instruction, which looks up the method in a per-class method table (conceptually similar to a virtual method table, or vtable) belonging to the object’s real class, not the reference’s declared type. This lookup is what allows one line of code, such as animal.makeSound(), to run different logic depending on whether animal actually refers to a Dog, a Cat, or a plain Animal at that moment.
Polymorphism relies on upcasting: a subclass object can always be assigned to a variable of its superclass (or an implemented interface) type, because a Dog genuinely is an Animal. The reverse, downcasting, requires an explicit cast and should be guarded with instanceof to avoid a ClassCastException at runtime.
Syntax
class Superclass {
returnType methodName(parameters) {
// superclass behavior
}
}
class Subclass extends Superclass {
@Override
returnType methodName(parameters) {
// subclass-specific behavior; same signature as superclass
}
}
Superclass ref = new Subclass(); // upcasting
ref.methodName(); // dynamic dispatch calls Subclass's version
| Part | Meaning |
|---|---|
extends |
Establishes the inheritance relationship required for overriding. |
@Override |
Optional but strongly recommended annotation; the compiler verifies the method truly overrides a superclass method. |
| Matching signature | Same method name, same parameter types and order, and a covariant or identical return type — otherwise it is overloading, not overriding. |
Superclass ref = new Subclass() |
Upcasting: the variable’s declared type is the superclass, but it holds a subclass object. |
instanceof / cast |
Used to safely check an object’s real type before downcasting to access subclass-only members. |
Examples
Example 1: Runtime polymorphism with method overriding
class Animal {
String name;
Animal(String name) { this.name = name; }
void makeSound() {
System.out.println(name + " makes a generic animal sound");
}
}
class Dog extends Animal {
Dog(String name) { super(name); }
@Override
void makeSound() {
System.out.println(name + " barks: Woof!");
}
void fetch() {
System.out.println(name + " fetches the ball");
}
}
class Cat extends Animal {
Cat(String name) { super(name); }
@Override
void makeSound() {
System.out.println(name + " meows: Meow!");
}
}
public class Main {
public static void main(String[] args) {
Animal[] animals = { new Dog("Rex"), new Cat("Whiskers"), new Animal("Creature") };
for (Animal a : animals) {
a.makeSound();
if (a instanceof Dog) {
Dog d = (Dog) a;
d.fetch();
}
}
}
}
Output:
Rex barks: Woof!
Rex fetches the ball
Whiskers meows: Meow!
Creature makes a generic animal sound
Every element of the array is declared as Animal, yet each call to makeSound() runs the version that belongs to the object’s actual class. This is dynamic dispatch in action. Notice that to call fetch(), which only Dog defines, we had to check with instanceof and then downcast — the compiler only lets you call methods that exist on the reference’s declared type unless you cast.
Example 2: Compile-time polymorphism with method overloading
public class Main {
static int add(int a, int b) {
return a + b;
}
static double add(double a, double b) {
return a + b;
}
static int add(int a, int b, int c) {
return a + b + c;
}
public static void main(String[] args) {
System.out.println(add(2, 3));
System.out.println(add(2.5, 3.5));
System.out.println(add(1, 2, 3));
}
}
Output:
5
6.0
6
Here there is no inheritance at all — just three methods sharing the name add with different parameter lists. The compiler picks the correct overload purely by matching the argument types you supply, and that choice is fixed permanently in the compiled bytecode.
Example 3: Polymorphism with an abstract class
abstract class Shape {
abstract double area();
void describe() {
System.out.printf("%s has area %.2f%n", getClass().getSimpleName(), area());
}
}
class Circle extends Shape {
double radius;
Circle(double radius) { this.radius = radius; }
@Override
double area() { return Math.PI * radius * radius; }
}
class Rectangle extends Shape {
double width, height;
Rectangle(double width, double height) { this.width = width; this.height = height; }
@Override
double area() { return width * height; }
}
public class Main {
public static void main(String[] args) {
Shape[] shapes = { new Circle(3), new Rectangle(4, 5) };
double total = 0;
for (Shape s : shapes) {
s.describe();
total += s.area();
}
System.out.printf("Total area: %.2f%n", total);
}
}
Output:
Circle has area 28.27
Rectangle has area 20.00
Total area: 48.27
Shape cannot be instantiated because it is abstract, but it defines a contract: every concrete shape must supply its own area(). The describe() method is written once on Shape, yet it correctly reports each object’s real area because area() is resolved dynamically for whichever object is currently being iterated.
Under the Hood: Step by Step
- When you write
Animal a = new Dog("Rex"), the JVM allocates aDogobject on the heap. The variableais just a reference whose declared (compile-time) type isAnimal. - When the compiler sees
a.makeSound(), it first checks thatAnimal(the declared type) actually has amakeSoundmethod — this is a compile-time check that determines which calls are legal. - If the method is an instance method (not
private,static, orfinal), the compiler emits aninvokevirtualinstruction rather than hard-wiring a specific method body. - At runtime, the JVM inspects the actual object’s class metadata (its runtime type,
Dog) and looks upmakeSoundin that class’s method table, walking up the class hierarchy only if the subclass didn’t override it. - The
Dogversion executes, even though the reference variable was declared asAnimal. This lookup happens on every call, which is why it is called dynamic dispatch. - Fields and
staticmethods do not participate in this mechanism — they are resolved using the reference’s declared type, not the object’s actual type (a common source of bugs, covered below).
Common Mistakes
Mistake 1: Calling a subclass-only method through a superclass reference
Wrong code (does not compile):
Animal a = new Dog("Rex");
a.fetch();
Even though a actually holds a Dog at runtime, the compiler only allows calls that exist on the declared type, Animal. Since Animal has no fetch() method, this fails to compile with “cannot find symbol,” regardless of what object is really stored in a.
Fixed code — check the real type and downcast before calling the subclass-specific method:
class Animal2 {
String name;
Animal2(String name) { this.name = name; }
}
class Dog2 extends Animal2 {
Dog2(String name) { super(name); }
void fetch() {
System.out.println(name + " fetches the ball");
}
}
public class Main {
public static void main(String[] args) {
Animal2 a = new Dog2("Rex");
if (a instanceof Dog2) {
Dog2 d = (Dog2) a;
d.fetch();
}
}
}
Output:
Rex fetches the ball
Mistake 2: Accidentally overloading instead of overriding
This next example actually compiles, which makes it more dangerous — the bug is silent. The intention was to override greet, but the parameter type does not match, so a brand new overload is created instead:
class Parent {
void greet(Object o) {
System.out.println("Parent greet: " + o);
}
}
class Child extends Parent {
void greet(String s) {
System.out.println("Child greet: " + s);
}
}
public class Main {
public static void main(String[] args) {
Parent p = new Child();
p.greet("hello");
}
}
Output:
Parent greet: hello
Because overload resolution is based on the reference’s declared type at compile time, p.greet("hello") is compiled against Parent, which only has greet(Object) — so that is the version that runs, even though the runtime object is a Child. The greet(String) method in Child is a separate overload, not an override, and it is invisible through a Parent-typed reference.
Fixed code — always use @Override so the compiler verifies the signature truly matches:
class Parent {
void greet(Object o) {
System.out.println("Parent greet: " + o);
}
}
class Child extends Parent {
@Override
void greet(Object o) {
System.out.println("Child greet: " + o);
}
}
public class Main {
public static void main(String[] args) {
Parent p = new Child();
p.greet("hello");
}
}
Output:
Child greet: hello
With matching signatures and @Override present, if you ever mistype the parameter list the compiler will now reject the code instead of silently creating an overload.
Best Practices
- Always annotate intended overrides with
@Override— it turns a silent overloading mistake into a compile error. - Program to the supertype or interface (for example, declare variables as
Listinstead ofArrayList, orShapeinstead ofCircle) so your code automatically works with any future subtype. - Prefer polymorphic dispatch over long
if (x instanceof A) ... else if (x instanceof B) ...chains; let each subclass implement its own behavior instead. - Remember that fields and
staticmethods are not polymorphic — only non-static, non-private, non-final instance methods use dynamic dispatch. Avoid shadowing fields with the same name in a subclass. - Only call subclass-specific methods after a safe
instanceofcheck and cast; needing to do this often is a sign the method belongs on the superclass or an interface instead. - Keep overridden method contracts consistent: don’t silently change what a method promises to do just because a subclass implements it differently.
Practice Exercises
- Create a superclass
Employeewith a methoddouble calculateBonus()that returns a flat 100.0. Create subclassesManager(returns 500.0) andIntern(returns 50.0). Store several employees in an array typed asEmployee[]and print each one’s bonus using a loop, relying on dynamic dispatch. - Write a class
Printerwith three overloaded methods namedprint: one taking anint, one taking aString, and one taking adouble. Call all three frommainand predict which output line each call produces before running it. - Take the
Shapeexample from this lesson and add a new subclassTrianglewith abaseandheightfield, overridingarea()correctly. Add it to theshapesarray and confirm the total area updates correctly without changing any other code.
Summary
- Polymorphism lets one method name or reference type behave differently depending on context, and comes in two forms: compile-time (overloading) and runtime (overriding).
- Method overloading is resolved by the compiler based on the argument types you pass, and never changes at runtime.
- Method overriding is resolved at runtime using the object’s actual class, via dynamic method dispatch (
invokevirtual), regardless of the reference’s declared type. - Upcasting (subclass to superclass) is automatic; downcasting requires an explicit cast and should be guarded with
instanceof. - Fields and
staticmembers are not polymorphic — they resolve based on the reference’s declared type, which is a frequent source of subtle bugs. - Always use
@Overrideto let the compiler catch accidental overloading when you meant to override.
