Dynamic dispatch under the hood (conceptual)
On This Lesson 11 topics
Lesson Overview
So far the rule has been "the object decides". That is true for overridden instance methods, but it is not true for everything in Java. Some decisions are made by the compiler from the reference type, before the program runs. If you do not know which is which, you will sooner or later be surprised by a program that prints something you did not expect.
This lesson is conceptual: it explains how Java decides which code to run for a call, in two stages, compile time and run time. The run-time stage is called dynamic dispatch. You will also see the places where dispatch does not happen: overloaded methods, fields, and static methods.
Why this matters: the "predict the output" questions in this module's assessment, and many real bugs, come from mixing up overloading with overriding. A clear mental model, not memorized tricks, is what lets you predict the output of unfamiliar code.
This lesson builds on the previous two: reference type versus object type (lesson 1), and what the compiler allows through a parent reference (lesson 2). You also need to know the difference between overloading (same name, different parameters, from the methods module) and overriding (same signature in a subclass, from inheritance).
Learning Objectives
By the end of this lesson you will be able to:
- Describe the two stages of a method call: what the compiler checks and what the JVM decides at run time.
- Explain dynamic dispatch, including what happens when the nearest class does not override the method.
- Predict the result when overloaded methods are called through a parent-typed reference.
- State that fields and static methods follow the reference type, not the object.
- Explain why
@Overrideis rejected on a static method. - Draw a simple picture of reference, object, class, and method lookup.
Written Explanation
Stage 1: the compiler checks the reference type
When the compiler sees a.monthEnd(), it looks at the declared type of a, say Account, and checks that Account (or one of its parents) has a method named monthEnd that accepts those arguments. If not, you get a "cannot find symbol" error, as in the last lesson. If yes, it records which method signature the call refers to. The compiler never looks at the object, because it does not exist yet.
When there are several methods with the same name (overloads), this is also the stage where the compiler chooses the one that best fits the declared types of the arguments. The Java Language Specification describes the compile-time step this way: it works from the compile-time type of the target and the arguments.
Stage 2: the JVM looks at the real object
When the line actually runs, the JVM follows the reference to the object and asks: what is the real class of this object? It then looks for the method starting in that class:
- If that class declares a method that overrides the one the compiler selected, run it.
- If not, move up to the parent class and repeat.
- Continue up the chain until a version is found.
This procedure is the dynamic method lookup of the Java Language Specification, and the overall behavior is dynamic dispatch. It explains the earlier results: a Truck that extends Car and does not override sound() ran Car's version, because lookup started at Truck, found nothing, and moved up one level.
You can picture it like this:
variable a object on the heap class SavingsAccount
(type Account) -------> [ real class: SavingsAccount ] -> monthEnd() <- found here, run it
(else: look in Account)Many JVM implementations speed this up with an internal table of methods per class (often called a virtual method table), so that the lookup does not search the chain each time. That is an implementation technique you do not control, and the language specification describes the behavior, not the data structure. I did not verify any particular JVM's internals, so treat the table as a picture of "each class knows its methods", not as a rule.
Overloading: decided at compile time
Overloading means several methods with the same name and different parameter lists. Which one is called is fixed by the compiler from the declared types of the arguments. No dispatch happens on the argument's real object.
static void show(Account a) { System.out.println("show(Account)"); }
static void show(SavingsAccount s) { System.out.println("show(SavingsAccount)"); }
Account a = new SavingsAccount();
show(a); // prints show(Account): the declared type of a is AccountThe object is a SavingsAccount, yet the Account version runs, because the compiler picked the signature when it only knew the declared type. Contrast this with overriding, where the receiver object (the thing before the dot) picks the version at run time. One way to remember: overloading chooses by the argument types the compiler can see; overriding chooses by the class of the object the method is called on.
What dispatch does not cover
Dynamic dispatch applies to instance methods. It does not apply to:
- Fields. A field is looked up by the reference type. If a subclass declares a field with the same name, it hides the parent's field rather than replacing it, and the two fields both exist in the object. Access through an
Accountreference givesAccount's field. (This is another reason to keep fieldsprivate: you almost never want this behavior.) - Static methods. They belong to the class, not to an object. A subclass static method with the same signature hides the parent's, and the call follows the declared type. The Oracle tutorial states this difference between hiding and overriding. Because static methods are not overridden,
@Overrideon one is a compile error (shown below). - Private methods. They are not inherited, so there is nothing to override.
- Constructors. They are not inherited either.
Examples
Example 1: lookup climbs the class chain (simple)
class Account {
String describe() { return "Account"; }
String kind() { return "plain"; }
}
class SavingsAccount extends Account {
@Override String describe() { return "Savings"; }
}
class PremiumSavings extends SavingsAccount {
@Override String kind() { return "premium"; }
}
// in main:
Account a = new PremiumSavings();
System.out.println(a.describe());
System.out.println(a.kind());Output:
Savings
premiumFor describe(): lookup starts at PremiumSavings, which does not declare it, moves to SavingsAccount, which does. For kind(): PremiumSavings declares its own, so it runs at once. At compile time the only requirement was that Account has both methods.
Example 2: overloading versus overriding (practical)
class Account { }
class SavingsAccount extends Account { }
class Printer {
static void show(Account a) { System.out.println("show(Account)"); }
static void show(SavingsAccount s) { System.out.println("show(SavingsAccount)"); }
}
// in main:
Account a = new SavingsAccount();
SavingsAccount s = new SavingsAccount();
Printer.show(a);
Printer.show(s);
Printer.show((SavingsAccount) a);Output:
show(Account)
show(SavingsAccount)
show(SavingsAccount)All three calls pass the same object. The compiler chose each overload from the declared type of the argument: Account, SavingsAccount, and (after the cast) SavingsAccount. This is the surprise to remember: a parent-typed argument selects the parent's overload even when the object is a child. If you want the behavior to depend on the real object, put it in an overridden instance method, as the lessons before did, instead of in overloaded helpers.
Example 3: fields and static methods follow the reference (realistic)
Real code sometimes ends up with a field or static method repeated in a subclass, usually by copy-paste. Here is what that does:
class Account {
String name = "Account-field";
static String hello() { return "Account.hello"; }
String who() { return "Account.who"; }
}
class SavingsAccount extends Account {
String name = "Savings-field";
static String hello() { return "SavingsAccount.hello"; }
@Override String who() { return "SavingsAccount.who"; }
}
// in main:
Account a = new SavingsAccount();
System.out.println(a.who());
System.out.println(a.name);
System.out.println(((SavingsAccount) a).name);
System.out.println(a.hello());Output:
SavingsAccount.who
Account-field
Savings-field
Account.helloOnly the instance method who() was dispatched on the object. The field name and the static hello() followed the declared type Account; a cast to SavingsAccount was needed to reach the child's field. Calling a static method through an instance reference like a.hello() is legal but misleading, so call it as Account.hello() to make clear which class it belongs to.
If you try to "override" the static method with the annotation, the compiler refuses:
Bad.java:3: error: static methods cannot be annotated with @Override
@Override static String hello() { return "S"; }
^
1 errorBackend Insight
Logging, mapping, and validation helpers are commonly written as overloaded methods (log(Order), log(Payment)). They work well as long as the caller passes the exact type. When a list of mixed objects is processed through a parent type, the compiler can only pick the overload for the parent type, which often means the "generic" version silently runs for everything.
The reliable pattern, which you will use in the next lesson, is to put the behavior in an overridden instance method on the objects themselves, and call that. Overloads then stay what they are meant to be: convenient entry points for different argument types known at compile time.
Common Mistakes
Mistake 1: Confusing overloading with overriding
- Incorrect: "
show(a)will pickshow(SavingsAccount)because the object is a savings account." - Correct: "
show(a)picks the overload from the declared type ofa, which isAccount."
Overloading is resolved at compile time from declared types. Overriding is resolved at run time from the real object. This is listed as a common mistake for the whole module, and the next lesson's refactoring is the cure.
Mistake 2: Expecting fields to behave like methods
If a parent and child both declare name, reading a.name through a parent reference gives the parent's field. The fix is design, not syntax: do not redeclare fields in subclasses.
Mistake 3: Calling static methods through instances
a.hello() runs the static method of the declared type, not of the object, and readers will assume dispatch. Write Account.hello().
Exercises
Try each exercise yourself before opening the solutions.
Level A: Foundation
A1. For each decision, say whether it is made at compile time or at run time: (a) whether getRate() exists on Account; (b) which overridden monthEnd() runs; (c) which of two overloaded show methods is called; (d) which static method runs for Account.hello().
Show solution A1
A1. (a) compile time. (b) run time. (c) compile time. (d) compile time: it names the class explicitly, and static methods are not dispatched on objects.
A2. Given class P { String m() { return "P"; } }, class Q extends P { @Override String m() { return "Q"; } }, class R extends Q { }, what does P x = new R(); System.out.println(x.m()); print, and why?
Show solution A2
A2. It prints Q. Lookup starts at R, the real class. R does not declare m(), so the search moves to Q, which does.
A3. True or false: a static method with the same signature in a subclass overrides the parent's static method. Explain.
Show solution A3
A3. False. It hides the parent's static method. Which one runs depends on the declared type or class name used in the call, not on any object.
Level B: Application
B1. Predict the six printed lines:
class Shape { String name = "shape"; String info() { return "shape " + name; } }
class Square extends Shape {
String name = "square";
@Override String info() { return "square " + name; }
}
static String log(Shape s) { return "log(Shape)"; }
static String log(Square s) { return "log(Square)"; }
// in main:
Shape sh = new Square();
System.out.println(sh.info());
System.out.println(sh.name);
System.out.println(log(sh));
System.out.println(log(new Square()));
Square q = new Square();
Shape alias = q;
System.out.println(log(alias));
System.out.println(alias.info());Show solution B1
B1. square square, shape, log(Shape), log(Square), log(Shape), square square. The overridden info() is dispatched on the object, and uses the Square field. sh.name follows the reference type Shape. Both log(sh) and log(alias) are chosen from the declared type Shape. I compiled and ran this to confirm.
B2. Predict the four lines:
class Base {
String run() { return helper(1); }
String helper(int x) { return "Base.helper"; }
}
class Derived extends Base {
@Override String helper(int x) { return "Derived.helper"; }
String helper(long x) { return "Derived.helper(long)"; }
}
// in main:
Base b = new Derived();
System.out.println(b.run());
System.out.println(b.helper(1));
System.out.println(((Derived) b).helper(1));
System.out.println(((Derived) b).helper(1L));Show solution B2
B2. Derived.helper, Derived.helper, Derived.helper, Derived.helper(long). Inside Base.run(), helper(1) has an int argument, so the compiler selects helper(int), and the override in Derived runs because the object is a Derived. The long overload exists only in Derived, so it is reachable only with a Derived-typed reference and a long argument.
B3. In B1, you want log to report log(Square) for alias. Rewrite so it works through a Shape reference without casts.
Show solution B3
B3. Make it an overridden instance method:
class Shape { String logName() { return "log(Shape)"; } }
class Square extends Shape { @Override String logName() { return "log(Square)"; } }
// alias.logName() prints log(Square)I compiled and ran it.
Level C: Challenge
C1. Describe in four steps what happens, in order, when a.monthEnd() runs with Account a = new SavingsAccount(...): one step for the compiler, and the rest for the JVM.
Show solution C1
C1. (1) Compile time: the compiler checks that Account declares monthEnd() and records that signature. (2) At run time the JVM follows the variable to the object. (3) It finds the object's real class, SavingsAccount. (4) It looks for monthEnd() starting in SavingsAccount (climbing to Account if it were not there) and runs the first one found.
C2. A colleague writes @Override static String hello() in a subclass and says "I want it to be polymorphic". Explain the compiler's reaction and what they should do instead.
Show solution C2
C2. The compiler reports an error ("static methods cannot be annotated with @Override"), because static methods are hidden, not overridden. To get behavior that depends on the real object, make the method an instance method in the parent and override it in the child.
Mini Challenge
Write a small program that makes all of these visible in one run. Use a Shape parent and a Circle child. Give them: (1) an instance method describe() overridden in Circle; (2) a field label redeclared in Circle; (3) a static method create() in both classes; and (4) two overloads of a helper static String inspect(Shape s) and static String inspect(Circle c). In main, create Shape s = new Circle(); and print s.describe(), s.label, s.create(), and inspect(s). Before running, write your predictions.
Check your work:
- You predicted each line before running and can explain every mismatch.
- You can say for each line whether compile-time or run-time information decided the result.
- Bonus: change
inspectinto an overridden instance method and show how the output changes.
Summary & Next Lesson
Key takeaways
- A call is resolved in two stages: the compiler checks the reference type and fixes the signature; the JVM then picks the version of an overridden method from the real class of the object (dynamic dispatch).
- Lookup starts at the object's class and climbs the chain until a version is found.
- Overloading is chosen at compile time from declared argument types. Overriding is chosen at run time from the receiver object.
- Fields and static methods follow the reference type. They are hidden, not overridden, and
@Overrideon a static method is a compile error. - JVMs often use method tables to speed up lookup, but that is an implementation detail.
Next lesson
You now have the full mental model. The next lesson, "Replacing conditionals with polymorphism", puts it to work: turning a chain of type checks into overriding methods, so that adding a type never touches the calling code.
Self-check
- At which stage does the compiler choose between overloaded methods, and using what information?
- What does the JVM do if the object's class does not override the method?
- A subclass declares the same field name as its parent. Through a parent reference, which field do you read?
- Is dynamic dispatch applied to static methods?
show(Account)andshow(SavingsAccount)exist. You callshow(x)wherexis declaredAccountbut holds aSavingsAccount. Which runs?
Show answers
- At compile time, using the declared types of the arguments.
- It continues the lookup in the parent class, and on up the chain, running the first version it finds.
- The parent's field: fields follow the reference type.
- No. Static methods are hidden, not overridden, and the call follows the declared class.
show(Account), because the overload is chosen from the declared type.
References
- Oracle, Java Language Specification SE 21, 15.12.4.4 Locate Method to Invoke. Supports the lookup procedure: start at the actual run-time class of the target and climb the superclass chain to the first overriding method, and that casts do not change which method runs.
- Oracle, Java Language Specification SE 21, Chapter 15, section 15.5 and the table of contents for 15.12. Supports the split between compile-time selection of a method from the compile-time type and run-time selection of the implementation. I did not read the full text of section 15.12.2 on overload resolution; the overload behavior shown is confirmed by compiling and running the examples with JDK 21.
- Oracle, Java Tutorials, Overriding and Hiding Methods. Supports the distinction between overriding instance methods and hiding static methods.
- Oracle, Java SE 21 API, Override annotation. Supports the claim that
@Overridecauses a compile error unless the method overrides or implements a supertype method. - Not verified from sources: the behavior of fields hiding (confirmed by running the example only), and the existence and name of virtual method tables in particular JVMs (hedged in the text).
Practice
Apply this lesson with the module's lab, exercises and quiz.
Marking a lesson complete is optional and you can undo it any time. Your progress stays in this browser.