.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兆位元組。
實際結果輸出
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 中國大陸許可協議
進行許可,歡迎轉載,但未經作者同意必須保留此段宣告,且在文章頁面明顯位置給出原文連線