skip to content

Kotlin, C++ and C# make a class's methods non-overridable unless the author writes `open` or `virtual`; Java and Python make them overridable unless the author writes `final` or an equivalent. Setting the API-design argument aside, what does each default buy or cost the machinery that has to resolve the call — an ahead-of-time compiler, a JIT, and a framework that generates subclass proxies at runtime?

level: seniorimportance: should knowfreq 31%

answer

  1. AOT must commit; JIT can bet and undo
  2. CHA + type profile → inline under a guard
  3. new class loaded → dependency invalidated → deopt
  4. closed world (native-image) substitutes for `final`
  5. sealed surface breaks subclass proxies → all-open plugin

basics

~20 s

Sealing is a compile-time promise. An ahead-of-time compiler needs it to skip the dispatch table and inline. A JIT gets the same win speculatively from the classes actually loaded, and undoes it when a second implementation appears — so final buys it far less.

solid answer

~60 s

Sealing is information for the compiler, and its worth depends on **when** compilation happens. - **Ahead-of-time toolchains** — C++, Rust, Swift whole-module optimisation, Kotlin/Native, GraalVM native-image, .NET NativeAOT — must commit at build time. Non-virtual or `final` is what lets them turn an indirect call into a direct one and inline across it. GraalVM reaches the same conclusion differently: its closed-world analysis proves only one implementation is reachable in the whole image, and nothing can be loaded later to break that. - **A JIT does not need the keyword.** HotSpot uses class hierarchy analysis plus a per-call-site type profile to devirtualise monomorphic sites and inline them under a guard; loading a second implementation invalidates the compiled code and triggers deoptimisation and recompilation. .NET does the same with guarded devirtualisation and dynamic PGO. So on a managed runtime `final` is mostly an API and correctness decision, not a speed one. - **The cost of sealing is proxying.** Spring's CGLIB proxies subclass and override, which is why the `kotlin-spring` all-open plugin exists; C# proxy libraries and EF Core lazy loading need `virtual`; Swift types need `open`, not merely `public`, to be subclassed from another module.

code

text · 8 lines
text
t0  interpret  call site sees only Impl  (one class loaded)
t1  compile    CHA: unique target -> direct call, body INLINED
               compiled code records dependency: "no other impl"
t2  run fast   no vtable load, callee constants folded in
t3  classload  OtherImpl arrives -> dependency broken
t4  invalidate compiled code marked not-entrant
t5  deopt      active frames rebuilt for the interpreter
t6  recompile  guarded inline (if type==Impl ...) else virtual call

go deeper

for a junior

Know the core recall: a virtual call is resolved at run time from the object's actual type, a non-virtual or final one at compile time from the declared type, and only the second can be inlined for free.

for a middle

Explain why blocked inlining, not the indirect jump itself, is the real cost, and that final/virtual is information the compiler either has or must prove for itself.

for a senior

Contrast the compilation models concretely: AOT needs the promise, HotSpot recovers it with class hierarchy analysis plus type profiles and undoes it by deoptimisation, and closed-world images substitute whole-program analysis for the keyword. Name the proxying cost — kotlin-spring all-open, virtual for C# proxies, Swift open.

for a principal

Frame it as a platform decision: pick the default from the target toolchain and the framework stack, insist on measurement before claiming a JIT-side win, and steer the codebase toward interface-based extension points so sealed implementations and runtime proxying can coexist.

