Java Dynamic Method Dispatch Explained with Examples
java dynamic method dispatch: Understand how the JVM selects the correct method implementation at runtime, why it matters for polymorphism, and where it can mislead you.
What Java Dynamic Method Dispatch Does
Java dynamic method dispatch is the mechanism the JVM uses to decide which implementation of an overridden method to execute when a program calls a method through a reference whose compile-time type differs from the runtime type of the object it points to. Consider this minimal example:
class Animal { void speak() { System.out.println("Generic animal sound"); } } class Dog extends Animal { @Override void speak() { System.out.println("Bark"); } } public class DispatchDemo { public static void main(String[] args) { Animal a = new Dog(); a.speak(); // prints "Bark" } }
The variable a is declared as Animal, but the object it references is a Dog. The compiler allows the call because speak() exists on Animal. At runtime, the JVM inspects the actual object type and invokes Dog.speak(), not Animal.speak(). That decision is dynamic method dispatch.
Compile-Time Type vs Runtime Type
The distinction between the declared type of a variable and the actual type of the object it references is central to understanding dispatch. The compiler uses the declared type to validate the call. If speak() did not exist on Animal, the code would not compile, even if the object were a Dog. The runtime uses the actual object type to select the method body.
Animal a = new Dog(); // compile-time type: Animal, runtime type: Dog Dog d = new Dog(); // compile-time type: Dog, runtime type: Dog
In the first line, the reference type is Animal, so only Animal's interface is visible through a. In the second, d exposes Dog's full interface. But when an overridden method is called, both references produce the same output because the JVM dispatches on the runtime type.
How the JVM Resolves the Method
The JVM does not scan the class hierarchy on every call. Each class carries a method table, often called a vtable, that maps method signatures to their concrete implementations. When a class overrides a method, it replaces the entry in its vtable with its own implementation. When the JVM invokes a virtual method, it looks up the method in the vtable of the actual runtime class.
This lookup happens once per call site in the JIT-compiled code, and the JVM can inline the result when it proves that only one implementation can be reached. That optimization is why dynamic dispatch does not impose a meaningful cost in most production workloads.
Overriding Rules That Affect Dispatch
Dynamic dispatch only applies to instance methods that are overridden. Static methods, private methods, and constructors are not dispatched dynamically. Static methods are bound at compile time based on the reference type. Private methods cannot be overridden, so the compiler resolves them directly. Constructors are never polymorphic.
class Vehicle { static void describe() { System.out.println("Vehicle"); } void start() { System.out.println("Vehicle starting"); } } class Car extends Vehicle { static void describe() { System.out.println("Car"); } @Override void start() { System.out.println("Car starting"); } } public class DispatchRules { public static void main(String[] args) { Vehicle v = new Car(); v.describe(); // prints "Vehicle" — static binding v.start(); // prints "Car starting" — dynamic dispatch } }
The static method call v.describe() resolves to Vehicle.describe() because static resolution uses the declared type. The instance method call v.start() resolves to Car.start() because the runtime object is a Car. This asymmetry is a common source of confusion.
Field Access Does Not Use Dynamic Dispatch
Fields are not polymorphic in Java. Accessing a field through a reference always uses the compile-time type of that reference. This surprises developers who expect field access to behave like method calls.
class Parent { String name = "parent"; } class Child extends Parent { String name = "child"; } public class FieldAccess { public static void main(String[] args) { Parent p = new Child(); System.out.println(p.name); // prints "parent" } }
The field name exists in both classes, but p.name resolves to Parent.name because field resolution is static. If you need polymorphic behavior, expose the value through a getter method instead.
Where Dynamic Dispatch Matters in Real Code
Dynamic dispatch is what makes interfaces and abstract classes useful. When you write code against an interface, the JVM selects the concrete implementation at runtime. This is the foundation of strategy patterns, dependency injection, and most plugin architectures.
interface PaymentProcessor { void processPayment(double amount); } class CreditCardProcessor implements PaymentProcessor { @Override public void processPayment(double amount) { System.out.println("Charging credit card: " + amount); } } class PayPalProcessor implements PaymentProcessor { @Override public void processPayment(double amount) { System.out.println("Charging PayPal account: " + amount); } } public class Checkout { private final PaymentProcessor processor; public Checkout(PaymentProcessor processor) { this.processor = processor; } public void complete(double amount) { processor.processPayment(amount); } }
The Checkout class does not know which processor it holds. The JVM dispatches the processPayment call to whichever implementation was injected. Adding a new processor does not require changing Checkout. That is the practical value of dynamic dispatch.
Common Mistakes and Edge Cases
One recurring mistake is calling a method on null. Dynamic dispatch requires an actual object. A NullPointerException is thrown at the call site because there is no runtime type to dispatch on.
Another mistake is assuming that a method call inside a constructor dispatches to the subclass implementation. It does, but the subclass fields may not be initialized yet. Consider this:
class Base { Base() { log(); } void log() { System.out.println("Base log"); } } class Derived extends Base { private String message = "Derived message"; Derived() { // super() runs first, then message is initialized } @Override void log() { System.out.println(message); } }
When new Derived() executes, Base's constructor calls log(), which dispatches to Derived.log(). At that moment, message is still null because the field initializer runs after the superclass constructor completes. The output is null, not Derived message. This is a classic dynamic dispatch pitfall that leads to subtle bugs. Avoid calling overridable methods from constructors.
Performance and Maintainability Tradeoffs
Dynamic dispatch itself is cheap in modern JVMs. The JIT compiler can often determine the concrete type at a call site and devirtualize the call, eliminating the vtable lookup entirely. When it cannot, the lookup is a single indirect jump through the method table. You should not avoid polymorphism for performance reasons without profiling first.
The maintainability tradeoff is different. Heavy use of dynamic dispatch through deep inheritance hierarchies makes code harder to follow, because the reader must trace which class actually implements a method. Prefer composition and interfaces over deep class hierarchies, and keep overridden methods focused so the dispatch behavior remains predictable.
When Dynamic Dispatch Does Not Apply
Method overloading is resolved at compile time, not runtime. If you overload a method and call it through a parent reference, the compiler picks the overload based on the declared parameter types, not the runtime types of the arguments.
class Printer { void print(Object o) { System.out.println("Object"); } void print(String s) { System.out.println("String"); } } public class OverloadDemo { public static void main(String[] args) { Printer p = new Printer(); Object obj = "hello"; p.print(obj); // prints "Object" — compile-time overload resolution } }
The call p.print(obj) binds to print(Object) because the static type of obj is Object. Dynamic dispatch does not help here; overload resolution is entirely static. If you need runtime selection based on argument types, use instanceof checks or a visitor pattern instead.