Module 6 · Inheritance, Polymorphism & Design ⏱ 19 min

Polymorphism and Dynamic Dispatch

By the end of this lesson you will be able to:
  • Tell apart a variable's declared type from its object's runtime type
  • Predict which overridden method runs by tracing the runtime type
  • Explain why fields and static methods do NOT use dynamic dispatch

Read this method signature: void ignite(Engine e). It takes an Engine — not a petrol engine, not an electric one, just the contract. Yet when it runs, the right engine starts. Somehow the call e.start() reaches the correct start method for whatever object was handed in, even though ignite was written long before most of those engine types existed.

That behaviour has a name, and it is the single most important mechanism in object-oriented Java: dynamic dispatch. Without it, every new subclass would force you to open old methods and stitch in another branch. With it, you code against a supertype once, and every subtype you ever invent just works. This lesson teaches you to predict exactly which method runs, so a surprising dispatch never catches you out in production.

flowchart LR
  V["declared type<br/>Engine e"] --> O["runtime type<br/>ElectricEngine object"]
  style V fill:#3776ab,color:#fff
  style O fill:#c2410c,color:#fff
One variable carries two types: a declared type the compiler sees, and a runtime type only the running program knows.

Two types, one variable

Every object variable in Java carries two types, and keeping them straight is the whole game. The declared type (sometimes called the static type) is the name you wrote to the left of the variable: the Engine in Engine e. The compiler checks against it — it decides which method calls are legal. The runtime type (the actual type) is the class of the object the variable currently points at: an ElectricEngine created by new. The JVM consults that one when a call runs.

A variable of declared type Engine may legally hold an ElectricEngine, because an electric engine is an engine. The compiler only ever sees the declared type; only the running program knows the real one. That gap between the two is where dispatch lives.

Main.java — one interface, two implementations, and a loop that calls start on each. Real, compiling Java.
public class Main {
    interface Engine {
        String start();
    }

    static class PetrolEngine implements Engine {
        @Override
        public String start() {
            return "Petrol engine: vroom";
        }
    }

    static class ElectricEngine implements Engine {
        @Override
        public String start() {
            return "Electric engine: hum";
        }
    }

    public static void main(String[] args) {
        Engine[] engines = { new PetrolEngine(), new ElectricEngine() };
        for (Engine e : engines) {
            System.out.println(e.start());
        }
    }
}

How dispatch picks the method

When you write e.start(), two things happen, in order. First, the compiler confirms that the declared type Engine has a start method — if it did not, the call would not compile, no matter what object e held. Second, at runtime, the JVM ignores the declared type entirely and asks the object itself: which start should run?

Every class keeps a hidden table of its method implementations. The JVM follows the object's real class to that table and invokes the entry it finds there. That lookup is dynamic dispatch. The declared type gates whether the call is legal; the runtime type decides which body executes.

flowchart TD
  C["call e.start()"] --> Q{"look up start() on<br/>the runtime type"}
  Q --> P["PetrolEngine.start"]
  Q --> E["ElectricEngine.start"]
  style Q fill:#c2410c,color:#fff
Dynamic dispatch: the JVM looks up the method on the object's real class, not the variable's declared type.
Main.java — a method coded against the interface works for any implementor, even ones written later. Real, compiling Java.
public class Main {
    interface Engine {
        String start();
    }

    static class PetrolEngine implements Engine {
        @Override
        public String start() {
            return "vroom";
        }
    }

    static class ElectricEngine implements Engine {
        @Override
        public String start() {
            return "hum";
        }
    }

    static void ignite(Engine e) {
        System.out.println("Igniting: " + e.start());
    }

    public static void main(String[] args) {
        Engine e = new ElectricEngine();
        ignite(e);
        ignite(new PetrolEngine());
    }
}

Dispatch reaches up through super

An override can call back into the version it replaced using super.method(), and dispatch still does the right thing. A subclass method might return super.describe() + " extra", layering its own behaviour on top of the parent's without rewriting it.

The super. prefix means the parent's version specifically — it is one of the few places Java deliberately skips dynamic dispatch, because you usually want the parent body rather than your own (calling your own would recurse forever). Everywhere else, a plain method() call dispatches to the runtime type. Ordinary calls follow the object; super is the explicit, parent-bound exception.

