Back to Blog
Java

Java Polymorphism: Overloading and Overriding

Understand how Java polymorphism works through method overloading and overriding, including runtime dispatch behavior and common pitfalls.

polymorphismmethod overloadingmethod overridingdynamic dispatchinterfaces
Diagram illustrating Java polymorphism with method overloading and overriding, showing compile-time and runtime dispatch.

Polymorphism in Java lets a single method name or type behave differently depending on the object it operates on. Java supports two related forms: compile-time polymorphism through method overloading, and runtime polymorphism through method overriding. Knowing where each decision is made helps you write flexible code without surprises.

Compile-Time Polymorphism: Method Overloading

Method overloading allows a class to define multiple methods with the same name but different parameter lists. The compiler selects the correct method based on the number and types of arguments at compile time, so overloading is sometimes called static polymorphism.

public class Calculator { public int add(int a, int b) { return a + b; } public double add(double a, double b) { return a + b; } public int add(int a, int b, int c) { return a + b + c; } }

The compiler decides which add method to invoke based on the argument types and count. add(1, 2) binds to the first method, add(1.0, 2.0) to the second, and add(1, 2, 3) to the third. Binding happens during compilation, so there is no runtime lookup overhead.

Overloading is useful for APIs that accept different input forms. It does not depend on inheritance or interfaces, and the runtime type of the object does not affect which overload is chosen.

Runtime Polymorphism: Method Overriding

Method overriding occurs when a subclass provides its own implementation of an inherited method. The overriding method must have the same name and parameter list as the superclass method; it may use a covariant return type, but it cannot have a more restrictive access modifier. Adding @Override lets the compiler catch signature mistakes.

public class Animal { public void sound() { System.out.println("Some generic animal sound"); } } public class Dog extends Animal { @Override public void sound() { System.out.println("Bark"); } } public class Cat extends Animal { @Override public void sound() { System.out.println("Meow"); } }

When sound() is called on a reference of type Animal, the actual method executed depends on the runtime object. This is dynamic method dispatch: the JVM resolves the method from the object's class at runtime, not at compile time.

How the JVM Resolves Overridden Methods

The JVM uses per-class method tables, often called vtables. When a class overrides a method, the table entry for that method points to the subclass implementation. When the JVM sees an invokevirtual instruction, it looks up the method in the table for the actual object's class and jumps to the appropriate code.

This indirection adds a small overhead compared with a direct call. In most applications the impact is negligible, but in tight loops with millions of calls it can matter. The JIT compiler often applies devirtualization when it can prove the receiver type is fixed, eliminating the lookup.

Polymorphism with Interfaces and Abstract Classes

Interfaces and abstract classes make polymorphism work across broader type hierarchies. A class can implement multiple interfaces, and a method can accept an interface type while receiving any implementation.

public interface PaymentProcessor { void processPayment(double amount); } public class CreditCardProcessor implements PaymentProcessor { @Override public void processPayment(double amount) { // charge credit card } } public class PayPalProcessor implements PaymentProcessor { @Override public void processPayment(double amount) { // charge PayPal account } }

A method that receives a PaymentProcessor can accept any implementation. This decouples the caller from the concrete class, enabling dependency injection and easier testing. Abstract classes work similarly but can provide partial implementation and leave abstract methods for subclasses.

Common Mistakes and Pitfalls

One frequent mistake is calling an overridable method from a constructor. When the constructor runs, subclass fields are not initialized yet, so the overridden method may see null or default values. Avoid invoking overridden methods in constructors unless you are certain of the initialization order.

Another issue is forgetting @Override when overriding. Without it, a typo in the parameter list silently creates a new method instead of overriding the superclass method, leading to surprising behavior. The annotation makes the compiler verify the override.

Overloading and overriding can also be confused when a subclass declares a method with the same name as a superclass method but a different parameter list. That creates a new overload, not an override, and the compiler selects the method based on the reference type. Check whether the parameter list matches exactly.

Performance and Maintainability Tradeoffs

Dynamic dispatch costs a method-table lookup per call, but the JIT can often optimize it away. The larger maintainability concern is that polymorphism spreads behavior across many classes. When a method's behavior depends on runtime type, tracing the actual execution path can become harder. Debuggers and IDE navigation help, but the codebase becomes less linear.

Prefer polymorphism when the set of behaviors should remain open to extension, such as adding new payment processors without modifying existing code. If the set is fixed and small, a simple conditional may be clearer. Overusing polymorphism can lead to class explosion and indirection.

When designing for polymorphism, keep interfaces focused and small. A large interface forces every implementation to provide many methods, some of which may be irrelevant. The Interface Segregation Principle applies here: split interfaces into cohesive units so implementers depend only on what they use.

Java Polymorphism: Practical Usage and Code Examples | RYUSLOG DEV