What is a Sealed Class?
In C#, the sealed keyword indicates that a class cannot be inherited.
When developing a framework, using sealed can prevent developers from making incorrect extensions, ensuring that the class's behavior remains intact.
However, the .NET design guidelines recommend not overusing sealed for the sake of extensibility. It should be used sparingly and only for types that are likely to encounter issues or are treated like values.
.NET Compiler Optimizations
The .NET compiler performs various optimizations, some of which are only applied when a class is sealed.
Normally, when calling a virtual method, the virtual method table (vtable) must be consulted to find the actual method of the type.
However, for sealed classes, since it is "certain that there are no derived types," the compiler can skip the vtable lookup and call the method directly.
In addition to virtual method calls, there are also performance improvements for additional type checks, such as array covariance checks, because sealed classes guarantee that there are no further inheritances.
.NET 6 Benchmark
You can find profiling results for sealed classes in .NET 6 in the blog post below.
Performance benefits of sealed class in .NET - Meziantou's blog
It shows that using sealed results in better performance.
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 Automatically?
.NET programs are first compiled into IL (Intermediate Language) and then translated into machine code at runtime by the JIT (Just-In-Time) compiler.
From the JIT's perspective, it cannot be certain that a class truly has no inheritance because it cannot see the entire code.
Therefore, perfect optimization at runtime is not feasible.
Optimization Methods
Option 1 - Use AOT
AOT (Ahead-of-Time) compilation converts the entire code into machine code before execution.
At this time, the compiler knows all type relationships, allowing it to optimize classes without inheritance aggressively.
However, AOT deployment has limitations and is not applicable to every project.
Option 2 - Use PGO
Starting from .NET 8, PGO (Profile-Guided Optimization) is enabled by default.
This method profiles the code during execution and compiles optimized code in real-time.
One of the optimizations performed by PGO is Devirtualization.
This transforms virtual calls that are presumed to have no further inheritance into direct calls.
When virtualization is removed, vtable lookups are eliminated, and opportunities for inlining increase, improving performance.
However, since it cannot be guaranteed that there is no further inheritance, runtime type checks like Guarded Devirtualization are used as a safeguard.
Thus, while it may not be as fast as direct calls, it can still generate faster code than virtual calls.
- runtime/docs/design/coreclr/jit/GuardedDevirtualization.md at main · dotnet/runtime · GitHub
- Dynamic PGO | .NET Conf 2023 - YouTube
Option 3 - Use Sealed
As suggested in Performance benefits of sealed class in .NET - Meziantou's blog, using the Meziantou.Analyzer package can help you easily identify classes that can be sealed.
This allows you to seal classes that are expected to have frequently called virtual functions in hot paths.
.NET 6 vs .NET 10 Benchmark
Below are the results measured with the same code in .NET 6 and the latest .NET 10.
In .NET 10, the virtual method calls for NonSealed have also improved significantly, but for classes that are called frequently, it may still be beneficial to explicitly set them as sealed.
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++;
}
Conclusion
- Sealed helps improve performance by eliminating vtable lookups and type checks.
- Thanks to PGO, non-sealed classes have also become faster, but when virtual method calls are frequent, it is still worth considering the use of the sealed keyword.