## What "binding" means here **Binding** is deciding which body of code a call runs. **Static (early) binding** picks it at compile time from the *declared* type of the receiver; **dynamic (late) binding** picks it at run time from the *actual* type, normally through a per-class table of function pointers (a vtable, or an interface table). Dynamic dispatch costs an indirect load and jump, but the real cost is that the optimiser cannot see the callee's body: an indirect call is an opaque wall that blocks inlining and everything inlining unlocks — constant folding, escape analysis, dead-branch removal, register allocation across the boundary. **Devirtualisation** is proving that a dynamically-dispatched call has exactly one possible target and replacing it with a direct call, usually followed by inlining. Every argument below is about who is allowed to make that proof, and when. ## Two defaults, one keyword Java, Python, Ruby and Objective-C dispatch by default; the author writes `final` (or nothing at all, in the dynamic three) to stop it. C++, C#, Kotlin and Swift-across-module-boundaries bind statically by default; the author writes `virtual` or `open` to opt in. Rust and Go sidestep the question — Rust's default is static dispatch through monomorphised generics, with `dyn Trait` as the explicit opt-in to a vtable — but the tradeoff is identical: the language decides whether the compiler is *told* the target is fixed or must *prove* it. ## What an ahead-of-time compiler gets from the promise An AOT compiler emits machine code once and can never revisit it. If a method is overridable and the compiler is compiling a library separately from its consumers, any class in any future consumer might override it, so the compiler must keep the indirect call. The keyword is therefore not a micro-optimisation hint, it is the *only* evidence available. This is why `final` measurably matters in Swift with whole-module optimisation, in Kotlin/Native, and in C++ where `final` on a class or virtual method is a documented devirtualisation aid. The exception is a **closed world**. GraalVM native-image and .NET NativeAOT analyse the entire program plus its libraries, with no dynamic class loading permitted afterwards. If only one implementation of a method is reachable in that image, the call can be bound statically even though the source left it open — and unlike a JIT, the compiler never has to plan for being wrong. Closed-world analysis substitutes for the keyword; separate compilation cannot. ## What a JIT does instead HotSpot compiles late and can recompile. It devirtualises using two independent facts. **Class hierarchy analysis (CHA)** asks whether, among the classes loaded *so far*, exactly one implements the method; if so, the call is compiled as a direct, inlined call, and the compiled method records a dependency on that assumption. **Type profiles** gathered by the interpreter say which receiver classes actually showed up at this specific call site; a site that saw one type (monomorphic) or two (bimorphic) is inlined behind a cheap class check, with a slow path for anything else. Both are undoable. When a class that breaks a recorded assumption is loaded, the JVM invalidates the dependent compiled code and **deoptimises** — execution falls back to the interpreter, mid-frame if necessary, and the method is later recompiled with a guard or a genuine virtual call. .NET's RyuJIT does the equivalent with guarded devirtualisation and, with dynamic PGO on by default from .NET 8, profile-driven type guards. The consequence is that on the JVM, `final` on a method rarely buys speed the JIT was not already getting. What *does* cost you is a genuinely **megamorphic** call site — many receiver types at one place — where no guard pays for itself, inlining is abandoned, and the wall goes back up. That is a property of the actual type distribution, not of the modifier. ## Where sealing collides with the tooling A large slice of framework behaviour — transactions, caching, lazy loading, security, test doubles — is implemented by generating a **subclass proxy** at run time that overrides the target's methods. That mechanism requires exactly what sealing forbids. Kotlin's final-by-default surface broke Spring's CGLIB proxying badly enough that the ecosystem ships the `kotlin-spring` all-open compiler plugin, which reopens classes carrying Spring annotations. In C#, dynamic-proxy libraries and EF Core's lazy-loading proxies can only intercept `virtual` members, which is why .NET APIs lean on interfaces. Swift requires `open` rather than `public` for cross-module subclassing, and adds `@objc dynamic` when a tool needs message-level interception (KVO, some instrumentation) that a vtable cannot provide. So the sealed default hands the AOT compiler a real optimisation and hands the runtime toolchain a real problem, and the ecosystem pays for it with a compiler plugin or an interface-first style. ## How to decide Ask what compiles your code. On HotSpot or CoreCLR, choose the default for API and correctness reasons and do not claim a speed win you have not measured. On an AOT target, treat `final`/`sealed`/non-virtual as performance-relevant and seal the hot paths. Either way, prefer **interface-based** extension points: interface proxies work under both defaults, and a sealed implementation class behind a published interface gives the AOT compiler its promise without breaking the frameworks.

  • If a JIT devirtualises speculatively anyway, when does leaving methods overridable actually cost runtime performance?
    When the call site is genuinely megamorphic — many receiver types reach the same place — the type profile gives the compiler nothing to speculate on, guards stop paying for themselves, and the call stays indirect. Inlining is then abandoned, and the losses cascade: no constant folding of arguments, no escape analysis on the callee's allocations, no cross-boundary register allocation. Also count the churn cost: an application that keeps loading new implementations forces repeated invalidation, deoptimisation and recompilation.
  • How does GraalVM native-image change the calculus compared with running the same Kotlin or Java code on HotSpot?
    Native-image compiles under a closed-world assumption: the reachable classes are fixed at build time and no class can be loaded later. That lets it devirtualise a call whose method is nominally open, purely because only one implementation is reachable — and unlike a JIT the conclusion can never be falsified, so no guard or deoptimisation path is needed. The flip side is that reflection, dynamic proxies and runtime class generation must be declared to the build or they simply do not exist at run time, which is the same constraint that makes subclass-proxy frameworks awkward.
  • How do you keep an AOT-friendly sealed surface without breaking a framework that proxies your classes?
    Publish an interface or protocol for anything the framework or a test needs to substitute, and seal the implementation class behind it. Interface-based proxies (JDK dynamic proxies, .NET interface proxies) never need to override a concrete method, so the implementation can stay final and the AOT compiler still gets its promise for internal calls. Where the framework insists on subclass proxies, use the ecosystem's own escape hatch — Kotlin's all-open plugin for Spring-annotated classes, `virtual` on the specific members C# proxy libraries must intercept — rather than opening the whole surface.

Sealing for an AOT compiler is a contract signed at build time: without a signature it cannot act. A JIT does not need the signature because it can place a bet and tear up the receipt — the guard and deoptimisation path are how it cancels the bet when a second implementation shows up.

saying these in an interview costs you the question

  • Claiming `final` makes methods faster on the JVM as a general rule, without distinguishing AOT from JIT compilation.
  • Saying a JIT cannot inline a virtual call — inlining a monomorphic virtual call behind a guard is exactly what it does.
  • Assuming an ahead-of-time compiler can devirtualise an open method in a separately-compiled library just by analysing enough code; only a closed-world build can conclude that.
  • Treating the sealed-versus-open default as purely an API style question with no consequence for the compiler or the runtime.
  • Believing proxy frameworks work by reflection alone, so virtuality is irrelevant — subclass proxies must override, which sealing forbids.

context