Java Runtime Polymorphism: How Dynamic Dispatch Works
java runtime polymorphism: Understand how Java runtime polymorphism works through dynamic dispatch, method overriding, and interface implementation, with practical exa...
When a Java method call is resolved at runtime based on the actual object type rather than the reference type, that is runtime polymorphism. This is what makes code like Animal a = new Dog(); a.speak(); call Dog.speak() instead of Animal.speak(). The decision happens at runtime because the JVM does not know the concrete type until the object is created. This article explains the mechanism behind java runtime polymorphism, shows where it applies, and discusses the practical tradeoffs you should consider when designing classes.
How Java Resolves Overridden Methods at Runtime
Java uses dynamic dispatch for instance methods. When you call a method on a reference, the JVM looks up the actual class of the object and invokes the most specific override. This lookup is not done by the compiler; it is deferred to runtime. The compiler only checks that the method exists in the declared type, but the actual implementation is selected when the call executes.
The JVM implements this with a method table (vtable) per class. Each class has a table of method pointers, and subclasses override entries by replacing them with their own implementations. When the bytecode instruction invokevirtual executes, the JVM reads the object's class, finds the vtable, and jumps to the correct method. This is why a superclass reference can call an overridden method that did not exist when the superclass was compiled.
Consider a simple hierarchy:
class Animal { void speak() { System.out.println("Some sound"); } } class Dog extends Animal { @Override void speak() { System.out.println("Woof"); } } class Cat extends Animal { @Override void speak() { System.out.println("Meow"); } }
If you write Animal a = new Dog(); a.speak();, the JVM sees that a actually points to a Dog instance and calls Dog.speak(). The same method call on a Cat reference would invoke Cat.speak(). This is the core of runtime polymorphism: the same call site behaves differently depending on the object's runtime type.
A Minimal Example of Runtime Polymorphism
A typical use is processing a collection of objects through a common interface or superclass. Suppose you have a list of Animal references that contain Dog and Cat instances. Iterating and calling speak() dispatches to the correct subclass method:
List<Animal> animals = new ArrayList<>(); animals.add(new Dog()); animals.add(new Cat()); for (Animal animal : animals) { animal.speak(); }
Output:
Woof
Meow
The loop does not need to know which concrete class each element belongs to. That is the practical benefit: you can write code against a general contract and extend it later without modifying existing callers. Adding a new subclass, say Bird, requires only that it extends Animal and overrides speak(). The loop above continues to work unchanged.
This pattern is common in frameworks, event handling, and plugin architectures where the exact implementation is determined at runtime. It also enables the Open/Closed Principle: classes are open for extension but closed for modification.
What Is and Isn't Polymorphic in Java
Not every method call is resolved dynamically. Java applies runtime polymorphism only to instance methods that are overridden. The following are excluded:
- Static methods: They are bound to the class at compile time. Calling
Animal.staticMethod()on aDogreference still invokesAnimal's version, notDog's, even ifDogdeclares a method with the same signature. - Private methods: They are not inherited and cannot be overridden. A private method in a superclass is invisible to subclasses, so no dispatch occurs.
- Final methods: A
finalmethod cannot be overridden. The compiler may inline it or treat it as non-virtual. - Fields: Field access is not polymorphic. If a subclass declares a field with the same name as a superclass field, both fields exist independently. Accessing
obj.fielduses the declared type of the reference, not the runtime type.
These distinctions matter when you design APIs. If you rely on runtime polymorphism, ensure the methods you intend to override are non-static, non-private, and non-final. Also, be careful with field hiding: it often leads to subtle bugs because the field accessed depends on the reference type.
Covariant Return Types and @Override
Since Java 5, an overriding method may return a subtype of the return type declared in the superclass. This is called a covariant return type. For example:
class Animal { Animal getFriend() { return new Animal(); } } class Dog extends Animal { @Override Dog getFriend() { return new Dog(); } }
Here, Dog.getFriend() returns Dog, which is a subtype of Animal. This is allowed because the JVM uses a bridge method to preserve compatibility with the superclass signature. The bridge method calls the actual overridden method and casts the result.
The @Override annotation is not required, but it is strongly recommended. It tells the compiler to verify that the method actually overrides a superclass method. If you misspell the method name or use an incompatible signature, the compiler reports an error instead of silently creating a new method. This prevents a common mistake where you intend to override but accidentally overload.
Runtime Polymorphism with Interfaces
Interfaces also participate in runtime polymorphism. When a class implements an interface, the JVM uses the same dynamic dispatch mechanism. The interface reference can point to any implementing class, and method calls are resolved to the actual implementation.
interface Shape { double area(); } class Circle implements Shape { private final double radius; Circle(double radius) { this.radius = radius; } @Override public double area() { return Math.PI * radius * radius; } } class Square implements Shape { private final double side; Square(double side) { this.side = side; } @Override public double area() { return side * side; } }
Calling Shape s = new Circle(2.0); s.area(); invokes Circle.area(). This is the same runtime behavior as with class inheritance. Interfaces are often preferred over abstract classes because a class can implement multiple interfaces, while it can extend only one class. This gives you more flexibility in designing polymorphic behavior without forcing a rigid inheritance hierarchy.
Performance and JIT Behavior
Runtime polymorphism has a small cost compared to a direct method call. The JVM must perform a vtable lookup, which is an extra indirection. However, modern JVMs mitigate this with just-in-time (JIT) compilation. When a call site consistently sees the same concrete type, the JIT can inline the method, eliminating the dispatch overhead entirely. This is called monomorphic inlining.
If a call site sees multiple types, the JIT may use a technique called polymorphic inline caching. It checks the actual type against a small set of known types and jumps directly to the correct implementation. If the number of types exceeds a threshold, the call becomes megamorphic, and the JIT falls back to a full vtable lookup. Megamorphic calls are slower but still correct.
You should not avoid runtime polymorphism for performance reasons in most applications. The dispatch overhead is negligible compared to the cost of the method body itself. Premature optimization here usually sacrifices maintainability. However, if you are writing a hot loop that calls a virtual method millions of times and profiling shows a bottleneck, consider whether you can restructure the code to reduce megamorphic calls, for example by using a switch on a type tag or by using functional interfaces with lambdas.
Design Tradeoffs and Maintainability
Runtime polymorphism is a powerful tool, but it is not always the right choice. Overusing inheritance can lead to deep hierarchies that are hard to modify and test. Before adding a subclass, ask whether the relationship is truly an "is-a" relationship and whether the behavior is likely to vary. If you only need to vary a single algorithm, composition with a strategy pattern might be cleaner.
Consider the following when deciding between inheritance and composition:
- Inheritance exposes the superclass's implementation details to subclasses. A change in the superclass can break subclasses in unexpected ways.
- Composition keeps the implementation hidden behind an interface. You can swap behavior at runtime by assigning a different implementation, which is more flexible.
- Testing is often easier with composition because you can mock the collaborator. With inheritance, you may need to mock the superclass or use a test-specific subclass.
A common example is a PaymentProcessor that should support different payment methods. Instead of creating subclasses for each method, define an interface and have separate classes implement it. Then the payment service holds a reference to the interface and delegates the actual processing. This gives you runtime polymorphism without forcing a rigid class hierarchy.
Another consideration is the Liskov Substitution Principle. A subclass must be substitutable for its superclass without altering the correctness of the program. If an override changes the contract in a way that violates expectations, runtime polymorphism becomes a source of bugs. For instance, if Dog.speak() throws an exception that Animal.speak() does not declare, callers that expect the superclass contract will break. Always ensure that overridden methods honor the original contract, including preconditions, postconditions, and exception behavior.
Finally, remember that runtime polymorphism is about behavior, not data. Fields are not polymorphic, so do not try to use them to achieve polymorphic behavior. If you need different data per type, model it in the subclass and expose it through overridden methods. This keeps the abstraction clean and avoids the confusion of hidden fields.
In practice, runtime polymorphism is most valuable when you have a stable contract and a set of implementations that can change independently. It lets you extend a system without rewriting existing code, which is why it remains a central feature of Java's object-oriented model.