Static versus Dynamic Polymorphism
Two Kinds of Polymorphism
Java provides two distinct forms of polymorphism, operating at different times and by different mechanisms. Confusing them is a common source of bugs and a frequent Tripos pitfall.
Static Polymorphism: Method Overloading
Overloading is resolved at compile time by the compiler, using the declared (static) types of the arguments. Multiple methods in the same class share a name but have different parameter lists (different number, types, or order of parameters).
class Printer {
void print(int x) { System.out.println("int: " + x); }
void print(double x) { System.out.println("double: " + x); }
void print(String x) { System.out.println("String: " + x); }
void print(int x, int y){ System.out.println("two ints: " + x + ", " + y); }
}
Printer p = new Printer();
p.print(42); // "int: 42" — compiler picks print(int)
p.print(3.14); // "double: 3.14" — compiler picks print(double)
p.print("hello"); // "String: hello"
p.print(1, 2); // "two ints: 1, 2"
The compiler decides which overload to call based purely on the argument types at the call site. This decision is baked into the bytecode — the runtime type of the arguments is irrelevant.
Overloading Resolution with Subtypes
The compiler picks the most specific applicable method:
class Animal { }
class Dog extends Animal { }
void handle(Animal a) { System.out.println("Animal handler"); }
void handle(Dog d) { System.out.println("Dog handler"); }
Animal a = new Dog();
Dog d = new Dog();
handle(a); // "Animal handler" — declared type of a is Animal
handle(d); // "Dog handler" — declared type of d is Dog
Even though a holds a Dog object at runtime, the overloaded method is chosen based on a’s declared type (Animal). The runtime type does not enter into overloading resolution.
Ambiguous Overloads
If the compiler cannot determine a single most specific overload, it reports a compile error:
void m(int x, double y) { }
void m(double x, int y) { }
m(1, 2); // compile error: ambiguous — both are equally applicable
Dynamic Polymorphism: Method Overriding
Overriding is resolved at runtime by the JVM using the object’s runtime type. A subclass provides a new implementation for an inherited method with the same signature.
class Animal {
String sound() { return "..."; }
}
class Dog extends Animal {
@Override
String sound() { return "Woof"; }
}
class Cat extends Animal {
@Override
String sound() { return "Meow"; }
}
Animal a1 = new Dog();
Animal a2 = new Cat();
System.out.println(a1.sound()); // "Woof" — runtime type Dog
System.out.println(a2.sound()); // "Meow" — runtime type Cat
Both a1 and a2 have declared type Animal. But the JVM dispatches sound() based on the actual object’s class.
The Virtual Method Table (Vtable)
Dynamic dispatch is implemented using a virtual method table (vtable), also known as a dispatch table.
Each class has a vtable — an array of function pointers, one entry per virtual method. When a method is overridden in a subclass, the relevant entry in the subclass’s vtable points to the new implementation. When the JVM executes obj.method(), it:
- Follows the object reference to the heap object.
- Reads the object’s hidden class pointer (a reference to the
Classmetadata). - Indexes into that class’s vtable at the known offset for
method. - Jumps to the function pointer stored there.
This adds one level of indirection per virtual method call. It is a small cost, and modern JVMs aggressively optimise it away with inline caching and devirtualisation.
Key Differences: Summary Table
| Overloading (Static) | Overriding (Dynamic) | |
|---|---|---|
| Resolved | Compile time | Runtime |
| Mechanism | Compiler selects the most specific method based on argument types | JVM looks up the vtable based on the object’s runtime class |
| Depends on | Declared types of arguments | Runtime type of the receiver object |
| Where | Same class or subclass (methods with same name, different params) | Class hierarchy (methods with same signature) |
| Return type | Can be different | Must be same or covariant |
static methods | Can be overloaded | Cannot be overridden (hidden instead) |
final methods | Can be overloaded | Cannot be overridden |
Both Mechanisms in One Call
A single method call can involve both overloading resolution and overriding dispatch:
class Parent {
void m(Animal a) { System.out.println("Parent.m(Animal)"); }
}
class Child extends Parent {
@Override
void m(Animal a) { System.out.println("Child.m(Animal)"); }
void m(Dog d) { System.out.println("Child.m(Dog)"); } // overload, not override
}
Parent p = new Child();
Animal a = new Dog();
Dog d = new Dog();
p.m(a); // "Child.m(Animal)"
// Overloading: declared type of a is Animal → m(Animal)
// Overriding: runtime type of p is Child → Child.m(Animal)
p.m(d); // "Child.m(Animal)"
// Overloading: declared type of p is Parent → only m(Animal) visible
// Overriding: runtime type of p is Child → Child.m(Animal)
Even though d is a Dog and Child has an m(Dog) overload, p’s declared type is Parent, which only knows about m(Animal). Overloading resolution uses the declared type of the reference the method is called on; overriding dispatch uses the runtime type of the object.