Java Polymorphism

INTERMEDIATE ~8 min read Tutorial

Polymorphism — Greek for “many forms” — is the ability of one reference to refer to objects of different actual types. In Java, the most common form is runtime polymorphism through method overriding: a parent-type reference calls the version of the method that matches the actual object's class.

This tutorial explains virtual method dispatch, the difference between static and dynamic binding, polymorphism through interfaces, and the patterns it enables (strategy, template method, factory). Polymorphism is the single most powerful design tool in object-oriented Java — master it.

1. Virtual Method Dispatch

When you call a method on a parent-type reference, the JVM looks up the actual class of the object at runtime and calls that class's override:

java
public class Animal {
    public String sound() { return "?"; }
}

public class Dog extends Animal {
    @Override public String sound() { return "Woof"; }
}

public class Cat extends Animal {
    @Override public String sound() { return "Meow"; }
}

class=class="tok-str">"tok-cmt">// polymorphism in action
Animal[] zoo = { new Dog(), new Cat(), new Dog() };
for (Animal a : zoo) {
    System.out.println(a.sound());   class=class="tok-str">"tok-cmt">// Woof, Meow, Woof
}

class=class="tok-str">"tok-cmt">// parent-type reference, child object
Animal a = new Cat();
System.out.println(a.sound());   class=class="tok-str">"tok-cmt">// Meow - the actual class decides

This is the essence of “program to an interface, not an implementation”. The caller does not know or care which concrete subclass it is talking to.

2. Static vs Dynamic Binding

Method calls are resolved either at compile time (static binding) or at runtime (dynamic / virtual binding):

  • private, static, and final methods use static binding.
  • All other instance methods use dynamic binding — the actual class is consulted at runtime.
java
public class Base {
    public void dynamic() { System.out.println("Base.dynamic"); }
    public static void staticMethod() { System.out.println("Base.static"); }
    private void priv() { System.out.println("Base.priv"); }
}

public class Sub extends Base {
    @Override public void dynamic() { System.out.println("Sub.dynamic"); }
    public static void staticMethod() { System.out.println("Sub.static"); }

    public static void main(String[] args) {
        Base b = new Sub();
        b.dynamic();          class=class="tok-str">"tok-cmt">// "Sub.dynamic" - dynamic binding
        b.staticMethod();     class=class="tok-str">"tok-cmt">// "Base.static" - static binding (looks at type)
        class=class="tok-str">"tok-cmt">// Hiding a static method is not overriding.
    }
}

This is why private methods are not overridable (no point — the call site already knows the type) and final methods are not (the compiler can inline them).

3. Polymorphism via Interfaces

Interfaces are the most powerful way to achieve polymorphism. A reference of an interface type can point to any object whose class implements it:

java
public interface Shape {
    double area();
}

public record Circle(double r) implements Shape {
    public double area() { return Math.PI * r * r; }
}

public record Square(double s) implements Shape {
    public double area() { return s * s; }
}

public record Triangle(double b, double h) implements Shape {
    public double area() { return b * h / class="tok-num">2; }
}

class=class="tok-str">"tok-cmt">// a list of shapes - heterogeneous, all accessed through Shape
List<Shape> shapes = List.of(
    new Circle(class="tok-num">2.0),
    new Square(class="tok-num">3.0),
    new Triangle(class="tok-num">4.0, class="tok-num">5.0)
);

double totalArea = class="tok-num">0;
for (Shape s : shapes) {
    totalArea += s.area();   class=class="tok-str">"tok-cmt">// dispatched to the right implementation
}

The list can hold heterogeneous implementations, and the loop treats them all uniformly through the interface. Adding a new Shape implementation requires no change to the loop — this is the “open for extension, closed for modification” principle in action.

4. The Strategy Pattern

Polymorphism lets you swap behaviour at runtime by injecting different implementations:

java
public interface DiscountStrategy {
    double apply(double total);
}

public class NoDiscount implements DiscountStrategy {
    public double apply(double total) { return total; }
}

