Inheritance and polymorphism

Dynamic dispatch, overriding rules, upcasting and downcasting, super, the types of inheritance Java allows, abstract classes, and the fragile base class problem that makes inheritance a liability.

15 min read🧱 Object-Oriented Java

Inheritance is the feature every Java course teaches first and every experienced engineer uses least. It does two unrelated things at once — reuses implementation and declares a subtype — and the problems come from wanting one and getting both. Understand the mechanism precisely, down to the table the JVM consults on every call, and then the next lesson can explain why composition usually wins.

Dynamic dispatch

When you call a method on a reference, the JVM picks the implementation from the object's runtime class, not the reference's declared type:

java
abstract class Shape {
    abstract double area();
    String describe() { return getClass().getSimpleName() + " of area " + area(); }
}
class Circle extends Shape {
    final double r;
    Circle(double r) { this.r = r; }
    @Override double area() { return Math.PI * r * r; }
}
 
Shape s = new Circle(1);
s.describe();     // "Circle of area 3.14..." — describe() in Shape calls Circle's area()

describe is defined in Shape and calls area(), but the area() that runs is Circle's, because the object is a Circle. This is polymorphism: one call site, many behaviours, chosen at run time. Every non-private, non-static, non-final method in Java is dispatched this way (bytecode invokevirtual), and it is what makes an interface useful.

Textbooks and interviews call this runtime polymorphism, or dynamic method dispatch, and call method overloading compile-time polymorphism. The names are worth knowing, but the two mechanisms share nothing except the word. An overload is picked by javac from the declared types of the arguments; an override is picked by the JVM from the object the method is called on.

Under the hood: the vtable

invokevirtual Shape.area does not search anything at run time. When a class is loaded, the JVM builds its virtual method table: an array of method pointers, one slot per virtual method. A subclass's table starts as a copy of its superclass's, in the same order; an override replaces the pointer in that slot; a new method is appended. So area() is slot 3 in Shape, slot 3 in Circle, slot 3 in Square, and the call is: load the object's class pointer from its header, load the table, load slot 3, jump. Three loads and an indirect jump, whatever the depth of the hierarchy.

Circle objectheader: class ●r = 1.0 Shape vtable 0 Object.toString1 Object.equals2 Object.hashCode3 area (abstract)4 Shape.describe Circle vtable 0 Object.toString1 Object.equals2 Object.hashCode3 Circle.area4 Shape.describe s.area(): class pointer → table → slot 3 → jump. Same slot in every subclass. Shape.describe is inherited unchanged and calls slot 3 again.
Dispatch is an array index fixed at class load. Depth of hierarchy costs nothing; an override is a pointer swap.

Three things follow. private, static and final methods have no slot to swap, which is why they cannot be overridden and why invokespecial/invokestatic are direct calls. Interface methods cannot use a fixed slot, because unrelated classes implement the same interface at different positions, so invokeinterface searches an itable instead, and is slower in the interpreter. And the JIT does better than either: at a call site that has only ever seen one class it emits a direct call guarded by a class check, and with class hierarchy analysis it can inline a virtual call to a method that currently has no override anywhere in the loaded hierarchy, deoptimising if a subclass with an override is loaded later. The cost is small — a table lookup — and often zero. The cost that is not small is conceptual: a method in a superclass can be running code from a subclass it has never heard of.

Overriding rules

  • Same name, same parameter types. A different parameter list is an overload, not an override — annotate with @Override so the compiler catches the difference.
  • The return type may be a subtype (covariant return). javac implements this with a hidden bridge method that has the superclass's return type and forwards; javap shows it as synthetic bridge. The same mechanism makes generic overrides work after erasure, and it is why a stack trace occasionally shows a method twice.
  • Access may widen, not narrow: a protected method can be overridden as public, not as private.
  • Checked exceptions may be narrowed, not widened: the override cannot throw a checked exception the superclass method does not declare.
  • private, static and final methods are not overridden. A static method with the same signature in a subclass hides the superclass one — resolved by the reference's static type, not the object, which is a different and confusing thing.

Upcasting, downcasting and ClassCastException

Polymorphism rests on a conversion that happens without being written. Using a subclass object where its superclass is expected is an upcast:

java
Circle c = new Circle(1);
Shape s = c;          // upcast: implicit, and always safe
s.area();             // compiles: Shape declares area(), and Circle's version runs
double r = s.r;       // does not compile: cannot find symbol, because Shape has no r

The declared type decides what you may call; the object decides which implementation runs. An upcast changes neither the object nor its class. It only narrows what the compiler lets you see.

Going the other way is a downcast, and it has to be written:

java
Shape s = pick();
Circle c = (Circle) s;   // downcast: explicit, and checked when the line runs

javac accepts it because a Shape might be a Circle, and emits a checkcast instruction that tests the actual object at run time. If s turns out to be a Square, the cast throws ClassCastException and names both classes: class Square cannot be cast to class Circle (Square and Circle are in unnamed module of loader 'app').

