.NET中的值类型与引用类型
.NET中的值類型與引用類型
這是一個常見面試題,值類型(Value Type)和引用類型(Reference Type)有什么區別?他們性能方面有什么區別?
TL;DR(先看結論)
| 創建位置 | 棧 | 托管堆 |
| 賦值時 | 復制值 | 復制引用 |
| 動態內存分配 | 無 | 需要分配內存 |
| 額外內存消耗 | 無 | 32位:額外12字節;64位:24字節 |
| 內存分布 | 連續 | 分散 |
引用類型
常用的引用類型代碼示例:
void Main(){ // 開始計數器 var sw = Stopwatch.StartNew(); long memory1 = GC.GetAllocatedBytesForCurrentThread(); // 創建C16 Span<B16> data = new B16[40_0000]; foreach (ref B16 item in data) { item = new B16(); item.V15.V15.V0 = 1; } long sum = 0; // 求和以免代碼被優化掉 for (var i = 0; i < data.Length; ++i) { sum += data[i].V15.V15.V0; } // 終止計數器 sw.Stop(); long memory2 = GC.GetAllocatedBytesForCurrentThread(); // 輸出顯示結果 new { Sum = sum, CreateTime = sw.ElapsedMilliseconds, Memory = memory2 - memory1 }.Dump();}class A1{ public byte V0;}class A16{ public A1 V0, V1, V2, V3, V4, V5, V6, V7, V8, V9, V10, V11, V12, V13, V14, V15; public A16() { V0 = new A1(); V1 = new A1(); V2 = new A1(); V3 = new A1(); V4 = new A1(); V5 = new A1(); V6 = new A1(); V7 = new A1(); V8 = new A1(); V9 = new A1(); V10 = new A1(); V11 = new A1(); V12 = new A1(); V13 = new A1(); V14 = new A1(); V15 = new A1(); }}class B16{ public A16 V0, V1, V2, V3, V4, V5, V6, V7, V8, V9, V10, V11, V12, V13, V14, V15; public B16() { V0 = new A16(); V1 = new A16(); V2 = new A16(); V3 = new A16(); V4 = new A16(); V5 = new A16(); V6 = new A16(); V7 = new A16(); V8 = new A16(); V9 = new A16(); V10 = new A16(); V11 = new A16(); V12 = new A16(); V13 = new A16(); V14 = new A16(); V15 = new A16(); }}這次代碼中,我們創建了40萬個B16類型,然后對這40萬個B16進行了統計,其中:
A1是一個字節(byte)的class;
A16是包含16個A1的class;
B16是包含16個A16的class;
可以計算出,B16=16·A16=16x16·A1=16x16x256?bytes,一共分配了40萬個B16,所以一共有40_0000x256=1_0240_0000?bytes,或約100兆字節。
實際結果輸出
| 40_0000 | 8_681 | 3_440_000_304 |
電腦配置(之后的下文的性能測試結果與此完全相同):
| CPU | E3-1230 v3 @ 3.30GHz | 未超頻 |
| 內存 | 24GB DDR3 1600 MHz | 8GB x 3 |
| .NET Core | 3.0.100-preview7-012821 | 64位 |
| 軟件 | LINQPad 6.0.13 | 64位,optimize+ |
數字涵義:
40萬條數據對1求和,結果是40萬,正確;
總花費時間一共需要9417毫秒;
總內存開銷約為3.4GB。
請注意看內存開銷,我們預估值是100MB,但實際約為3.4GB,這說明了引用類型需要(較大的)額外內存開銷。
一個空對象 要分配多大的堆內存?
以一個空白引用類型為例,可以寫出如下代碼(LINQPad中運行):
long m1 = GC.GetAllocatedBytesForCurrentThread();var obj = new object();long m2 = GC.GetAllocatedBytesForCurrentThread();(m2 - m1).Dump();GC.KeepAlive(obj);注意GC.KeepAlive是有必要的,否則運行在optimize+環境下會將new object()優化掉。
運行結果:24(在32位系統中,運行結果為:12)
空引用類型(64位)為何要24個字節?
一個引用類型的堆內存包含以下幾個部分:
同步塊索引(synchronization block index),8個字節,用于保存大量與CLR相關的元數據,以下基本操作都會用到該內存:
線程同步(lock)
垃圾回收(GC)
哈希值(HashCode)
其它
方法表指針(method table pointer),又叫類型對象指針(TypeHandle),8個字節,用來指向類的方法表;
實例成員,8字節對齊,沒有任何成員時也需要8個字節。
由于以上幾點,才導致一個空白的object需要24個字節。
因為沒有同步塊索引,導致:
值類型不能參與線程同步(lock)
值類型不需要進行垃圾回收(GC)
值類型的哈希值計算過程與引用類型不同(HashCode)
因為沒有方法表指針,導致:
值類型不能繼承
值類型的性能
值類型代碼示例
void Main(){ // 開始計數器 var sw = Stopwatch.StartNew(); long memory1 = GC.GetAllocatedBytesForCurrentThread(); // 創建C16 Span<B16> data = new B16[40_0000]; foreach (ref B16 item in data) { // item = new B16(); item.V15.V15.V0 = 1; } long sum = 0; // 求和以免代碼被優化掉 for (var i = 0; i < data.Length; ++i) { sum += data[i].V15.V15.V0; } // 終止計數器 sw.Stop(); long memory2 = GC.GetAllocatedBytesForCurrentThread(); // 輸出顯示結果 new { Sum = sum, CreateTime = sw.ElapsedMilliseconds, Memory = memory2 - memory1 }.Dump();}struct A1{ public byte V0;}struct A16{ public A1 V0, V1, V2, V3, V4, V5, V6, V7, V8, V9, V10, V11, V12, V13, V14, V15;}struct B16{ public A16 V0, V1, V2, V3, V4, V5, V6, V7, V8, V9, V10, V11, V12, V13, V14, V15;}幾乎完全一樣的代碼,區別只有:
將所有的class(表示引用類型)關鍵字換成了struct(表示值類型)
將item = new B16()語句去掉了(因為值類型創建數組會自動調用默認構造函數)
運行結果
運行結果如下:
| 40_0000 | 32 | 102_400_024 |
注意,分配內存只有102_400_024字節,比我們預估的102_400_000只多了24個字節。這是因為數組也是引用類型,引用類型需要至少24個字節。
比較
| 值類型 | 32 | / | 102_400_024 | / |
| 引用類型 | 8_681 | 271.28x | 3_440_000_304 | 33.59x |
在這個示例中,僅將值類型改成引用類型,竟需要多出271倍的時間,和33倍的內存占用。
重新審視值類型
值類型這么好,為什么不全改用值類型呢?
值類型的優點,恰恰也是值類型的缺點,值類型賦值時是復制值,而不是復制引用,而當值比較大時,復制值非常昂貴。
在遠古時代,甚至是沒有動態內存分配的,所以世界上只有值類型。那時為了減少值類型復制,會用變量來保存對象的內存位置,可以說是最早的指針了。
在近代的的C里,除了值類型,還加入了指向動態分配的值類型的指針。其中指針基本可以與引用類型進行類比:
?指針和引用類型的引用,都指向真實的對象內存位置
?動態分配的內存需要手動刪除,引用類型會自動GC回收
?指針指向的內存位置不會變,引用類型指向的內存位置會隨著GC的內存壓縮而產生變化,可用fixed關鍵字臨時禁止內存壓縮
?指針指向的內存沒有額外消耗,引用類型需要分配至少24字節的堆內存
C++為了解決這個問題,也是卯足了勁。先是加入了值引用運算符 &,而后又發布了一版又一版的“智能”指針,如auto_ptr/shared_ptr/unique_ptr。但這些“智能”指針都需要提前了解它的使用場景,如:
有對象所有權還是沒有對象所有權?
線程安全還是不安全?
能否用于賦值?
而且庫與庫之前的版本多樣,不統一,還影響開發的心情。
所以引用類型的優勢就出來了,不用關心對象的所有權,不用關心線程安全,不用關心賦值問題,而且最重要的,還不用關心值類型復制的性能問題。
C#中的值類型支持
引用類型是如此好,以至于平時完全不需要創建值類型,就能完成任務了。但為什么值類型仍然還是這么重要呢?就是因為一旦涉及底層,性能關鍵型的服務器、游戲引擎等等,都需要關心內存分配,都需要使用值類型。
因為只有C#才能不依賴于C/C++等“本機語言”,就可寫出性能關鍵型應用程序。
C#因為有這些和值類型的特性,導致與其它語言(C/C++)相比時完全不虛:
首先,C#可以寫自定義值類型
C# 7.0?值類型Task(ValueTask):大量異步請求,如讀取流時,可以節省堆內存分配和GC
鏈接:https://devblogs.microsoft.com/dotnet/understanding-the-whys-whats-and-whens-of-valuetask/C# 7.0?ref返回值/本地變量引用:避免了大值類型內存大量復制的開銷(有點像C++的&關鍵字了)
鏈接:https://devblogs.microsoft.com/dotnet/whats-new-in-csharp-7-0/#user-content-ref-returns-and-localsC# 7.0?Span<T>和Memory<T>,簡化了ref引用的代碼,甚至讓foreach循環都可以操作修改值類型了
鏈接:https://docs.microsoft.com/en-us/dotnet/standard/memory-and-spans/memory-t-usage-guidelinesC# 7.2?加入in修飾符和其它修飾符,相當于C++中的const TypeName&
鏈接:https://docs.microsoft.com/zh-cn/dotnet/csharp/whats-new/csharp-7-2#safe-efficient-code-enhancementsC# 8.0 - Preview 5?可Dispose的ref struct,值類型也能使用Dispose模式了
鏈接:https://docs.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-8#disposable-ref-structs
ASP.NET Core曾使用Libuv(基于C語言)作為內部傳輸層,但從ASP.NET Core 2.1之后,換成了用.NET重寫,鏈接:https://docs.microsoft.com/en-us/aspnet/core/fundamentals/servers/kestrel?view=aspnetcore-2.2#transport-configuration
最后的話
開發經常拿C#與同樣開發Web應用的其它語言作比較,但由于缺乏對值類型的支持,這些語言沒辦法與C#相比。
其中Java還暫不支持自定義值類型。
推薦書籍:《C#從現象到本質》(郝亦非 著)
.NET社區新聞,深度好文,歡迎訪問公眾號文章匯總?http://www.csharpkit.com?
總結
以上是生活随笔為你收集整理的.NET中的值类型与引用类型的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Azure 物联网开发者体验 7 月更新
- 下一篇: 微软开源基于.NET Core的量子开发