skip to content

A JIT-compiled method inlined a virtual call because only one implementation of the interface had been loaded at compile time. What happens to that compiled code when the application later loads a second implementation?

level: seniorimportance: should knowfreq 32%

answer

  1. CHA: one implementor → inline with no type check
  2. dependency recorded on the nmethod
  3. class loading checks and breaks dependencies
  4. not entrant + on-stack deopt at a safepoint
  5. profile inlining fails by guard, not invalidation

basics

~20 s

The compiler recorded a dependency on "only one implementation exists". Loading a second one violates it, so during class loading the JVM invalidates the compiled code: it is made not entrant, frames on the stack are deoptimized, and the method is recompiled with a real virtual call or a type-guarded inline.

solid answer

~1 min

Devirtualization based on **class hierarchy analysis** (CHA) is not a guess about data — it is a claim about the loaded world: at compile time exactly one implementation of the interface (or no override of the method) exists, so the call has one possible target and can be inlined with no type check at all. Because that claim can be falsified only by class loading, the compiler records it as a **dependency** attached to the compiled method. Class loading and linking check the dependencies of existing compiled code; when the new class invalidates one, the runtime marks that nmethod **not entrant** so no new call enters it, and **deoptimizes any frames currently executing it** — these are on-stack deoptimizations, done at a safepoint. The method is then recompiled, now as a bimorphic guarded inline, an inline cache, or a plain vtable/itable call. The alternative form — inlining based on the *profile* rather than the hierarchy — keeps a type guard in the code, so a new type simply fails the guard and takes an uncommon trap instead of invalidating anything. Operationally this is why lazily loaded implementations, DI proxies and plugin classes can cause a second warmup wave long after startup.

code

text · 8 lines
text
$ java -Xlog:class+load=info -XX:+PrintCompilation App
...
 8120   61       4       svc.Gateway::handle (74 bytes)          # inlined ServiceImpl.run, no type check
...
18442  [class,load] svc.AuditingService source: file:/plugins/audit.jar
18443   61       4       svc.Gateway::handle (74 bytes)   made not entrant
18455   66       3       svc.Gateway::handle (74 bytes)
18512   70       4       svc.Gateway::handle (74 bytes)          # recompiled: bimorphic / virtual call

go deeper

for a junior

Know the outcome: the compiled version is thrown away and the method is compiled again, now that more than one implementation exists.

for a middle

Explain that the compiler recorded a dependency on the hierarchy and that class loading checks and breaks it, leading to not-entrant plus recompilation.

for a senior

Add on-stack deoptimization at a safepoint, contrast CHA invalidation with guard-failure deopt, and connect it to late class loading causing warmup waves in production.

for a principal

Treat it as an architectural input: how lazily the system loads implementations, whether warmup exercises the real type set, and the cost of introducing a second implementor of a hot interface.

## Two different ways a virtual call gets inlined Inlining a polymorphic call requires knowing the target. HotSpot has two routes, and they fail differently. **1. Hierarchy-based devirtualization (CHA).** The compiler asks the runtime: how many loaded classes implement this interface / override this method? If the answer is exactly one, there is currently only one possible target. The call can be compiled as a direct call and inlined with **no type check at all** — the fastest possible outcome. The only thing that can make it wrong is *loading a new class*. **2. Profile-based inlining.** The call-site profile says the receiver has always been `ArrayList`. The compiler inlines `ArrayList`'s method behind an explicit **type guard** (`if (receiver.klass != ArrayList) uncommon_trap`). Here a new type is handled by the guard at runtime; nothing needs invalidating. Interviews usually target route 1, because it is the one where class loading has a global effect on already-compiled code. ## Dependencies and invalidation Each compiled method (nmethod) carries a list of **dependencies** — assertions about the class hierarchy it relied on, such as "`Service` has a unique implementor `ServiceImpl`" or "`Base.run` has no overriding method". The dependency machinery is part of the class-loading path: when a class is loaded and linked, the runtime evaluates which recorded dependencies it breaks. On a break, at the next safepoint: 1. The affected nmethods are **made not entrant** — the entry point is patched so no new invocation enters the stale code. 2. Threads whose frames are *currently* executing that code are **deoptimized on the stack**: each such frame is converted into interpreter frames and continues interpreting. This is essential — those frames are running code that is now unsound. 3. The nmethod becomes unreachable and eventually its code-cache space is reclaimed. 4. The method is queued for recompilation. With two implementors now, the recompiled version typically uses a bimorphic guarded inline (test for the two known types, trap otherwise) or falls back to a virtual call with an inline cache. ## Why this matters in production - **Late-loading applications get a second warmup.** A container that loads plugins, a DI framework that creates proxies on first use, a serializer that spins classes on demand — every one of these can invalidate previously optimal code long after startup looked "warm". - **The very first alternate implementation is the expensive one.** Adding a second implementation of a hot interface — a decorator, a metrics wrapper, a test double left in a production path — can cost more than the wrapper's own work, because it converts a no-check inline into a guarded or virtual call across every caller. - **Frameworks that generate a class per type make call sites megamorphic.** Once a site has seen many receiver types, neither devirtualization route applies and the compiler emits a real itable/vtable dispatch. - **It is not a leak or a bug.** Invalidation is correctness machinery. The performance question is only how often it happens and whether it happens in steady state or during a bounded warmup. ## Distinguishing the two failure signatures - **CHA invalidation:** correlated with class loading. `-Xlog:class+load` shows a new implementation appearing right before a burst of `made not entrant` lines in `-XX:+PrintCompilation`. It happens once per new type, not per call. - **Guard failure:** correlated with *data*, not loading. A new receiver type flows through an already-compiled site; the deopt reason is `class_check`, and it can repeat until the site recompiles as bimorphic or megamorphic. ## Design implications You cannot and should not eliminate polymorphism to please the JIT, but a few things follow: - Keep genuinely hot interfaces implemented by one class where the design allows; the runtime rewards it substantially. - Prefer loading everything the steady state needs during warmup — eager plugin loading, eager proxy creation, a warmup pass exercising the real types — so invalidation happens before traffic arrives, not during peak. - Expect no benefit from marking things `final` merely to help devirtualization: HotSpot's CHA already devirtualizes single-implementor calls without `final`, and it re-optimizes when the world changes. `final` is a design statement, not a JIT flag.

  • Why must frames already running the invalidated code be deoptimized, rather than just preventing new entries?
    Those frames are executing machine code that assumes only one implementation exists — for example a direct call with no receiver type check. If the newly loaded type reaches that code, it would call the wrong method. Making the nmethod not entrant only stops future entries, so the runtime also converts live frames to interpreter frames at a safepoint.
  • How does profile-based inlining behave differently when a new receiver type appears?
    Nothing is invalidated. The compiled code already contains an explicit type guard, so the new type simply fails the guard and takes an uncommon trap. The site then deoptimizes and is typically recompiled as a bimorphic inline or a virtual call with an inline cache.
  • Does marking a class or method final help the JIT devirtualize?
    Rarely in a way you can measure. Class hierarchy analysis already devirtualizes a call whose interface or method has a single loaded implementor, and it will re-optimize when that changes. Use final to express design intent; do not add it as a performance trick.

saying these in an interview costs you the question

  • Believing already-compiled machine code cannot be affected by later class loading
  • Thinking a stale nmethod is patched in place instead of being invalidated and recompiled
  • Saying only future calls are affected, missing the on-stack deoptimization of frames already running
  • Confusing hierarchy-based devirtualization (invalidated by loading) with profile-based inlining (guarded at runtime)
  • Claiming final is required for the JIT to inline a virtual call

context