.NET中的值類型與引用類型

  • 2019 年 10 月 3 日
  • 筆記

.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=16×16·A1=16x16x256 bytes,一共分配了40萬個B16,所以一共有40_0000x256=1_0240_0000 bytes,或約100兆位元組

實際結果輸出

Sum CreateTime Memory
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()語句去掉了(因為值類型創建數組會自動調用默認構造函數)

運行結果

運行結果如下:

Sum CreateTime Memory
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 點擊查看
  • C# 7.0 ref返回值/本地變數引用:避免了大值類型記憶體大量複製的開銷(有點像C++&關鍵字了) 點擊查看
  • C# 7.0 Span<T>Memory<T>,簡化了ref引用的程式碼,甚至讓foreach循環都可以操作修改值類型了 點擊查看
  • C# 7.2 加入in修飾符和其它修飾符,相當於C++中的const TypeName& 點擊查看
  • C# 8.0 - Preview 5 可Dispose的ref struct,值類型也能使用Dispose模式了 點擊查看

ASP.NET Core曾使用Libuv(基於C語言)作為內部傳輸層,但從ASP.NET Core 2.1之後,換成了用.NET重寫

最後的話

開發經常拿C#與同樣開發Web應用的其它語言作比較,但由於缺乏對值類型的支援,這些語言沒辦法與C#相比。

其中Java還暫不支援自定義值類型。

推薦書籍:《C#從現象到本質》(郝亦非 著)


作者:周傑
出處:https://www.cnblogs.com/sdflysha
本文採用 知識共享署名-非商業性使用-相同方式共享 2.5 中國大陸許可協議 進行許可,歡迎轉載,但未經作者同意必須保留此段聲明,且在文章頁面明顯位置給出原文連接。