Skip to content
Part IA Michaelmas Term

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 a is Animal.
  • Does Animal have a method sound() 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:

  1. Dereferences a to find the heap object (a Cat instance).
  2. From the object header, reads the class pointer → Cat.class.
  3. Indexes into Cat’s vtable at the offset for sound().
  4. Finds a pointer to Cat.sound() code.
  5. 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:

InstructionUsed forResolution
invokevirtualInstance methods (non-private, non-static)Dynamic dispatch via vtable
invokespecialConstructors, super calls, private methodsStatic — exact method known at compile time
invokestaticStatic methodsStatic — exact method known at compile time
invokeinterfaceInterface methodsDynamic dispatch (similar to invokevirtual but with interface-specific lookup)
invokedynamicLambda 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:

  1. Start at the object’s runtime class.
  2. If this class defines (or overrides) the method with a matching signature, use it.
  3. Otherwise, look in the superclass.
  4. Repeat until Object.
  5. 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.