為什麼要把類設定成密封?

2022-11-28 12:02:27

前幾天筆者提交了關於FasterKvCache的效能優化程式碼,其中有一個點就是我把一些後續不需要繼承的類設定為了sealed密封類,然後就有小夥伴在問,為啥這個地方需要設定成sealed

提交的程式碼如下所示:

一般業務開發的同學可能接觸密封類比較少,密封類除了框架設計約束(不能被繼承)以外,還有一個微小的效能提升,不過雖然它是一個微小的優化點,多框架開發的作者都會做這樣的優化,如果方法呼叫的頻次很高,那也會帶來很大的收益。

筆者最開始是從.NET runtime 中的程式碼學習到這一個優化技巧,後面有看到meziantou大佬的文章performance-benefits-of-sealed-class完整的學習了一下。

然後本來是想翻譯一下這篇文章,找了下發現 Weihan 大佬今年年初翻譯了meziantou大佬的文章,質量非常高的中文版,大家可以戳連結看看,既然如此在本文中帶大家回顧一下文章中例子,另外從 JIT ASM 的層面分析為什麼效能會有提升。

效能優勢

虛方法呼叫

在上面提到的文章例子中,有一個虛方法的呼叫,大家其實要明白一點,現在物件導向的封裝、繼承、多型中的多型實現主要就是靠虛方法。

一個型別可能會有子類,子類可能會重寫型別的方法從而達到不同的行為(多型),而這些重寫的方法都在虛方法表裡,呼叫的話就需要查表。

回到文中的程式碼,大佬構建了一個這樣的測試用例:

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

    [Benchmark(Baseline = true)]
    public void NonSealed()
    {
        // JIT不能知道nonSealedType的實際型別.
        // 它可能已經被另一個方法設定為派生類。
        // 所以,為了安全起見,它必須使用一個虛擬呼叫。
        nonSealedType.Method();
    }

    [Benchmark]
    public void Sealed()
    {
        // JIT確信sealedType是一個SealedType。 由於該類是密封的。
        // 它不可能是一個派生型別的範例。
        // 所以它可以使用直接呼叫,這樣會更快。
        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() { }
}

取得的結果就是密封類要比非密封的快 98%。

那麼為什麼會這樣呢?首先我們來比較一下兩個方法的 IL 程式碼,發現是一模一樣的,對於方法呼叫都是用了callvirt(它就是用來呼叫虛方法的,想了解更多詳情可以看這裡),因為 instance 是從欄位中載入的,編譯器無法知道具體的型別,只能使用callvirt

那區別在哪裡呢?我們可以看到 JIT 生成後的組合程式碼,可以很清楚的看到密封類少了兩條指令,因為 JIT 可以從密封類中知道它不可能被繼承,也不可能被重寫,所以是直接跳轉到密封類目標方法執行,而非密封類還有一個查表的過程。而現在很多大佬聊天說 JIT 的"去虛擬化"其實主要就是在 JIT 編譯時去除了callvirt呼叫。

另外文中也提到了一段程式碼,如果 JIT 能確定型別,也是直接呼叫的:

void NonSealed()
{
    var instance = new NonSealedType();
    instance.Method(); // JIT知道`instance`是NonSealedType,因為它是在方法中被建立的,
                       // 從未被修改過,所以它使用直接呼叫
}

void Sealed()
{
    var instance = new SealedType();
    instance.Method(); // JIT知道型別是SealedType, 所以直接呼叫
}

此時兩者的組合程式碼沒有任何區別,都是直接 jmp 到目標方法。

發現一個有趣的東西,如果我們切到.NET Framework 的 JIT,可以發現.NET Framework 的 JIT 沒有.NET 生成的這麼高效,沒有直接 jmp 到目標方法,而是多了一層 call 和 ret。所以,朋友們還等什麼呢?快升級.NET 版本吧。

物件型別轉換 (is / as)

同樣有下面這樣一段程式碼,測試密封類和非密封類的物件型別轉換效能:

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 {}

毫無疑問,密封類快 91%。

IL 層面,兩個方法都是一模一樣:

可以看到密封類的程式碼相當高效,直接比較一下就轉換型別返回了,而非密封類還需要 call 方法走查表流程:

陣列

.NET 的陣列是協變的,協變相容的話就意味著在新增進入陣列時需要檢查它的型別,而如果是密封類那就可以刪除檢查,同樣有下面一段程式碼:

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 { }

密封類的效能要高 14%左右。

開啟 IL 程式碼,兩者編譯出的方法都是一樣的,但是跳轉到組合程式碼可以發現差別,同樣的是Stelem.Ref給陣列賦值,密封類只是檢查了一下陣列長度,然後直接賦值,而非密封類還需要呼叫System.Runtime.CompilerServices.CastHelpers.StelemRef進行檢查才能完成賦值。

將陣列轉換為Span<T>

和陣列一樣,將陣列轉換為Span<T>時也需要插入型別檢查,有如下測試程式碼:

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 { }

密封類的效能要高 50%:

同樣,這也是 IL 一模一樣的,在 JIT 階段做的優化,可以明顯的看到,JIT 為非密封類單獨做了型別檢查:

總結

筆者在 FasterKvCache 程式碼中將一些類設定為sealed的原因顯而易見:

  • 為了讓類的職責更加清晰,在設計中沒有計劃讓它有派生類
  • 為了效能的提升,JIT 優化可以讓其方法呼叫更快

還有更多有趣的東西(比如 IDE 智慧提示將類設定為密封,如何使用 dotnet format 整合這些分析),大家可以翻閱原文或者 Weihan 大佬翻譯的文章。

.NET效能優化交流群

相信大家在開發中經常會遇到一些效能問題,苦於沒有有效的工具去發現效能瓶頸,或者是發現瓶頸以後不知道該如何優化。之前一直有讀者朋友詢問有沒有技術交流群,但是由於各種原因一直都沒建立,現在很高興的在這裡宣佈,我建立了一個專門交流.NET效能優化經驗的群組,主題包括但不限於:

  • 如何找到.NET效能瓶頸,如使用APM、dotnet tools等工具
  • .NET框架底層原理的實現,如垃圾回收器、JIT等等
  • 如何編寫高效能的.NET程式碼,哪些地方存在效能陷阱

希望能有更多志同道合朋友加入,分享一些工作中遇到的.NET效能問題和寶貴的效能分析優化經驗。由於已經達到200人,可以加我微信,我拉你進群: ls1075