In .NET, every method on a class is potentially virtual — not in the C# sense of being marked virtual, but in the runtime sense that the JIT must consider whether a derived class might override it. When the JIT can prove that no override exists, it can devirtualise the call: replacing an indirect vtable lookup with a direct call, or even inlining the method entirely. Marking a class sealed is the simplest way to give the JIT this proof.
What Virtual Dispatch Costs
A virtual method call requires:
- Load the object's method table pointer.
- Index into the vtable to find the target method address.
- Call through the pointer.
This is an indirect call — the CPU cannot predict the target as easily as a direct call, and the method cannot be inlined. For methods called millions of times in tight loops, this overhead is measurable.
public class Animal
{
public virtual string Speak() => "...";
}
public class Dog : Animal
{
public override string Speak() => "Woof";
}
When calling animal.Speak(), the JIT must use virtual dispatch because another class could derive from Dog and override Speak() again.
The sealed Keyword
Marking a class sealed tells the compiler and JIT that no class can derive from it:
public sealed class Dog : Animal
{
public override string Speak() => "Woof";
}
Now the JIT knows that when it has a Dog reference, Speak() will always be Dog.Speak(). It can devirtualise the call — converting it from an indirect vtable lookup to a direct call. Better yet, if the method is small, the JIT can inline it entirely.
Measuring the Difference
public class BaseClass
{
public virtual int Calculate(int x) => x * 2;
}
public class UnsealedDerived : BaseClass
{
public override int Calculate(int x) => x * 3;
}
public sealed class SealedDerived : BaseClass
{
public override int Calculate(int x) => x * 3;
}
[MemoryDiagnoser]
public class SealedBenchmark
{
private readonly UnsealedDerived _unsealed = new();
private readonly SealedDerived _sealed = new();
[Benchmark(Baseline = true)]
public int Unsealed()
{
int sum = 0;
for (int i = 0; i < 1000; i++)
sum += _unsealed.Calculate(i);
return sum;
}
[Benchmark]
public int Sealed()
{
int sum = 0;
for (int i = 0; i < 1000; i++)
sum += _sealed.Calculate(i);
return sum;
}
}
With PGO disabled, the sealed version is typically 10-30% faster because the JIT inlines Calculate directly into the loop. With PGO enabled, the difference narrows because PGO can perform guarded devirtualisation even without sealed — but sealed provides a guarantee rather than a heuristic.
Beyond Classes: Sealed Methods
You can also seal individual methods:
public class Base
{
public virtual void Process() { }
}
public class Middle : Base
{
public sealed override void Process()
{
// No further override possible
}
}
public class Derived : Middle
{
// Cannot override Process — it's sealed in Middle
}
This is useful when you want to allow inheritance of the class but lock down specific method implementations.
ToString, Equals, and GetHashCode
Every class in .NET inherits ToString(), Equals(), and GetHashCode() from object. These are virtual methods. When you override them in a sealed class, the JIT can devirtualise the calls:
public sealed class UserId
{
private readonly int _value;
public UserId(int value) => _value = value;
public override int GetHashCode() => _value;
public override bool Equals(object? obj) =>
obj is UserId other && _value == other._value;
public override string ToString() => _value.ToString();
}
In a Dictionary<UserId, string>, the dictionary calls GetHashCode() and Equals() for every lookup. With sealed, these calls can be devirtualised and inlined, making dictionary operations measurably faster.
The .NET Runtime Uses sealed Extensively
The .NET runtime team marks types sealed wherever possible. A search through the runtime repository reveals thousands of sealed classes. This is not just about preventing inheritance — it is a deliberate performance strategy.
The team even has an analyser (CA1852) that suggests sealing classes that are not designed for inheritance:
<PropertyGroup>
<AnalysisLevel>latest-recommended</AnalysisLevel>
</PropertyGroup>
This enables the "Type can be sealed" diagnostic, which identifies classes that are internal, have no derived types, and no virtual members used polymorphically.
When Not to Seal
Do not seal a class if:
- It is designed as a base class for user extension.
- It is part of a public library API where consumers may need to derive from it.
- You are using mocking frameworks that create derived proxy types (though modern mocking frameworks like NSubstitute can mock interfaces instead).
For internal classes, there is almost never a reason not to seal them. The JIT benefits are free, and the sealed modifier communicates intent clearly.
Summary
Marking classes sealed is one of the simplest performance wins in .NET. It costs nothing to apply, communicates design intent, and enables the JIT to devirtualise and inline method calls. For internal types, make sealed the default. For public types, consider it carefully based on your API design. Either way, the performance benefits are real and measurable.