Java Polymorphism
Write flexible Java code with compile-time polymorphism (overloading) and runtime polymorphism (overriding).
Polymorphism is "one interface, many implementations." In Java it has two flavors:
- Compile-time polymorphism (overloading): the compiler picks between methods that share a name based on the argument types you pass.
- Runtime polymorphism (overriding): the JVM picks between method implementations based on the actual object the call is made on.
The runtime kind is what people usually mean by "polymorphism" in an OOP context, and it's the one that makes inheritance worth having. Without it, a Cat reference would be the only way to call Cat.speak() — you couldn't write code that loops over a mixed list of animals and asks each to speak.
Compile-time polymorphism — overloading
Two methods in the same class can share a name as long as their parameter lists differ. The compiler picks which one to call based on the argument types at the call site:
public class Printer {
void print(int n) { System.out.println("int: " + n); }
void print(double d) { System.out.println("double: " + d); }
void print(String s) { System.out.println("string: " + s); }
}
Printer p = new Printer();
p.print(5); // int
p.print(5.0); // double
p.print("hi"); // stringThis is decided entirely at compile time. The chosen method is baked into the bytecode; nothing changes at runtime. Method overloading was covered in detail in method overloading back in Part 5.
Runtime polymorphism — overriding and dynamic dispatch
The interesting kind. When a subclass overrides a method, calls through a parent-typed reference still dispatch to the subclass's version:
class Animal {
String speak() { return "(noise)"; }
}
class Cat extends Animal {
@Override String speak() { return "meow"; }
}
class Dog extends Animal {
@Override String speak() { return "woof"; }
}
Animal[] zoo = { new Cat(), new Dog(), new Animal() };
for (Animal a : zoo) {
System.out.println(a.speak());
}
// meow
// woof
// (noise)Each iteration a is typed as Animal, but the actual object is a Cat, a Dog, or an Animal. The call a.speak() doesn't pick the method at compile time — at compile time, the compiler only knows a is some Animal. At runtime, the JVM looks at the actual object and dispatches to that object's class's speak.
This is dynamic dispatch (sometimes called virtual dispatch). It's what makes the loop above interesting: it's written generically against Animal, and it works for any subclass — including ones that didn't exist when the loop was written.
Why it matters
Polymorphism is the OOP feature that makes code open to extension without modification. A function that takes a Shape and calls area() on it works for every shape that exists today and every shape someone adds tomorrow. The function doesn't need an if (shape instanceof Circle) chain.
double totalArea(List<Shape> shapes) {
double sum = 0;
for (Shape s : shapes) sum += s.area(); // dispatches to each subclass
return sum;
}Add Triangle extends Shape, and totalArea works on lists of triangles for free. This is the substance of the Open/Closed Principle — open to extension, closed to modification.
Upcasting and downcasting
Going from a subclass type to a parent type is an upcast. It's implicit and always safe:
Cat c = new Cat();
Animal a = c; // upcast — implicitGoing the other way — assigning a parent-typed reference back to a subclass type — is a downcast. It needs a cast expression, and the JVM checks at runtime that the object is actually of that subtype:
Animal a = new Cat();
Cat c = (Cat) a; // downcast — runtime check
Animal a2 = new Dog();
Cat c2 = (Cat) a2; // ClassCastException at runtimeThe compile-time-friendly alternative is the instanceof check, often combined with pattern matching in modern Java:
if (a instanceof Cat c) {
c.purr();
}Fields are not polymorphic
Dynamic dispatch applies to instance methods only. Fields, static methods, and private methods are bound at compile time based on the declared type of the reference:
class A {
String label = "A";
static String klass() { return "A"; }
}
class B extends A {
String label = "B";
static String klass() { return "B"; }
}
A a = new B();
System.out.println(a.label); // "A" — field, not polymorphic
System.out.println(a.klass()); // "A" — static, not polymorphicThis is one of the reasons you keep fields private and access them through methods — methods participate in polymorphism; fields don't.
@Override and silent bugs
Always annotate overrides with @Override. The annotation tells the compiler "this is meant to override a parent method — fail if it doesn't." Without it, a small typo creates a new method that looks like an override but isn't:
class Animal {
String speak() { return "(noise)"; }
}
class Cat extends Animal {
String Speak() { return "meow"; } // capital S — typo, new method
}
Animal a = new Cat();
System.out.println(a.speak()); // "(noise)" — Cat.Speak was never calledAdding @Override makes the compiler catch this immediately.
Polymorphism with interfaces
Inheritance isn't the only way. An interface is also a parent type — different concrete classes implement it, and code that takes the interface type works with all of them:
interface Shape { double area(); }
class Circle implements Shape { ... }
class Square implements Shape { ... }
Shape s = new Circle(5);
s.area(); // dispatched to Circle.areaSame idea — write code against the abstraction, let the runtime pick the implementation. The interfaces chapter dives into the mechanics.
A worked example
What's next
Polymorphism stands on top of one mechanism: a subclass replacing a method it inherited. That mechanism — what's allowed, what's not, and the @Override annotation that keeps you honest — is the subject of the next chapter. Continue to method overriding.
Practice
Animal a = new Cat(); a.speak(); — which speak() actually runs?