Performance benefits of sealed class in .NET

 
 
  • Gérald Barré

By default, classes are not sealed, meaning any class can inherit from them. A class should be sealed unless it is explicitly designed for inheritance – you can always remove the modifier later if needed. Beyond design concerns, sealing a class also has performance implications: the JIT can apply optimizations that slightly improve application performance.

Starting in .NET 7, a new analyzer is available to detect classes that can be sealed. The performance benefits of sealed classes are described in this issue.

#Performance benefits

##Calling virtual methods

When calling virtual methods, the runtime resolves the target method based on the object's actual type. Each type has a Virtual Method Table (vtable) that contains the addresses of all virtual methods. These pointers are used at runtime to invoke the appropriate method implementations (dynamic dispatch).

If the JIT knows the actual type of the object, it can skip the vtable and call the method directly, improving performance. Using sealed types helps the JIT because it guarantees no derived class can exist.

C#
public class SealedBenchmark
{
    readonly NonSealedType nonSealedType = new();
    readonly SealedType sealedType = new();

    [Benchmark(Baseline = true)]
    public void NonSealed()
    {
        // The JIT cannot know the actual type of nonSealedType. Indeed,
        // it could have been set to a derived class by another method.
        // So, it must use a virtual call to be safe.
        nonSealedType.Method();
    }

    [Benchmark]
    public void Sealed()
    {
        // The JIT is sure sealedType is a SealedType. As the class is sealed,
        // it cannot be an instance from a derived type.
        // So it can use a direct call which is faster.
        sealedType.Method();
    }
}

internal class BaseType
{
    public virtual void Method() { }
}
internal class NonSealedType : BaseType
{
    public override void Method() { }
}
internal sealed class SealedType : BaseType
{
    public override void Method() { }
}
MethodMeanErrorStdDevMedianRatioCode Size
NonSealed0.4465 ns0.0276 ns0.0258 ns0.4437 ns1.0018 B
Sealed0.0107 ns0.0160 ns0.0150 ns0.0000 ns0.027 B

Note that when the JIT can determine the actual type, it can use a direct call even if the type is not sealed. For instance, there is no difference between the following two snippets:

C#
void NonSealed()
{
    var instance = new NonSealedType();
    instance.Method(); // The JIT knows `instance` is NonSealedType because it is set
                       // in the method and never modified, so it uses a direct call
}

void Sealed()
{
    var instance = new SealedType();
    instance.Method(); // The JIT knows instance is SealedType, so it uses a direct call
}

##Casting objects (is / as)

When casting objects, the runtime must verify the object's type. For non-sealed types, it must walk the entire inheritance hierarchy. For sealed types, only a single type check is needed, making the operation faster.

C#
public class SealedBenchmark
{
    readonly BaseType baseType = new();

    [Benchmark(Baseline = true)]
    public bool Is_Sealed() => baseType is SealedType;

    [Benchmark]
    public bool Is_NonSealed() => baseType is NonSealedType;
}

internal class BaseType {}
internal class NonSealedType : BaseType {}
internal sealed class SealedType : BaseType {}
MethodMeanErrorStdDevRatio
Is_NonSealed1.6560 ns0.0223 ns0.0208 ns1.00
Is_Sealed0.1505 ns0.0221 ns0.0207 ns0.09

##Arrays

Arrays in .NET are covariant. This means that BaseType[] value = new DerivedType[1] is valid. This is not the case for generic collections – for instance, List<BaseType> value = new List<DerivedType>(); is not valid.

Array covariance comes with a performance cost: the JIT must verify the element type before assigning an item into an array. With sealed types, the JIT can skip this check. See Jon Skeet's post for more details on the performance penalties.

C#
public class SealedBenchmark
{
    SealedType[] sealedTypeArray = new SealedType[100];
    NonSealedType[] nonSealedTypeArray = new NonSealedType[100];

    [Benchmark(Baseline = true)]
    public void NonSealed()
    {
        nonSealedTypeArray[0] = new NonSealedType();
    }

    [Benchmark]
    public void Sealed()
    {
        sealedTypeArray[0] = new SealedType();
    }
}

internal class BaseType { }
internal class NonSealedType : BaseType { }
internal sealed class SealedType : BaseType { }
MethodMeanErrorStdDevRatioCode Size
NonSealed3.420 ns0.0897 ns0.0881 ns1.0044 B
Sealed2.951 ns0.0781 ns0.0802 ns0.8658 B

##Converting arrays to Span<T>

Arrays can be converted to Span<T> or ReadOnlySpan<T>. For the same reasons as the previous section, the JIT must verify the element type before performing the conversion. With a sealed type, this check is skipped, slightly improving performance.

C#
public class SealedBenchmark
{
    SealedType[] sealedTypeArray = new SealedType[100];
    NonSealedType[] nonSealedTypeArray = new NonSealedType[100];

    [Benchmark(Baseline = true)]
    public Span<NonSealedType> NonSealed() => nonSealedTypeArray;

    [Benchmark]
    public Span<SealedType> Sealed() => sealedTypeArray;
}

public class BaseType {}
public class NonSealedType : BaseType { }
public sealed class SealedType : BaseType { }
MethodMeanErrorStdDevRatioCode Size
NonSealed0.0668 ns0.0156 ns0.0138 ns1.0064 B
Sealed0.0307 ns0.0209 ns0.0185 ns0.5035 B

##Detecting unreachable code

With a sealed type, the compiler can detect invalid conversions and report warnings or errors, reducing bugs and eliminating unreachable code.

C#
class Sample
{
    public void Foo(NonSealedType obj)
    {
        _ = obj as IMyInterface; // ok because a derived class can implement the interface
    }

    public void Foo(SealedType obj)
    {
        _ = obj is IMyInterface; // ⚠️ Warning CS0184
        _ = obj as IMyInterface; // ❌ Error CS0039
    }
}

public class NonSealedType { }
public sealed class SealedType { }
public interface IMyInterface { }

#Finding types that could be sealed

Meziantou.Analyzer contains a rule that checks for types that could be sealed.

Shell
dotnet add package Meziantou.Analyzer

It should report any internal class that could be sealed using MA0053:

You can also instruct the analyzer to report public classes by editing the .editorconfig file:

.editorconfig
[*.cs]
dotnet_diagnostic.MA0053.severity = suggestion

# Report public classes without inheritors (default: false)
MA0053.public_class_should_be_sealed = true

# Report class without inheritors even if there is virtual members (default: false)
MA0053.class_with_virtual_member_shoud_be_sealed = true

You can use a tool such as dotnet format to fix the solution:

Shell
dotnet format analyzers --severity info

Also, Roslyn analyzers can only analyze a single assembly at a time. To analyze all assemblies in a solution, you can use Roslyn to load the solution and analyze all projects. Here's an example: How to Find All Types That Can Be Sealed Using Roslyn

#Additional notes

All benchmarks were run using the following configuration:

BenchmarkDotNet=v0.13.1, OS=Windows 10.0.22000
AMD Ryzen 7 5800X, 1 CPU, 16 logical and 8 physical cores
.NET SDK=7.0.100-preview.2.22153.17
  [Host]     : .NET 6.0.3 (6.0.322.12309), X64 RyuJIT
  DefaultJob : .NET 6.0.3 (6.0.322.12309), X64 RyuJIT

#Additional resources

Do you have a question or a suggestion about this post? Contact me!

Follow me:
Enjoy this blog?