Performance benefits of sealed class in .NET

  • Gérald Barré

By default, classes are not sealed. This means that you can inherit from them. I think this is not the right default. Indeed, unless a class is designed to be inherited from, it should be sealed. You can still remove the sealed modifier later if there is a need. In addition to not be the best default, it has performance implications. Indeed, when a class is sealed the JIT can apply optimizations and slightly improve the performance of the application.

A new analyzer should be available in .NET 7 to detect classes that can be sealed. In this post, I'll show some performance benefits of sealed classes mentioned in this issue.

Table of contents:

#Performance benefits

##Calling virtual methods

When calling virtual methods, the actual method is found at runtime based on the actual type of the object. Each type has a Virtual Method Table (vtable) which contains the address of all the 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 right method directly to improve performance. Using sealed types helps the JIT as it knows there cannot be any derived class.

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 check the type of the object at runtime. When casting to a non-sealed type, the runtime must check for all types in the hierarchy. However, when casting to a sealed type, the runtime must only check the type of the object, so it is 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. That means that BaseType[] value = new DerivedType[1] is valid. This is not the case for other collections. For instance, List<BaseType> value = new List<DerivedType>(); is not valid.

The covariance comes with performance penalties. Indeed, the JIT must check the type of the object before assigning an item into an array. When using sealed types, the JIT can remove the check. You can check the post from Jon Skeet to get more details about 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>

You can convert arrays to Span<T> or ReadOnlySpan<T>. For the same reasons as the previous section, the JIT must check the type of the object before converting the array to a Span<T>. When using a sealed type, it can avoid the check and slightly improve the 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

When using a sealed type, the compiler knows some conversions are not valid. So, it can report warnings and errors. This may reduce errors in your application and also remove 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

#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?Buy Me A Coffee