When you call an overridden method through a base-class reference, how does the runtime know which implementation to run?
The compiler cannot know, so it emits an indirect call through a per-class table of function pointers that the object carries a reference to. The lookup itself is a couple of memory accesses; the real cost is the optimisations the compiler cannot perform when the target is not known until run time.
What the interviewer is scoring
- Whether the candidate distinguishes compile-time overload resolution from run-time dispatch
- That the per-object cost is one pointer and the per-class cost is one table, not a table per object
- Does the candidate identify lost inlining as the dominant cost rather than the indirection itself
- Whether they know a managed runtime can speculatively devirtualise and what invalidates that
- That interface dispatch is recognised as a harder problem than single-inheritance dispatch
Answer
Two different questions that both look like "which method"
Overloading and overriding are resolved at different times, and conflating them is the fastest way to get this wrong.
Choosing between print(int) and print(String) is overload resolution, and it happens at compile time from the static types of the arguments. The compiler picks one and bakes the choice into the emitted code. Choosing between Circle.area() and Square.area() when you hold a Shape reference is dynamic dispatch, and it cannot happen at compile time because the compiler does not know what the reference will point to.
The consequence catches people out: a method whose parameter is declared Object will be selected for a String argument if the static type at the call site is Object, no matter what the object actually is. Overload resolution never looks at run-time types.
The table of function pointers
The standard implementation has been stable for decades. Each class with overridable methods gets one virtual method table — a vtable — an array of pointers to the implementations that class uses. Every instance carries a hidden pointer to its class's table.
Building the table is what makes it work. A subclass's table starts as a copy of its parent's, with the entries for overridden methods replaced by the subclass's implementations. Crucially, a given method occupies the same slot index in every class in the hierarchy. Shape.area() might be slot 2, so Circle's table has Circle.area at slot 2 and Square's has Square.area at slot 2.
That layout invariant is what lets the compiler emit a fixed instruction sequence without knowing the type: load the vtable pointer from the object, load slot 2, call it. Three steps, one indirect call, no searching, and identical machine code for every possible subclass.
The costs are correspondingly modest. One pointer per object, one table per class — not per instance, which is a common misconception. Two dependent memory loads before the call, both usually cache hits because the vtable of a class you are actively using stays hot.
The cost that actually matters
If dynamic dispatch were only two loads, nobody would discuss its performance. What makes it expensive is what the compiler cannot do around it.
A direct call to a small method is typically inlined — the body is pasted into the caller, the call disappears, and the surrounding code is then optimised as one unit. Constant folding crosses the old boundary, dead branches vanish, the loop becomes vectorisable. Inlining is not a small optimisation; it is the one that unlocks the others.
An indirect call cannot be inlined by an ahead-of-time compiler, because there is no single body to inline. So you lose the call overhead and every downstream optimisation, and in a hot loop the second part dominates by a wide margin. The two loads are noise next to a loop that failed to vectorise.
There is a second-order cost. The processor speculates through an indirect call using a branch target predictor, and while a call site that always resolves to the same class predicts nearly perfectly, one that alternates unpredictably between several types mispredicts and pays a pipeline flush each time. Dispatch cost is therefore a property of the call site's behaviour, not of the language feature.
Why managed runtimes often pay nothing
A JIT compiler has information an ahead-of-time compiler does not: what actually happened. It knows the classes loaded so far and it profiles each call site.
If a virtual call site has only ever seen one receiver type — monomorphic, which describes the large majority of call sites in real programs — the JIT can speculatively devirtualise: emit a cheap check that the receiver is the expected class, then the fully inlined body, with a fallback path if the check fails. The result is a direct inlined call plus a comparison, and the surrounding optimisations come back.
The speculation is what makes it valid, and it is guarded. Load a second implementing class and the assumption is invalidated; the JIT deoptimises that code and recompiles with real dispatch. This is why a microbenchmark exercising one implementation reports that virtual calls are free, and the same code in an application that loads three implementations does not. The benchmark measured a monomorphic call site, and monomorphism was an accident of the benchmark.
Declaring a class or method final in Java, or non-virtual in C#, gives the compiler that certainty statically rather than speculatively. In practice the JIT usually gets there anyway, so final is better justified as a design statement about extension than as an optimisation.
Interface dispatch is a harder problem
The slot-index trick relies on a single inheritance chain, so every class shares its parent's layout. Interfaces break it: a class can implement several unrelated interfaces, and two of them may declare a method that would want the same slot.
Runtimes solve this with additional indirection — per-interface tables, or a small cache at the call site that remembers the last resolved target. The consequence is that an interface call is generally somewhat costlier than a class-virtual call, and more dependent on the call site staying monomorphic. It is rarely worth designing around, but it is worth knowing that List and ArrayList as a declared type are not identical at the machine level.
The trap in construction order
A subtle and genuinely dangerous consequence: calling an overridable method from a constructor.
In Java, the base constructor runs before the subclass's field initialisers. The object's vtable pointer already refers to the subclass table, so a virtual call from the base constructor dispatches to the subclass override — which then reads subclass fields that have not been assigned yet and sees nulls and zeros. The code looks correct and fails only for subclasses that happen to depend on their own state.
C++ chose the other resolution: the vtable pointer is updated as each constructor in the chain runs, so a virtual call from a base constructor dispatches to the base implementation. Safer, and surprising in the opposite direction if you expect the override to run.
The vtable lookup is cheap. What is expensive is that the compiler cannot see through the call, and every optimisation that depended on seeing through it is what you are really paying for.
Likely follow-ups
- Why can a JVM often make a virtual call as fast as a direct one, and when does that stop working?
- Why is calling an overridable method from a constructor dangerous?
- How does interface dispatch differ from class dispatch, and why is it harder?
- What does `final` or `sealed` let a compiler do that it otherwise cannot?
Related questions
- Where does encapsulation or polymorphism change a design, and how do you decide between composition and inheritance?mediumAlso on polymorphism6 min
- What are the four pillars of OOP, and what is the difference between abstraction and encapsulation?easyAlso on polymorphism4 min
- Walk me through what happens between the source you write and the instructions the CPU runs, and where does a JIT fit into that?mediumAlso on jit5 min
- A dashboard has been showing the same temperature for two days and nobody noticed. Walk the path and tell me what would have caught it.hardSame kind of round: concept6 min
- Everyone in the business calls it the fax queue, and nothing has been faxed since 2014. Do you keep that name in the code?mediumSame kind of round: concept4 min
- How do you split a story that is too big to fit in a Sprint?mediumSame kind of round: concept3 min
- The business insists on a requirement that nobody can test — "the system must be intuitive". What do you write instead?mediumSame kind of round: concept4 min
- How does your order placement change on a venue that allocates pro rata within a price level instead of first in, first out?hardSame kind of round: concept6 min