When the compiler can prove a cast can never succeed, it refuses it. (String) someInteger fails with incompatible types: Integer cannot be converted to String, and (Runnable) "x" fails the same way, because String is final and does not implement Runnable. Casting an object of a non-final class to an interface it does not implement does compile, because some subclass might implement it.

Test before you cast, with instanceof. Since Java 16 the test and the cast are a single step:

java
if (s instanceof Circle circle) {
    System.out.println(circle.r);   // circle exists only where the test passed
}

instanceof is false for null, so the test also rules out a null.

A downcast in application code is often a method that belongs on the superclass. If every caller asks "is it a Circle?" before doing something, that something is a polymorphic method waiting to be written. The honest exceptions are places where type information really is lost: equals(Object), data that arrives deserialised, and a sealed hierarchy handled by a pattern switch.

Constructors and super

A subclass constructor calls a superclass constructor first — implicitly super() if you write nothing, which fails to compile if the superclass has no no-arg constructor. The superclass part of the object is initialised before the subclass part. Which produces the classic trap:

java
class Base {
    Base() { init(); }                // calls the override before the subclass exists
    void init() {}
}
class Derived extends Base {
    final List<String> items = new ArrayList<>();
    @Override void init() { items.add("x"); }   // NullPointerException: items is still null
}

Base's constructor runs before Derived's field initialisers, so the overridden init sees items == null. The vtable explains why: the object's class pointer is Derived from the moment new allocates it, so init() in Base's constructor already dispatches to Derived.init, on an object whose Derived fields are still zero. Rule: never call an overridable method from a constructor. Make the method final or private, or do the work after construction.

Types of inheritance, and the one Java refuses

Textbooks name the shapes a class hierarchy can take:

ShapeExampleIn Java
singleCircle extends Shapeyes
multilevelCircle extends Ellipse, which extends Shapeyes; constructors chain up through every level to Object
hierarchicalCircle and Square both extend Shapeyes
multipleclass C extends A, Bnot for classes: the compiler stops at the comma with '{' expected
hybrida mix that includes multipleonly through interfaces

A class has exactly one superclass and may implement any number of interfaces. The restriction is about state. Two superclasses could each bring fields and constructors, and there would be no single answer to which constructor runs first, or which parent super.method() means when both define it. Interfaces have no instance fields and no constructors, so implementing several is safe; when two of them supply the same default method, the class must override it and choose, which is the diamond rule in the interfaces lesson.

Abstract classes

An abstract class cannot be instantiated and may have abstract methods that subclasses must implement. It is the right tool for the template method pattern: the superclass owns the algorithm's skeleton and the subclass fills in steps.

java
abstract class Report {
    final String render() {                        // the skeleton is final: the shape is fixed
        return header() + body() + footer();
    }
    protected String header() { return "Report\n"; }
    protected abstract String body();
    protected String footer() { return "\n--"; }
}

An abstract class can hold state and constructors, which an interface cannot. That is its only remaining advantage since interfaces gained default methods. If there is no shared state, prefer an interface.

The fragile base class problem

A subclass depends on details of the superclass it cannot see. Change the superclass and the subclass breaks — without either file showing a compile error.

java
class InstrumentedSet<E> extends HashSet<E> {
    int added = 0;
    @Override public boolean add(E e) { added++; return super.add(e); }
    @Override public boolean addAll(Collection<? extends E> c) { added += c.size(); return super.addAll(c); }
}
 
new InstrumentedSet<String>().addAll(List.of("a", "b", "c")).added   // 6, not 3

HashSet.addAll (inherited from AbstractCollection) is implemented by calling add for each element, and that add is a virtual call, slot lookup and all, which lands on the subclass's override. The subclass counts three in addAll and three more in add. Whether addAll calls add is an implementation detail that is not part of HashSet's contract and may change in any release. This example is from Effective Java, and it is the textbook reason to prefer composition: wrap a Set in a class that has one and forwards calls, and the wrapper's correctness depends only on the Set interface.

Walkthrough: the upgrade that changed a subclass's behaviour

A team extended a library's RetryingClient to add metrics: class MeteredClient extends RetryingClient { @Override Response send(Request r) { timer.record(() -> super.send(r)); } }. It worked for a year.

  1. The library's 3.0 release refactored RetryingClient: send now delegates each attempt to a new protected sendOnce, and the retry loop lives in send. The library considered this an internal change; send's contract was unchanged.
  2. MeteredClient still overrides send, so the timer now wraps the whole retry loop, and the metric that used to mean "one request" now means "up to five". Dashboards show latency tripling on the day of the upgrade. No compile error, no test failure, because the tests asserted that a request was timed, not how many.
  3. A colleague "fixes" it by overriding sendOnce instead. The library's 3.1 release renames it attempt. Now the override is a dead method that nothing calls, and metrics silently stop.
  4. The composition version, class MeteredClient implements Client { private final Client inner; ... send(r) { return timer.record(() -> inner.send(r)); } }, measured one send on the day it was written and still does, because it depends on Client's interface and not on RetryingClient's insides.