What is NOT dynamically dispatched

Here is the part that trips up nearly everyone. Dynamic dispatch applies to instance methods, and only to instance methods. Two close neighbours look identical but are resolved at compile time, by the declared type.

Fields are chosen by the declared type of the expression, not by the object's real class. Static methods are too, because they belong to the class as a whole, not to any one instance. So reading a field or calling a static method never consults the runtime type — the compiler has already decided which one you mean when it built the program. Only a genuine instance method call goes through the runtime lookup. Hold onto that split and you will read Java correctly under pressure.

flowchart LR
  ACC["field access or static call"] --> S["static binding<br/>uses declared type"]
  CALL["instance method call"] --> D["dynamic dispatch<br/>uses runtime type"]
  style S fill:#b45309,color:#fff
  style D fill:#3776ab,color:#fff
Instance method calls dispatch dynamically by runtime type; field access and static calls bind statically by declared type.
Main.java — a field uses the declared type while a method uses the runtime type. Watch the surprise. Real, compiling Java.
public class Main {
    static class Animal {
        String tag = "animal";

        String describe() {
            return "animal method";
        }
    }

    static class Dog extends Animal {
        String tag = "dog";

        @Override
        String describe() {
            return "dog method";
        }
    }

    public static void main(String[] args) {
        Animal a = new Dog();
        System.out.println(a.tag);
        System.out.println(a.describe());
    }
}
Exercise

What does this program print? Three engines in a list. Predict all three lines before you check.

public class Main {
    interface Engine {
        String start();
    }

    static class PetrolEngine implements Engine {
        @Override
        public String start() { return "vroom"; }
    }

    static class DieselEngine implements Engine {
        @Override
        public String start() { return "clatter"; }
    }

    static class ElectricEngine implements Engine {
        @Override
        public String start() { return "hum"; }
    }

    public static void main(String[] args) {
        Engine[] engines = { new ElectricEngine(), new DieselEngine(), new PetrolEngine() };
        for (Engine e : engines) {
            System.out.println(e.start());
        }
    }
}
Exercise

A variable is declared Engine e but assigned new ElectricEngine(). When you call e.start(), which method runs?

Exercise

The field-hiding trap. p is declared Player but holds a Captain. Predict both lines — they surprise most people.

public class Main {
    static class Player {
        String team = "red";

        String role() {
            return "member";
        }
    }

    static class Captain extends Player {
        String team = "blue";

        @Override
        String role() {
            return "leader";
        }
    }

    public static void main(String[] args) {
        Player p = new Captain();
        System.out.println(p.team);
        System.out.println(p.role());
    }
}
Exercise

A dispatch chain. Each subclass calls super and prepends a letter. Trace carefully and predict all three lines.

public class Main {
    static class Base {
        String id() {
            return "B";
        }
    }

    static class Mid extends Base {
        @Override
        String id() {
            return "M" + super.id();
        }
    }

    static class Top extends Mid {
        @Override
        String id() {
            return "T" + super.id();
        }
    }

    public static void main(String[] args) {
        Base[] items = { new Base(), new Mid(), new Top() };
        for (Base b : items) {
            System.out.println(b.id());
        }
    }
}
Exercise

Animal a = new Dog();. Both Animal and Dog declare a static method kind(). Which kind() does a.kind() call?

Recap

  • Every object variable has a declared type (what the compiler sees) and a runtime type (the object's real class).
  • Dynamic dispatch chooses an instance method by the runtime type — so a supertype variable can call the correct subclass body.
  • super.method() is the exception: it deliberately calls the parent's version, skipping dispatch.
  • Fields and static methods do not dispatch dynamically; they bind by the declared type.
  • Prefer methods over exposed fields so a subclass can override and dispatch gives you its answer.

Next you will meet abstract classes and interface default methods, and learn the exact rule Java uses to resolve a method when more than one candidate exists.

Checkpoint quiz

Which one determines the instance method that actually runs?

Animal a = new Dog(); where Animal.tag is "animal" and Dog.tag is "dog". What does System.out.println(a.tag); print?

Go deeper — technical resources