What Is a Sealed Class?
C# has a sealed keyword that indicates a class cannot be inherited.
When developing a framework, for example, you can use sealed to prevent inheritance and stop other developers from extending a class incorrectly.
However, the .NET design guidelines recommend avoiding excessive use of sealed to preserve extensibility.
Instead, they suggest using it selectively for types where inheritance poses a risk or that behave like values.
.NET Compilation Optimizations
The .NET compiler performs various optimizations, some of which apply only when a class is sealed.
Normally, calling a virtual method requires looking up the virtual method table (vtable) to find the method for the actual type.
But with a sealed class, the compiler knows for certain that there are no further derived types, so it can skip the vtable lookup and call the method directly.
Beyond virtual method calls, sealed classes can also improve performance slightly in cases that require additional type checks, such as array covariance checks.
Because the class cannot be inherited, some of those checks can be omitted.
.NET 6 Benchmark
The following blog post profiles the performance of sealed classes in .NET 6:
Performance benefits of sealed class in .NET - Meziantou's blog
As you can see, the sealed version performs better.
| Method | Mean | Error | StdDev |
|---|---|---|---|
| NonSealed | 0.4465 ns | 0.0276 ns | 0.0258 ns |
| Sealed | 0.0107 ns | 0.0160 ns | 0.0150 ns |
Can't the Compiler Optimize This Automatically?
A .NET program is first compiled into IL (Intermediate Language), which the JIT compiler translates into machine code at runtime.
The JIT cannot see all the code, so it cannot be certain that a class has no derived types.
As a result, it cannot always fully optimize these calls at runtime.
Optimization Options
Option 1: Use AOT
AOT (Ahead-of-Time) compilation converts the entire application into machine code before execution.
The compiler can then see all type relationships and aggressively optimize classes that have no derived types.
However, AOT deployment has limitations and isn't suitable for every project.
Option 2: Use PGO
Starting with .NET 8, PGO (Profile-Guided Optimization) is enabled by default.
It profiles the application as it runs and compiles optimized code in real time.
One of the optimizations PGO performs is devirtualization.
It can turn a virtual call into a direct call when it expects there to be no further derived types.
Devirtualization eliminates the vtable lookup and creates more opportunities for inlining, improving performance.
However, because it cannot be certain that there are no further derived types, it uses runtime type checks as a safeguard, as in guarded devirtualization.
The resulting code isn't as fast as an unconditional direct call, but it can still be faster than a virtual call.
- runtime/docs/design/coreclr/jit/GuardedDevirtualization.md at main · dotnet/runtime · GitHub
- Dynamic PGO | .NET Conf 2023 - YouTube
Option 3: Use sealed
Performance benefits of sealed class in .NET - Meziantou's blog
As suggested in the blog post above, you can use the Meziantou.Analyzer package to easily find classes that can be sealed.
You can then seal classes whose virtual methods are expected to be called frequently on a hot path.
.NET 6 vs. .NET 10 Benchmark
Below are results measured using the same code on .NET 6 and the latest version, .NET 10.
In .NET 10, virtual method calls on NonSealed have become considerably faster,
but explicitly marking a class as sealed may still be beneficial when its methods are called frequently.
| Method | Runtime | Mean | Error | StdDev |
|---|---|---|---|---|
| NonSealed | .NET 6.0 | 115.06 us | 2.299 us | 3.369 us |
| Sealed | .NET 6.0 | 28.83 us | 0.549 us | 0.513 us |
| NonSealed | .NET 10.0 | 25.44 us | 0.503 us | 0.826 us |
| Sealed | .NET 10.0 | 23.52 us | 0.415 us | 0.368 us |
[SimpleJob(RuntimeMoniker.Net60)]
[SimpleJob(RuntimeMoniker.Net10_0)]
public class SealedCall
{
readonly NonSealedType nonSealedType = new();
readonly SealedType sealedType = new();
const int TestNum = 100000;
[Benchmark]
public long NonSealed()
{
nonSealedType.num = 0;
for (int i = 0; i < TestNum; i++)
{
nonSealedType.Method();
}
return nonSealedType.num;
}
[Benchmark]
public long Sealed()
{
sealedType.num = 0;
for (int i = 0; i < TestNum; i++)
{
sealedType.Method();
}
return sealedType.num;
}
}
internal class BaseType
{
public long num = 0;
public virtual void Method() { }
}
internal class NonSealedType : BaseType
{
public override void Method() => num++;
}
internal sealed class SealedType : BaseType
{
public override void Method() => num++;
}
Summary
- sealed can improve performance by eliminating vtable lookups and type checks.
- PGO has made non-sealed classes faster too, but using the sealed keyword is still worth considering when virtual methods are called frequently.