Every override of a non-abstract method in a class you do not own is a bet that its call graph will not change. Libraries do not owe you that.

final classes and methods

final on a class forbids subclassing; on a method it forbids overriding. Use them to say "this is not designed for extension" — which is true of most classes. Designing a class for inheritance means documenting which methods call which (the "self-use" pattern in the JDK's Javadoc: "This implementation calls add for each element"), never calling overridables from constructors, and testing with subclasses. If you have not done that work, seal the class. Records and enums are implicitly final, and a sealed class is the middle ground: extensible by the types you list and no others.

Try it yourself

What prints?

java
class A { String who() { return "A"; } static String s() { return "A.s"; } String call() { return who() + "/" + s(); } }
class B extends A { @Override String who() { return "B"; } static String s() { return "B.s"; } }
A a = new B();
System.out.println(a.call() + " " + a.s() + " " + ((B) a).s());
Answer

B/A.s A.s B.s. who() is virtual and dispatches on the object: B. s() is static: no vtable slot, resolved by the static type of the expression. Inside call() the call is A.s() (the class the code is in); a.s() is A.s because a is declared A; ((B) a).s() is B.s because the cast changed the static type. Static "overriding" is hiding, and it follows the reference, never the object.

Which casts survive?

java
Object o = "hello";
CharSequence cs = (CharSequence) o;
System.out.println(cs.length());
System.out.println(o instanceof Comparable);
try {
    Integer i = (Integer) o;
    System.out.println("cast worked: " + i);
} catch (ClassCastException e) {
    System.out.println("ClassCastException");
}
Answer

5, true, then ClassCastException. The variable is declared Object, but the object is a String, and a String is a CharSequence and a Comparable, so the first cast and the instanceof test succeed. The cast to Integer compiles, because an Object might be an Integer, and fails when checkcast looks at the actual object. No cast changed the object: all three lines asked a question about the same String.

Count the calls

java
class Counter extends ArrayList<String> {
    int n;
    @Override public boolean add(String s) { n++; return super.add(s); }
}
Counter c = new Counter();
c.addAll(List.of("a", "b"));
Collections.addAll(c, "c", "d");
System.out.println(c.n);
Answer

2, on current JDKs, and that is the point. ArrayList.addAll copies the array directly and does not call add, so the first two are not counted; Collections.addAll calls c.add per element, so the last two are. Whether n is 2 or 4 depends on an implementation detail of a class you do not own, which can change without notice. The composition version counts whatever it chooses to count and is not consulted otherwise.

Find the bridge

java
interface Parser<T> { T parse(String s); }
class IntParser implements Parser<Integer> { public Integer parse(String s) { return Integer.valueOf(s); } }

javap -p IntParser shows two parse methods. Why, and which one does Parser<Integer> p = new IntParser(); p.parse("1") call?

Answer

After erasure the interface method is Object parse(String), and the class's Integer parse(String) has a different descriptor, so it does not override it at the JVM level. javac adds a synthetic bridge Object parse(String) that calls the real one and is marked bridge. The interface call lands on the bridge (the itable entry for parse(String)Object), which forwards. The same happens for covariant returns in class hierarchies.

Misconceptions

  • "Dynamic dispatch searches up the hierarchy at run time." It indexes a table built at class load. The depth of the hierarchy does not affect the cost of a call.
  • "Static methods can be overridden." They can be hidden. Resolution follows the static type of the expression, which is why a.s() and ((B) a).s() differ.
  • "Overriding a method is safe as long as I call super." The superclass may call that method from other methods, or stop calling it. Its call graph is not part of its contract.
  • "The constructor sees a superclass object until it finishes." The object's class pointer is the subclass's from allocation. An overridable call from the superclass constructor dispatches to the subclass on zeroed fields.
  • "A cast converts the object." It converts nothing. An upcast changes what the compiler lets you call; a downcast checks, at run time, what the object already was.
  • "Java has no multiple inheritance." It has no multiple inheritance of classes, and so of fields and constructors. A class inherits types from many interfaces, and behaviour from their default methods.
  • "Interfaces are slower than abstract classes." In the interpreter, invokeinterface searches an itable. Under the JIT, a monomorphic or bimorphic call site is a guarded direct call either way.

Going deeper

  • JVMS §5.4.3.3 and §6.5 (invokevirtual, invokeinterface, invokespecial): the resolution rules the vtable implements.
  • JLS §8.4.8 (inheritance, overriding and hiding) and §15.12.4.4 for the exact dispatch rule.
  • JLS §8.4.8.3 and javap -p on a covariant override, for bridge methods.
  • Effective Java, item 18 (composition over inheritance) and item 19 (design and document for inheritance or prohibit it).
  • Aleksey Shipilëv, "The Black Magic of (Java) Method Dispatch", for what the JIT does at monomorphic, bimorphic and megamorphic sites.
Progress is saved on this device and to your account when signed in.