Runtime Polymorphism
Runtime polymorphism is one of the most powerful features in object-oriented Java. It lets you write code that works against a parent type, yet automatically calls the right method for whatever subclass object actually shows up — all decided at runtime, not at compile time.
What Is Runtime Polymorphism?
Runtime polymorphism (also called dynamic method dispatch or late binding) happens when an overridden method is resolved at runtime through a parent-type reference. The JVM looks at the actual object sitting in memory, not the declared type of the variable, to decide which method to call.
This is different from compile-time polymorphism (method overloading), where the compiler picks the method before the program ever runs.
Note: Runtime polymorphism requires method overriding — a subclass must redefine a method it inherited from its parent.
A Simple Example
class Animal {
void sound() {
System.out.println("Some generic animal sound");
}
}
class Dog extends Animal {
@Override
void sound() {
System.out.println("Woof!");
}
}
class Cat extends Animal {
@Override
void sound() {
System.out.println("Meow!");
}
}
public class Main {
public static void main(String[] args) {
Animal a; // declared type: Animal
a = new Dog(); // actual type: Dog
a.sound();
a = new Cat(); // actual type: Cat
a.sound();
}
}
Output:
Woof!
Meow!
The variable a is declared as Animal, but the JVM calls Dog.sound() and Cat.sound() respectively — because that is the actual object at runtime.
Upcasting: The Trigger
Runtime polymorphism is enabled by upcasting — assigning a subclass object to a parent-type reference. Java does this implicitly (no cast keyword needed) because every subclass is-a parent.
Animal a = new Dog(); // implicit upcast: Dog → Animal
You can also pass upcasted references into methods:
class Main {
static void makeSound(Animal a) { // accepts any Animal subtype
a.sound();
}
public static void main(String[] args) {
makeSound(new Dog());
makeSound(new Cat());
}
}
Output:
Woof!
Meow!
This is the real-world power of runtime polymorphism: your makeSound method never needs to change, even when you add a new subclass months later.
Tip: This pattern is the foundation of the Open/Closed Principle — open for extension, closed for modification.
Rules for Runtime Polymorphism
Runtime polymorphism has a few conditions:
| Requirement | Details |
|---|---|
| Inheritance | There must be an IS-A relationship (extends or implements) |
| Method overriding | The subclass must override the parent method |
| Parent-type reference | The variable must be declared as the parent (or interface) type |
| Object is a subtype | The actual object created must be a subclass instance |
Warning: Runtime polymorphism applies only to instance methods. It does NOT work for static methods, instance variables, or static variables — those are resolved at compile time based on the reference type.
Static Methods Are NOT Polymorphic
class Parent {
static void greet() {
System.out.println("Hello from Parent");
}
}
class Child extends Parent {
static void greet() {
System.out.println("Hello from Child");
}
}
public class Main {
public static void main(String[] args) {
Parent p = new Child();
p.greet(); // calls Parent.greet() — NOT Child.greet()
}
}
Output:
Hello from Parent
Static methods belong to the class, not the object. The compiler resolves them at compile time based on the reference type. This is called method hiding, not overriding.
Instance Variables Are NOT Polymorphic
class Parent {
int value = 10;
}
class Child extends Parent {
int value = 20;
}
public class Main {
public static void main(String[] args) {
Parent p = new Child();
System.out.println(p.value); // prints 10, NOT 20
}
}
Output:
10
Field access is compile-time bound. Only methods participate in runtime dispatch.
Polymorphism With Interfaces
Interfaces are another powerful way to achieve runtime polymorphism. An interface reference can point to any class that implements it.
interface Shape {
double area();
}
class Circle implements Shape {
double radius;
Circle(double r) { this.radius = r; }
@Override
public double area() {
return Math.PI * radius * radius;
}
}
class Rectangle implements Shape {
double width, height;
Rectangle(double w, double h) { this.width = w; this.height = h; }
@Override
public double area() {
return width * height;
}
}
public class Main {
public static void main(String[] args) {
Shape[] shapes = { new Circle(5), new Rectangle(4, 6) };
for (Shape s : shapes) {
System.out.printf("Area: %.2f%n", s.area());
}
}
}
Output:
Area: 78.54
Area: 24.00
See Interfaces for more details on using interface references.
Multi-Level Inheritance and Dispatch
When inheritance chains are deeper, the JVM always calls the most specific (most derived) override it can find.
class A {
void hello() { System.out.println("A"); }
}
class B extends A {
@Override
void hello() { System.out.println("B"); }
}
class C extends B {
// does NOT override hello()
}
public class Main {
public static void main(String[] args) {
A obj = new C();
obj.hello(); // walks up to B (closest override)
}
}
Output:
B
The JVM walks up the class hierarchy from C → finds B.hello() → calls it.
Under the Hood: vtable & Dynamic Dispatch
When the JVM loads a class, it builds a virtual method table (vtable) — an array of method pointers for every overridable (virtual) method. Subclass vtables copy their parent’s entries and replace any that the subclass overrides.
At a call site like a.sound(), the JVM:
- Reads the actual type of the object referenced by
a. - Looks up the vtable slot for
sound()on that type. - Jumps to the method pointer stored there.
This lookup costs one extra memory indirection compared to a static call, but the JIT compiler can eliminate it through a technique called inline caching and devirtualization — if the JIT observes that a call site always sees the same concrete type, it compiles it as a direct call. So in hot paths the overhead is effectively zero.
All non-private, non-final, non-static instance methods in Java are virtual by default. You can read the full details in vtable & Dynamic Dispatch and JIT Compilation & Bytecode.
Note: Marking a method or class
finalprevents overriding and lets the JIT skip the vtable lookup entirely — a small but real performance benefit in tight loops.
Compile-Time vs. Runtime Polymorphism
| Feature | Compile-Time | Runtime |
|---|---|---|
| Mechanism | Method overloading | Method overriding |
| Resolved at | Compile time | Runtime |
| Based on | Parameter types | Actual object type |
| Also called | Static binding | Dynamic binding |
| Works with | Static & instance methods | Instance methods only |
| Performance | Slightly faster | JIT can optimize to equal |
See Overloading vs Overriding for a deeper comparison.
Related Topics
- Method Overriding — the foundation that makes runtime polymorphism possible
- Compile-Time Polymorphism — the compile-time counterpart using method overloading
- Inheritance — how the IS-A relationship enables upcasting
- Interfaces — another key mechanism for achieving runtime polymorphism
- vtable & Dynamic Dispatch — deep dive into how the JVM resolves virtual calls
- Static & Dynamic Binding — understand when Java binds method calls at compile time vs. runtime