前幾天筆者提交了關于FasterKvCache
的性能優化代碼,其中有一個點就是我把一些后續不需要繼承的類設置為了sealed
密封類,然后就有小伙伴在問,為啥這個地方需要設置成sealed
?
提交的代碼如下所示:
一般業務開發的同學可能接觸密封類比較少,密封類除了框架設計約束(不能被繼承)以外,還有一個微小的性能提升,不過雖然它是一個微小的優化點,多框架開發的作者都會做這樣的優化,如果方法調用的頻次很高,那也會帶來很大的收益。
筆者最開始是從.NET runtime 中的代碼學習到這一個優化技巧,后面有看到meziantou
大佬的文章performance-benefits-of-sealed-class[1]完整的學習了一下。
然后本來是想翻譯一下這篇文章,找了下發現 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
(它就是用來調用虛方法的,想了解更多詳情可以看這里[2]),因為 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 大佬翻譯的文章。
https://www.meziantou.net/performance-benefits-of-sealed-class.htm
https://mp.weixin.qq.com/s/dZlEjOB8jx0ku8eN8AhpzQ
.NET性能優化交流群
相信大家在開發中經常會遇到一些性能問題,苦于沒有有效的工具去發現性能瓶頸,或者是發現瓶頸以后不知道該如何優化。于是很高興的在這里宣布,我創建了一個專門交流.NET性能優化經驗的群組,主題包括但不限于:
如何找到.NET性能瓶頸,如使用APM、dotnet tools等工具
.NET框架底層原理的實現,如垃圾回收器、JIT等等
如何編寫高性能的.NET代碼,哪些地方存在性能陷阱
希望能有更多志同道合朋友加入,分享一些工作中遇到的.NET性能問題和寶貴的性能分析優化經驗。由于已經達到200人,可以加我微信,我拉你進群: ls1075

參考資料
[1]
performance-benefits-of-sealed-class: https://www.meziantou.net/performance-benefits-of-sealed-class.htm
[2]這里: https://learn.microsoft.com/zh-cn/dotnet/api/system.reflection.emit.opcodes.callvirt?view=net-7.0