• Skip to main content

CPlatt Portfolio

Creating a Visually Stunning Web

  • Portfolio
  • Blog

Java Polymorphism: Overloading, Overriding, Interfaces, and Dynamic Dispatch (Explained)

April 11, 2026 by Chris Platt

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() and s.area() use dynamic dispatch:
    • shape is actually a Square, so you get "Square" and 16
  • printer.print(rect):

    • Overload choice uses compile-time type: Rectangle
    • It calls print(Rectangle r)
    • Again, overridden methods (describe, area) are selected at runtime from Square

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 PayPalPayment later 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 Rectangle as 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.

Filed Under: Uncategorized

CPlattDesign © 2012–2026