public class PercentageDiscount implements DiscountStrategy {
    private final double pct;
    public PercentageDiscount(double pct) { this.pct = pct; }
    public double apply(double total) { return total * (class="tok-num">1 - pct / class="tok-num">100); }
}

public class FixedDiscount implements DiscountStrategy {
    private final double amount;
    public FixedDiscount(double amount) { this.amount = amount; }
    public double apply(double total) { return Math.max(class="tok-num">0, total - amount); }
}

public class Checkout {
    private DiscountStrategy strategy = new NoDiscount();
    public void setStrategy(DiscountStrategy s) { this.strategy = s; }
    public double total(double cartTotal) { return strategy.apply(cartTotal); }
}

Checkout c = new Checkout();
c.setStrategy(new PercentageDiscount(class="tok-num">10));
System.out.println(c.total(class="tok-num">100.0));   class=class="tok-str">"tok-cmt">// class="tok-num">90.0
c.setStrategy(new FixedDiscount(class="tok-num">15));
System.out.println(c.total(class="tok-num">100.0));   class=class="tok-str">"tok-cmt">// class="tok-num">85.0

Modern Java favours lambdas over anonymous strategy classes when the interface is functional (a single abstract method), as we will see in the Lambda Expressions tutorial.

5. Template Method Pattern

An abstract parent defines the algorithm skeleton and lets subclasses fill in the steps:

java
public abstract class DataProcessor {
    class=class="tok-str">"tok-cmt">// template method - final so subclasses cannot change the flow
    public final void process(String input) {
        String cleaned = clean(input);
        String parsed  = parse(cleaned);
        save(parsed);
    }
    protected abstract String clean(String raw);
    protected abstract String parse(String cleaned);
    protected void save(String data) { class=class="tok-str">"tok-cmt">/* default no-op */ }
}

public class CsvProcessor extends DataProcessor {
    @Override protected String clean(String raw) { return raw.trim(); }
    @Override protected String parse(String cleaned) { return cleaned.toUpperCase(); }
}

This is a classic OO pattern. Override only the bits that vary; the parent controls the flow.

6. Type Checking and Casting

Polymorphism lets you call only methods declared on the reference's type. When you need a method on the actual type, you must cast — but check first with instanceof to avoid ClassCastException:

java
Object obj = "Hello";

class=class="tok-str">"tok-cmt">// classic - test then cast
if (obj instanceof String) {
    String s = (String) obj;
    System.out.println(s.length());
}

class=class="tok-str">"tok-cmt">// pattern (Java class="tok-num">16+) - test and bind in one
if (obj instanceof String s) {
    System.out.println(s.length());   class=class="tok-str">"tok-cmt">// s is in scope
} else if (obj instanceof Integer i) {
    System.out.println(i * class="tok-num">2);
}

class=class="tok-str">"tok-cmt">// switch pattern (Java class="tok-num">21+)
String desc = switch (obj) {
    case String s  -> "string of length " + s.length();
    case Integer i -> "int " + i;
    case null      -> "null";
    default        -> "other";
};

Since Java 16, instanceof can bind a pattern variable, removing the boilerplate cast. The need to cast should be rare — if your code is full of instanceof checks, you are likely missing a polymorphic method.

7. Pitfalls

Calling overridable methods from a constructor

If a parent constructor calls a method that the subclass overrides, the subclass version runs — before the subclass constructor body. The subclass fields are not yet initialised. This is a famous footgun; avoid calling non-final, non-private methods from constructors.

Forgetting @Override

If you intend to override but spell the method name wrong, you silently add a new method. Always annotate with @Override so the compiler catches the typo.

Exercises

  1. Define an interface PaymentMethod with method pay(double amount). Implement it with CreditCard, PayPal, BankTransfer. Loop through a list of them.
  2. Build a Logger hierarchy with a template method log(message) that calls format(message) and emit(line).
  3. Refactor an if/else chain of instanceof checks into a polymorphic method.
  4. Use a pattern instanceof to safely extract a value from an Object that may be a String or an Integer.