Skip to content
Java polymorphism 6 min read

vtable & Dynamic Dispatch

When you call an overridden method on a parent-type reference, Java somehow knows which subclass version to run — every single time, without error. The machinery behind that magic is called the virtual method table (vtable) and the dynamic dispatch mechanism built into the JVM.

What Is a vtable?

A vtable (virtual method table) is a data structure the JVM creates for each class that holds a list of pointers to the actual method implementations the class provides. You never write one yourself — the JVM builds it automatically when it loads a class.

Think of it as a lookup table: each row corresponds to a virtual (overridable) method, and each cell points to the concrete code that should run. When a subclass overrides a method, its vtable row for that method is updated to point to the new implementation. If a subclass does not override the method, it simply inherits the pointer from the parent’s table.

vtable slotAnimal’s vtableDog’s vtable
slot 0Animal.sound() codeDog.sound() code (overridden)
slot 1Animal.eat() codeAnimal.eat() code (inherited)

Note: Only instance methods that can be overridden get vtable entries. static methods, private methods, and final methods are resolved at compile time — they do NOT participate in dynamic dispatch.

Dynamic Dispatch Step by Step

When you write animal.sound() where animal is declared as Animal but actually holds a Dog at runtime, here is exactly what happens:

  1. The JVM looks at the object reference stored in animal.
  2. It follows the reference to the actual Dog object on the heap.
  3. Every object on the heap contains a hidden pointer to its class’s vtable (called the klass pointer or class word).
  4. The JVM looks up the sound() slot in Dog’s vtable.
  5. It calls whatever method pointer lives there — Dog.sound().

This whole lookup is extremely fast; it is essentially a couple of pointer dereferences.

Seeing Dynamic Dispatch in Action

class Animal {
    void sound() {
        System.out.println("Generic animal sound");
    }

    void breathe() {
        System.out.println("Breathing...");
    }
}

class Dog extends Animal {
    @Override
    void sound() {                        // overrides — new vtable entry
        System.out.println("Woof!");
    }
    // breathe() not overridden — inherits Animal's vtable pointer
}

class Cat extends Animal {
    @Override
    void sound() {
        System.out.println("Meow!");
    }
}

public class VTableDemo {
    public static void main(String[] args) {
        Animal a1 = new Dog();  // declared Animal, actual Dog
        Animal a2 = new Cat();  // declared Animal, actual Cat

        a1.sound();   // JVM checks Dog's vtable   → Dog.sound()
        a2.sound();   // JVM checks Cat's vtable   → Cat.sound()
        a1.breathe(); // Dog has no override        → Animal.breathe()
    }
}

Output:

Woof!
Meow!
Breathing...

The variable type (Animal) is irrelevant at runtime. The vtable lookup on the actual object decides everything.

The Bytecode: invokevirtual

The compiler translates a virtual method call into the invokevirtual bytecode instruction. You can verify this with the javap tool (see javap Tool):

// Compile VTableDemo.java, then run:
// javap -c VTableDemo

Inside main, you will see lines like:

invokevirtual #7  // Method Animal.sound:()V

Notice the instruction says Animal.sound — that is the declared type. At runtime the JVM replaces that with the correct subclass version through the vtable. The compiler only ever sees the declared type; the JVM does the actual dispatch.

Tip: Compare this with invokespecial, which the JVM uses for private methods, constructors, and super calls. Those are resolved statically — no vtable lookup.

What Does NOT Use the vtable

Not every method goes through dynamic dispatch. These are resolved at compile or link time:

  • static methods — use invokestatic; belong to the class, not an instance.
  • private methods — use invokespecial; cannot be overridden.
  • final methods — the JIT compiler often inlines them completely.
  • Constructors — use invokespecial.
  • Interface default methods — use invokeinterface, a separate (slightly more complex) dispatch mechanism.

This is also why calling a method through a static or private reference is slightly faster in theory — though in practice the JIT compiler optimizes virtual calls so aggressively that the difference rarely matters.

How Interfaces Differ: itable

When you call a method through an interface reference, the JVM uses a companion structure called the itable (interface method table). The itable is separate because a class can implement many interfaces, so a single flat vtable would conflict. The lookup is slightly more work, but the JIT still manages to inline the most common case.

interface Speakable {
    void speak();
}

class Parrot implements Speakable {
    @Override
    public void speak() {
        System.out.println("Polly wants a cracker!");
    }
}

public class ItableDemo {
    public static void main(String[] args) {
        Speakable s = new Parrot();   // interface reference
        s.speak();                    // uses invokeinterface → itable lookup
    }
}

Output:

Polly wants a cracker!

Under the Hood

Object Header and klass Pointer

Every Java object on the heap begins with a small object header (typically 12–16 bytes on a 64-bit JVM with compressed oops). Part of that header is the klass pointer — a reference to the HotSpot internal Klass structure for the object’s actual class. That Klass structure contains the vtable. So the dispatch path is:

object reference → object header → klass pointer → Klass struct → vtable → method code

All of this happens in nanoseconds.

JIT Inlining and Devirtualization

The HotSpot JIT compiler is very smart about virtual dispatch. When profiling data shows that a vtable slot almost always resolves to the same concrete class, the JIT performs devirtualization: it replaces the vtable lookup with a direct call (or even inlines the method body entirely), then adds a guard check to fall back to the vtable if a different class appears. This means hot virtual calls often cost the same as direct calls.

This is one reason you should not prematurely optimize by making methods final just to “help the JIT” — the JIT already devirtualizes based on real profiling.

Sealed Classes and vtable (Java 17+)

Sealed classes (stable since Java 17) explicitly restrict which classes can extend a given type. This gives the JIT even stronger guarantees for devirtualization, because it can statically prove that only a small, known set of subclasses will ever appear.

Practical Implications for You

Understanding vtables helps you reason about several everyday decisions:

  • Why @Override matters: if you accidentally misspell a method name, the compiler catches it because no vtable slot exists for your “new” method. Always annotate with @Override.
  • Why polymorphism is safe: the vtable mechanism means the JVM cannot call the wrong method — the correct pointer is always in the table.
  • Why static methods are not polymorphic: they bypass the vtable entirely. Declaring a static method with the same signature in a subclass is called method hiding, not overriding, and it does not participate in dynamic dispatch (see static Keyword).
  • Performance: virtual dispatch is effectively free in modern JVMs for hot code paths thanks to JIT devirtualization. Design for correctness and clarity, not to avoid vtable lookups.

Warning: Do not call overridable (virtual) methods from a constructor. At the time the parent constructor runs, the subclass object is only partially initialized, but dynamic dispatch will still route to the subclass override — potentially reading uninitialized fields.

class Base {
    Base() {
        init(); // dangerous — dispatches to Child.init() before Child fields are set
    }
    void init() { System.out.println("Base.init"); }
}

class Child extends Base {
    int value = 42;
    @Override
    void init() {
        System.out.println("Child.init, value = " + value); // prints 0, not 42!
    }
}

Output:

Child.init, value = 0

The field value is still 0 because the Child constructor body (where value = 42 runs) has not executed yet.

Last updated June 13, 2026
Was this helpful?