Object-oriented code is built on a small act of misdirection. When you write
shape.area(), the compiler usually cannot say which area will run —
a Circle's? a Square's? — because shape's real type is decided at
run time. So it emits an indirect call: load the object's method table (its vtable),
fetch the right slot, and jump through the pointer. Flexible, but expensive — and, far worse than the
jump itself, an indirect call is a wall the optimiser cannot see past. It cannot inline
an unknown target, cannot propagate constants into it, cannot reason about its side effects.
Devirtualization is the optimisation that tears the wall down: prove (or bet) which
method the call really invokes, and replace the indirect call with a direct, inlinable one.
This is why it matters so much for OOP performance. A modern JIT or ahead-of-time optimiser lives and dies by inlining, and a virtual call blocks inlining cold. Turn one virtual call into a direct call and you often unlock a cascade — inline the callee, then constant-fold, then eliminate dead branches inside it. Devirtualization is frequently the first domino.
A virtual call is not one instruction but a little dance: load the vtable pointer from the object, load
the method address from a fixed slot, then an indirect call through that address.
Two extra loads, plus an indirect branch the CPU's predictor may miss — but the real damage is what does
not happen: call *fn is opaque, so inlining stops here, and every optimisation that
would have flowed through the callee is blocked. Compare the prize:
The cheapest way to devirtualize is to look at the class hierarchy. If the compiler can
see the whole program's set of classes, it knows every type that could possibly be behind a reference of
static type Shape — namely Shape and all its subclasses. Class-hierarchy
analysis (CHA) asks: among all those types, how many actually override
area()? If the answer is exactly one, the call is monomorphic —
there is only one possible target — and the compiler can rewrite the indirect call as a direct call with
no guard at all.
In the hierarchy above, a call to s.area() where s could be any
Shape sees three overriding implementations — polymorphic, so plain CHA cannot devirtualize
it. But a call through a reference statically typed Rectangle sees only Square
as a further subclass; if Square does not override area(), the call is
monomorphic and becomes a direct call to Rectangle::area.
CHA reasons about declared types; points-to and type-flow
analysis reason about what a reference can actually hold. Even when a class has many subclasses,
the specific variable at a call site might only ever be assigned a Circle — a fact
Circle, the call is monomorphic in practice and can be devirtualized
even though Shape has many subclasses. This kind of whole-program reasoning is exactly what
Often the compiler cannot prove monomorphism — a new subclass might be loaded later (Java, C#), or profile data merely shows one type is usually the receiver. Then it speculates: guess the likely type, guard the guess with a cheap check, take a direct (inlinable) fast path when the check passes, and fall back to the original indirect call when it fails.
The guard costs one comparison and a well-predicted branch; in exchange the common case is fully inlined and open to every downstream optimisation. This is the workhorse of JIT compilers — they collect actual receiver types at run time, inline the hot one behind a guard, and rely on deoptimization to bail out to the interpreter if the guard ever fails (say, because a new subclass finally shows up). The bet is that the guard almost never fails, so the fast path almost always runs.
Here is class-hierarchy analysis making the call. Given a hierarchy and which classes override a method, we compute, for a call on a given static type, the set of possible targets; if it is a singleton, we devirtualize.
The result is exactly the hierarchy's story: a call on Shape is polymorphic and stays
indirect, but a call on Rectangle is monomorphic — both Rectangle and
its subclass Square resolve to Rectangle::area — so CHA rewrites it as a direct,
inlinable call. One walk of the class tree unlocks the inliner.
This is the deep tension in languages like Java: CHA's "only one override exists" can be true today
and false a millisecond later when the class loader pulls in a new subclass. The answer is
speculative optimisation backed by deoptimization. The JIT devirtualizes and inlines on
the current hierarchy, but records an assumption ("no class overriding area below
Rectangle exists"). If a class that violates the assumption is ever loaded, the runtime
invalidates the compiled code — it throws the optimised version away and deoptimizes back
to a safe interpreted or recompiled version. So the compiler gets to bet on a closed world for speed while
the language keeps its open world for flexibility. HotSpot calls these "class-hierarchy dependencies," and
they are checked on every class load. The optimisation is aggressive precisely because there is a safety
net under it.
Speculative devirtualization is a bet, and bets can lose. The type guard adds a comparison and a
branch on every call, and it duplicates code (a fast path and a slow path). If the guessed type
is wrong most of the time, you pay the guard and still take the indirect call — strictly worse
than leaving the call alone, plus the code bloat that can push hot instructions out of the instruction
cache. This is why serious speculation is profile-guided: only bet on a type the profiler
shows genuinely dominates. And beware megamorphic sites — call sites that see many types (a
generic container's compare, say). Guarding one type there is pointless; the mispredict rate
is high and the right move is often to leave it virtual, or use an inline cache with a few entries rather
than a single guarded target. Devirtualize where the receiver is predictable, not everywhere.