Introduction
You’ve probably seen this moment in Java:
You call a method on a variable that looks like one type, but at runtime it behaves like a different one. Maybe you expected one output… and got another.
Or you wrote two methods with the same name (overloading), then later added a subclass and overrode one of them—only to realize Java chose a different method than you thought.
These bugs aren’t random. They’re the result of how Java handles polymorphism, specifically overloading, overriding, interfaces, and dynamic dispatch. Let’s make these concepts feel predictable instead of mysterious.
Main Concepts
Polymorphism in plain English
Polymorphism means: the same method call can result in different behavior depending on the situation.
In Java, that “situation” is usually either:
- Compile-time type (what the variable is declared as)
- Runtime type (what object you actually have)
Java uses both in different ways depending on whether you’re dealing with overloading or overriding.
Overloading: compile-time choice
Overloading happens when multiple methods have the same name in the same class (or subclass) but different parameter lists.
Java decides which overloaded method to call based on the compile-time types of the arguments.
That means overloading is mostly determined before the program runs.
Example:
class Calculator {
int add(int a, int b) {
return a + b;
}
double add(double a, double b) {
return a + b;
}
}
If you call add(1, 2) you’ll get the int version, because both arguments are compile-time int.
But if you do:
Calculator c = new Calculator();
System.out.println(c.add(1, 2.0));
Java will select the best match based on the compile-time argument types (here: int + double triggers numeric conversions and selects the double overload).
Key point: overloading does not depend on runtime object type. It depends on the method call signature Java sees at compile time.
Overriding: runtime choice
Overriding happens when a subclass provides a new implementation of a method declared in a superclass.
Unlike overloading, overriding is resolved using the runtime type of the object.
That’s where polymorphism really kicks in.
Example:
class Animal {
void speak() {
System.out.println("Animal sound");
}
}
class Dog extends Animal {
@Override
void speak() {
System.out.println("Woof");
}
}
Now compare:
Animal a = new Dog(); // compile-time: Animal, runtime: Dog
a.speak(); // prints "Woof"
Even though the variable a is declared as Animal, the JVM calls Dog.speak() because the actual object is a Dog.
Key point: overriding is dynamic dispatch.
Dynamic dispatch: the mechanism
Dynamic dispatch is the runtime process that selects the correct overridden method implementation.
When you call a.speak(), Java doesn’t just “look up” speak() in the Animal class and stop there. Instead, it checks the actual runtime class of a and invokes the method that matches that class’s override.
This is what makes polymorphism useful: you can write code against a general type (Animal) and still get specific behavior (Dog, Cat, etc.).
Interfaces: polymorphism without inheritance
You can achieve polymorphism using interfaces too—often preferred in real code because it avoids deep inheritance trees.
An interface defines a contract:
interface Payment {
void pay(int amount);
}
Different classes implement it:
class CardPayment implements Payment {
@Override
public void pay(int amount) {
System.out.println("Paid " + amount + " using a card");
}
}
class CashPayment implements Payment {
@Override
public void pay(int amount) {
System.out.println("Paid " + amount + " using cash");
}
}
Now write code against the interface:
void checkout(Payment payment) {
payment.pay(50);
}
Call it like this:
checkout(new CardPayment()); // Paid 50 using a card
checkout(new CashPayment()); // Paid 50 using cash
Same method call (pay(50)), different behavior at runtime. That’s polymorphism via interfaces + dynamic dispatch.
Putting it together: compile-time vs runtime
A good mental model:
- Overloading: decided at compile time using argument types.
- Overriding: decided at runtime using the object’s actual type.
Mix those up and you’ll get surprising results.
Example
Let’s build a small scenario that includes overloading, overriding, and interfaces in one place.
Step 1: Define an interface with an overriding target
interface Shape {
int area(); // overridden implementations
String describe(); // overridden implementations
}
Step 2: Create two implementations
class Rectangle implements Shape {
private final int w;
private final int h;
Rectangle(int w, int h) {
this.w = w;
this.h = h;
}
@Override
public int area() {
return w * h;
}
@Override
public String describe() {
return "Rectangle";
}
}
class Square extends Rectangle {
private final int side;
Square(int side) {
super(side, side);
this.side = side;
}
@Override
public int area() {
return side * side;
}
@Override
public String describe() {
return "Square";
}
}
Step 3: Add overloading in a separate utility method
class Printer {
// Overloaded: compile-time selection
void print(Shape s) {
System.out.println("Shape: " + s.describe() + ", area=" + s.area());
}
void print(Rectangle r) {
System.out.println("Rectangle-ish: " + r.describe() + ", area=" + r.area());
}
}
Step 4: Observe what happens when you call overloaded methods
public class Demo {
public static void main(String[] args) {
Printer printer = new Printer();
Shape shape = new Square(4); // compile-time Shape, runtime Square
Rectangle rect = new Square(4); // compile-time Rectangle, runtime Square
printer.print(shape);
printer.print(rect);
}
}
What to expect:
-
printer.print(shape):- Overload choice uses compile-time type:
Shape - It calls
print(Shape s) - Inside,
s.describe()ands.area()use dynamic dispatch: shapeis actually aSquare, so you get"Square"and16
- Overload choice uses compile-time type:
-
printer.print(rect):- Overload choice uses compile-time type:
Rectangle - It calls
print(Rectangle r) - Again, overridden methods (
describe,area) are selected at runtime fromSquare
- Overload choice uses compile-time type:
This example highlights the “two-stage” behavior: overloading picks the method, then overriding chooses the behavior inside.
Practical Use
1) Write flexible code with interfaces
When you define something like Payment, Notification, or Storage, you can build the rest of your system around that interface. That keeps your code open for extension:
- Add
PayPalPaymentlater without rewriting the checkout flow.
2) Use @Override aggressively
Junior devs often forget @Override, and then accidentally create a new method instead of overriding (wrong signature, different parameters, etc.). With @Override, the compiler helps you catch the mistake early.
@Override
public int area() { ... }
3) Prefer overriding for behavior, overloading for convenience
Overloading is great for APIs that accept different input types, like:
read(String path)read(Path path)read(File file)
Overriding is for polymorphic behavior—like different shapes, animals, renderers, strategies, etc.
4) Expect dynamic dispatch in polymorphic code
If you have:
List<Shape> shapes = List.of(new Square(2), new Rectangle(2, 3));
for (Shape s : shapes) {
System.out.println(s.describe() + ": " + s.area());
}
You should expect each element to call its own overridden implementation.
Common Mistakes
Mistake 1: Assuming overloading uses runtime types
Consider:
class A {}
class B extends A {}
class Test {
void m(A a) { System.out.println("A"); }
void m(B b) { System.out.println("B"); }
}
If you do:
Test t = new Test();
A x = new B();
t.m(x);
You might want "B", but Java prints "A" because overload selection uses compile-time type (A), not runtime type (B).
Mistake 2: Confusing “is-a” with “has-a”
Polymorphism feels like inheritance, but interfaces help you express capability:
Square implements Shape(capability)- not
Square is-a Rectangleas the only design tool
Inheritance is not always the right first choice.
Mistake 3: Forgetting that fields don’t use dynamic dispatch
This one surprises people. If you override a field (you can’t truly override fields in Java like methods; you can hide them), polymorphism doesn’t behave the same way.
Methods dispatch dynamically; fields are selected differently. If you need polymorphic state, store it in methods or use separate objects.
Mistake 4: Overriding without matching the signature
If you write:
class Cat extends Animal {
void speak(int volume) { ... } // different signature
}
You didn’t override speak(). You added a new method. Calls to speak() still use Animal.speak() unless the signature matches.
Conclusion
Java polymorphism is powerful, but it has two distinct “selection rules”:
- Overloading is chosen at compile time based on the declared argument types.
- Overriding uses dynamic dispatch at runtime based on the actual object type.
- Interfaces make polymorphism flexible and composable, often reducing reliance on deep inheritance.
- Dynamic dispatch is what makes
Animal a = new Dog(); a.speak();print “Woof”.
Practical takeaways
- Use overriding for behavior differences between subclasses/implementations.
- Use overloading for convenience when multiple input shapes can map to the same concept.
- Program to an interface when you want extensibility.
- Always annotate overrides with
@Override. - When something “feels wrong,” ask: Did Java choose the method based on compile-time or runtime types?
Once you internalize that split, polymorphism stops being a source of surprises—and becomes a tool you can rely on.