Dynamic Dispatch in Detail
The Canonical Animal Example
class Animal {
String sound() { return "..."; }
}
class Cat extends Animal {
@Override
String sound() { return "Meow"; }
}
class Dog extends Animal {
@Override
String sound() { return "Woof"; }
}
class SilentFish extends Animal {
// does NOT override sound() — inherits Animal's "..."
}
Now iterate over a polymorphic array:
Animal[] zoo = { new Cat(), new Dog(), new SilentFish() };
for (Animal a : zoo) {
System.out.println(a.sound());
}
Output:
Meow
Woof
...
Even though the declared type of every element is Animal, the printed strings differ. This is dynamic dispatch in action.
Step-by-Step Walkthrough
Let us trace what happens when a.sound() executes for a = the first element:
Compile Time
The compiler sees the expression a.sound():
- Declared type of
aisAnimal. - Does
Animalhave a methodsound()with no parameters? Yes → compilation succeeds. - The compiler emits a bytecode instruction:
invokevirtual Animal.sound().
The compiler does not know or care that a will be a Cat at runtime. It only checks that the method exists on the declared type.
Runtime
The JVM executes invokevirtual:
- Dereferences
ato find the heap object (aCatinstance). - From the object header, reads the class pointer →
Cat.class. - Indexes into
Cat’s vtable at the offset forsound(). - Finds a pointer to
Cat.sound()code. - Jumps to and executes
Cat.sound(), which returns"Meow".
The JVM never consults the declared type (Animal) during dispatch. The declared type was only used to verify the call was legal; the actual dispatch is entirely runtime-type-driven.
What If the Method Only Exists on the Subclass?
class Dog extends Animal {
void fetch() { System.out.println("Fetching ball"); }
}
Animal a = new Dog();
a.fetch(); // COMPILE ERROR: fetch() is not on Animal
The compiler rejects the call because fetch() is not in Animal’s interface. Even though we know the object is a Dog, the compiler enforces the declared type. To call fetch(), we must downcast:
if (a instanceof Dog d) {
d.fetch(); // OK
}
This is a key design principle: the declared type acts as a static contract. Any method not on the declared type is invisible to code using that reference, regardless of the runtime type. This forces the programmer to acknowledge when they are relying on subtype-specific behaviour.
Non-Overridden Methods
When a method is not overridden, dynamic dispatch still works — it just finds the inherited implementation:
class Animal {
String breathe() { return "Breathing..."; }
}
class Dog extends Animal {
@Override
String sound() { return "Woof"; }
// breathe() is not overridden
}
Dog d = new Dog();
d.breathe(); // calls Animal.breathe() — found by walking up the class hierarchy
The JVM starts at Dog’s vtable. If breathe() has not been overridden, the vtable entry for Dog still points to Animal.breathe() (or is inherited by copying the pointer). The JVM does not need to “search” at runtime — the vtable is constructed at class-loading time so that every virtual method has a direct entry.
The Invoke Instructions
Different JVM bytecode instructions handle method calls:
| Instruction | Used for | Resolution |
|---|---|---|
invokevirtual | Instance methods (non-private, non-static) | Dynamic dispatch via vtable |
invokespecial | Constructors, super calls, private methods | Static — exact method known at compile time |
invokestatic | Static methods | Static — exact method known at compile time |
invokeinterface | Interface methods | Dynamic dispatch (similar to invokevirtual but with interface-specific lookup) |
invokedynamic | Lambda expressions, string concatenation (Java 8+) | Deferred — resolved by a bootstrap method at first call |
Method Resolution Order (MRO)
When the JVM needs to find an implementation for a virtual method, it conceptually walks up the class hierarchy from the object’s runtime class. In practice this walk is compiled into the vtable, but the algorithmic description is:
- Start at the object’s runtime class.
- If this class defines (or overrides) the method with a matching signature, use it.
- Otherwise, look in the superclass.
- Repeat until
Object. - If still not found, throw an
AbstractMethodError(should never happen for correctly compiled code).
Inheritance Depth Does Not Affect Dispatch Speed
Because the vtable provides a flat array indexed by method offset, looking up sound() takes the same constant time whether the class hierarchy is 2 levels deep or 20 levels deep. Deep hierarchies do not make virtual calls slower. The only cost of a deep hierarchy is code comprehension.
The final Optimisation
When a method is declared final, the compiler and JVM know it cannot be overridden. The JIT compiler can therefore devirtualise the call — replacing the vtable lookup with a direct jump, or even inlining the method body into the call site. This eliminates the dispatch overhead entirely. Classes declared final can similarly enable devirtualisation of all their methods.