Vtable slots in the MethodTable, the instruction sequence of a virtual call, override versus new, sealed and devirtualisation.
Each MethodTable has vtable slots for its virtual methods. A derived class copies its parent's slots and replaces the ones it overrides; new methods get a new slot instead. A virtual call loads the object's MethodTable pointer, loads the slot (through a chunk indirection) and makes an indirect call: a few dependent loads and an indirect branch. That is cheap, but it prevents inlining, which is usually the bigger cost. The JIT turns virtual calls into direct calls when it knows the exact type: sealed classes or methods, a just-created object, or guarded devirtualisation from Dynamic PGO.
A virtual call chooses the method implementation at run time from the object's actual type. In the CLR, that's done through the vtable in the object's MethodTable: load the MethodTable pointer, load the slot for the method, and make an indirect call. The dispatch itself costs only a couple of memory loads and an indirect branch — typically around a nanosecond — but the bigger cost is that virtual calls usually can't be inlined, which blocks further optimisation. The JIT recovers much of this with devirtualisation.
public abstract class Discount { public abstract decimal Apply(decimal amount); }
public sealed class Percentage(decimal p) : Discount { public override decimal Apply(decimal a) => a * (1 - p); }
Discount d = GetDiscount();
var result = d.Apply(100m); // virtual call
Roughly, on x64:
mov rax, [rcx] ; rcx = d; load MethodTable*
mov rax, [rax + 0x40] ; load vtable chunk pointer
call [rax + 0x20] ; call through the Apply slot
new (hiding) creates a new slot.callvirt for all instance calls (it also performs the null check), but non-virtual methods are called directly.| Aspect | Direct (non-virtual) call | Virtual call |
|---|---|---|
| Instructions | call target |
2 dependent loads + indirect call |
| Branch prediction | Always predictable | Indirect branch — predicted well if the target is consistent, mispredictions cost ~15–20 cycles |
| Inlining | Possible | Not possible unless devirtualised |
| Cache | Code only | MethodTable/vtable cache lines too |
| Typical cost (monomorphic, hot) | ~0–1 ns (often inlined to nothing) | ~1–2 ns, plus lost optimisation |
The lost inlining matters most: a small inlined method can be folded into the caller's loop, enabling constant propagation, bounds-check removal and vectorisation. A virtual call is an optimisation barrier.
In typical business code (I/O-bound), virtual call overhead is irrelevant. In tight CPU-bound loops over millions of elements, it can matter.
The JIT turns a virtual call into a direct (inlinable) call when it can prove or guess the target:
sealed override → exact target known.new in the same method, or returned from an inlined method.static readonly fields at Tier 1: the JIT knows the object's actual type.Percentage → emit if (d.MethodTable == Percentage_MT) { inlined body } else { virtual call }.A risk calculation iterated 50 million positions calling position.Instrument.Price(scenario) — a virtual method across 6 instrument types, mixed randomly in the list. Profiling showed time spent in indirect calls and no inlining. Sorting positions by instrument type (making each segment of the loop monomorphic so PGO's guard succeeded) and sealing the instrument classes cut the run from ~11 to ~7 minutes. We didn't remove the polymorphic design — just made the hot loop friendlier to the JIT.
The interviewer checks you understand how polymorphism is implemented and its real cost.
Green flags: vtable slots in the MethodTable, override versus new, the load sequence, cost is mainly lost inlining, devirtualisation conditions, sealing.
Red flags: thinks virtual calls are very slow; confuses new and override; unaware of devirtualisation.
.override).The object's MethodTable is the most-derived type from allocation, so virtual calls in base constructors dispatch to derived overrides — before derived constructors run. A correctness trap, not a performance one.
callvirt on non-virtual methodsC# uses callvirt for instance methods to get a null check. The JIT emits a direct call plus a null check (often folded into the first memory access). So callvirt in IL doesn't imply vtable dispatch.
[Benchmark] public decimal Sealed() { decimal s = 0; foreach (var d in _sealedArr) s += d.Apply(1m); return s; }
[Benchmark] public decimal Mixed() { decimal s = 0; foreach (var d in _mixedArr) s += d.Apply(1m); return s; }
Compare monomorphic, bimorphic, megamorphic arrays; with TieredPGO on/off; inspect disassembly for guards.
dumpmt -md in SOS to view a type's vtable slots.using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkRunner.Run<DispatchBench>();
public abstract class Shape { public abstract double Area(); }
public class OpenCircle(double r) : Shape { public override double Area() => Math.PI * r * r; }
public sealed class SealedCircle(double r) : Shape { public override double Area() => Math.PI * r * r; }
[DisassemblyDiagnoser(maxDepth: 2)]
public class DispatchBench
{
private readonly Shape[] _asBase = Enumerable.Range(1, 1000).Select(i => (Shape)new OpenCircle(i)).ToArray();
private readonly SealedCircle[] _sealed = Enumerable.Range(1, 1000).Select(i => new SealedCircle(i)).ToArray();
[Benchmark(Baseline = true)]
public double SumBase()
{
double s = 0;
foreach (Shape x in _asBase) s += x.Area(); // virtual call (PGO may add a guard)
return s;
}
[Benchmark]
public double SumSealed()
{
double s = 0;
foreach (SealedCircle x in _sealed) s += x.Area(); // exact type known: direct call, inlined
return s;
}
}
callvirt with virtual dispatchWhat happens: assuming all instance calls use the vtable.
Why it is wrong: callvirt is also used for null checks on non-virtual methods.
Do instead: check whether the method is virtual.
What happens: replacing virtual methods with switch statements everywhere. Why it is wrong: worse design; minimal gain in non-hot code. Do instead: measure; optimise only hot loops.
What happens: leaving leaf classes unsealed. Why it is wrong: misses cheap devirtualisation. Do instead: seal by default.
What happens: iterating mixed types in performance-critical loops. Why it is wrong: guards fail; no inlining. Do instead: group by type or use generics.
What happens: measuring only call overhead. Why it is wrong: the main cost is lost optimisation. Do instead: consider what inlining would enable.
What happens: base constructors call virtual methods. Why it is wrong: overrides run on partially initialised objects. Do instead: avoid (CA2214).
What happens: short benchmarks. Why it is wrong: guarded devirtualisation not yet applied. Do instead: proper warm-up with BenchmarkDotNet.
new (hiding) to avoid virtual costWhat happens: hiding methods instead of overriding. Why it is wrong: changes semantics; calls through base use the base method. Do instead: keep correct semantics; optimise elsewhere.
Seal by default: it documents intent and helps the JIT.
| Choice | Design effect | Performance effect |
|---|---|---|
sealed class |
Prevents unplanned inheritance | Devirtualisation and inlining |
| Virtual method | Extension point | Indirect call, inlining blocked unless devirtualised |
| Interface | Decoupling | Interface dispatch (slightly more work) |
| Generic struct constraint | Static polymorphism | Specialised direct calls |
The .NET team seals internal classes by default, and the analyser CA1852 suggests sealing types that are never derived from.
A class can implement many interfaces, so the interface method's slot number is not fixed in every implementing type's vtable. CoreCLR has used virtual stub dispatch: each call site calls through an indirection cell that first points to a lookup stub; after the first call it becomes a dispatch stub that checks "is this object's MethodTable the one I saw?" and jumps straight to the cached target, and if call sites see many types it switches to a resolve stub backed by a hash cache. NativeAOT uses a similar cached interface dispatch. Monomorphic interface calls are nearly as fast as virtual calls; megamorphic ones cost more. On structs, calling through the interface boxes, but a generic constrained call does not.
Dynamic PGO (profile-guided optimisation), on by default since .NET 8, lets the JIT optimise using data gathered from your own process. Hot methods first run in an instrumented tier that records which concrete types reach virtual and interface call sites, which delegates are invoked and which branches are taken. Tier 1 then uses that profile: guarded devirtualisation inserts a cheap type check for the dominant type and inlines its method, falling back to a normal virtual call otherwise; it also lays out hot blocks together and makes better inlining decisions. It is enabled with <TieredPGO> (default true) and depends on tiered compilation.
A sealed class cannot be inherited, and a sealed override cannot be overridden further. Sealing says "this type was not designed for extension", which protects its invariants. It also helps performance: the JIT can devirtualise and inline calls on sealed types, and type checks and casts to a sealed class are a single method-table comparison. Structs and static classes are already sealed. Analyser CA1852 suggests sealing internal types with no subtypes. The trade-off is that classic mocking libraries cannot mock sealed classes, which is a hint to depend on an interface at that seam. Unsealing later is non-breaking; sealing a public class later is breaking.
How to tell it (STAR)
In [our CAD exporter], tiny virtual methods dominated the profile. Our lists were already typed to concrete shapes, so I sealed the leaf classes and the JIT inlined the calls. Export time dropped [X%] and we turned on CA1852.
Last updated 31 Oct 2026.