Java Polymorphism: Overloading and Overriding
java polymorphism: Understand how Java polymorphism works through method overloading and overriding, including runtime dispatch behavior and common pitfalls.
java polymorphism requires a clear understanding of the core syntax, runtime behavior, and practical implementation patterns demonstrated in the examples below.
Polymorphism in Java lets a single method name or type behave differently based on the object it operates on. The language supports two distinct forms: compile-time polymorphism through method overloading, and runtime polymorphism through method overriding. Understanding how each works, and where the JVM makes its decisions, is essential for writing code that is both flexible and predictable.
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. This is why 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. The binding happens during compilation, so there is no runtime lookup overhead.
Overloading is useful for providing convenient APIs that accept different input forms. It does not rely on inheritance or interfaces, and it is not affected by the runtime type of the object.
Runtime Polymorphism: Method Overriding
Method overriding occurs when a subclass provides its own implementation of a method inherited from a superclass. The method signature must match exactly, and the subclass method must not be more restrictive in access level. The @Override annotation helps 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 you call sound() on a reference of type Animal, the actual method executed depends on the runtime object. This is dynamic method dispatch. The JVM looks up the method in the object's class at runtime, not at compile time.
How the JVM Resolves Overridden Methods
The JVM uses a virtual method table, or vtable, for each class. When a class overrides a method, the vtable entry for that method points to the subclass implementation. When the JVM encounters an invokevirtual instruction, it looks up the method in the vtable of the actual object's class and jumps to the appropriate code.
This indirection adds a small overhead compared to a direct method call. In most applications the impact is negligible, but in tight loops with millions of calls it can matter. The JVM's 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 extend polymorphism to type hierarchies that are not strictly inheritance-based. 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 takes a PaymentProcessor can accept any implementation. This decouples the caller from the concrete class, enabling dependency injection and easier testing. The same principle applies to abstract classes, which 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, the subclass fields are not yet initialized, 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 signature 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 be confused when a subclass overloads a method that happens to have the same name as a superclass method. This creates a new method, not an override, and the compiler selects based on the reference type. Always check whether the parameter list matches exactly.
Performance and Maintainability Tradeoffs
Dynamic dispatch costs a vtable 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 the runtime type, it can be harder to trace the actual execution path. Tools like debuggers and IDE navigation help, but the codebase becomes less linear.
Prefer polymorphism when the set of behaviors is 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 that is hard to follow.
When designing for polymorphism, keep interfaces focused and small. A large interface forces every implementation to provide many methods, many of which may be irrelevant. The Interface Segregation Principle applies here: split interfaces into cohesive units so implementers only depend on what they use.