[英]How are types compared in .NET? How does the CLR identify they are the same or different?
[英]How does the CLR (.NET) internally allocate and pass around custom value types (structs)?
是否所有 CLR 值類型,包括用戶定義的struct
,都只存在於計算堆棧上,這意味着它們永遠不需要被垃圾收集器回收,或者是否存在垃圾收集的情況?
我之前在 SO上問過一個關於流暢接口對 .NET 應用程序運行時性能的影響的問題。 我特別擔心創建大量非常短暫的臨時對象會通過更頻繁的垃圾收集對運行時性能產生負面影響。
現在我發現如果我將這些臨時對象的類型聲明為struct
(即作為用戶定義的值類型)而不是class
,如果結果證明所有值類型都是獨占的,那么垃圾收集器可能根本不參與在評估堆棧上。
(這對我來說主要是因為我在考慮 C++ 處理局部變量的方式。通常是自動( auto
)變量,它們在堆棧上分配,因此在程序執行返回調用者時被釋放——沒有通過動態內存管理new
涉及new
/ delete
。我認為 CLR可能會類似地處理struct
。)
我做了一個簡短的實驗,看看為用戶定義的值類型和引用類型生成的 CIL 有什么區別。 這是我的 C# 代碼:
struct SomeValueType { public int X; }
class SomeReferenceType { public int X; }
.
.
static void TryValueType(SomeValueType vt) { ... }
static void TryReferenceType(SomeReferenceType rt) { ... }
.
.
var vt = new SomeValueType { X = 1 };
var rt = new SomeReferenceType { X = 2 };
TryValueType(vt);
TryReferenceType(rt);
這是為最后四行代碼生成的 CIL:
.locals init
(
[0] valuetype SomeValueType vt,
[1] class SomeReferenceType rt,
[2] valuetype SomeValueType <>g__initLocal0, //
[3] class SomeReferenceType <>g__initLocal1, // why are these generated?
[4] valuetype SomeValueType CS$0$0000 //
)
L_0000: ldloca.s CS$0$0000
L_0002: initobj SomeValueType // no newobj required, instance already allocated
L_0008: ldloc.s CS$0$0000
L_000a: stloc.2
L_000b: ldloca.s <>g__initLocal0
L_000d: ldc.i4.1
L_000e: stfld int32 SomeValueType::X
L_0013: ldloc.2
L_0014: stloc.0
L_0015: newobj instance void SomeReferenceType::.ctor()
L_001a: stloc.3
L_001b: ldloc.3
L_001c: ldc.i4.2
L_001d: stfld int32 SomeReferenceType::X
L_0022: ldloc.3
L_0023: stloc.1
L_0024: ldloc.0
L_0025: call void Program::TryValueType(valuetype SomeValueType)
L_002a: ldloc.1
L_002b: call void Program::TryReferenceType(class SomeReferenceType)
我無法從這段代碼中弄清楚的是:
.locals
塊中提到的所有局部變量在哪里分配? 它們是如何分配的? 他們是如何被釋放的?
(題外話:為什么需要這么多匿名局部變量並來回復制,只是為了初始化我的兩個局部變量rt
和vt
?)
您接受的答案是錯誤的。
值類型和引用類型之間的區別主要在於賦值語義之一。 值類型在賦值時復制 - 對於結構,這意味着復制所有字段的內容。 引用類型只復制引用,而不是數據。 堆棧是一個實現細節。 CLI 規范沒有承諾對象的分配位置,依賴規范中沒有的行為是一個壞主意。
值類型的特征在於它們的值傳遞語義,但這並不意味着它們實際上被生成的機器代碼復制。
例如,一個對復數求平方的函數可以接受兩個浮點寄存器中的實部和虛部,並在兩個浮點寄存器中返回其結果。 代碼生成器優化了所有的復制。
有幾個人在下面的評論中解釋了為什么這個答案是錯誤的,但一些版主已將其全部刪除。
臨時對象(本地對象)將存在於第 0 代 GC 中。 GC 已經足夠聰明,可以在它們超出范圍時立即釋放它們。 您不需要為此切換到結構體實例。
這完全是胡說八道。 GC 只看到運行時可用的信息,此時所有范圍的概念都消失了。 GC 不會“一旦超出范圍”就收集任何東西。 GC 將在它變得無法訪問后的某個時間點收集它。
可變值類型已經有導致錯誤的傾向,因為很難理解何時對副本與原始進行變異。 但是在這些值類型上引入引用屬性,就像流暢接口的情況一樣,將是一團糟,因為看起來結構的某些部分正在被復制,而其他部分則沒有(即參考屬性)。 我不能強烈反對這種做法,從長遠來看,它很可能導致各種維護問題。
再一次,這完全是胡說八道。 在值類型中包含引用並沒有錯。
現在,回答你的問題:
是否所有 CLR 值類型,包括用戶定義的結構,都只存在於計算堆棧中,這意味着它們永遠不需要被垃圾收集器回收,或者是否存在垃圾收集的情況?
值類型當然不會“只存在於評估堆棧中”。 首選是將它們存儲在寄存器中。 如有必要,它們將溢出到堆棧中。 有時它們甚至被裝在堆上。
例如,如果您編寫一個循環數組元素的函數,那么int
循環變量(值類型)很有可能完全存在於寄存器中,並且永遠不會溢出到堆棧或寫入堆. 這就是 Eric Lippert(來自 Microsoft C# 團隊,他自己寫的關於 .NET GC 的“我不知道所有細節” )的意思,當他寫道,當“抖動選擇不注冊值” 。 對於較大的值類型(如System.Numerics.Complex
)也是如此,但較大的值類型不適合寄存器的可能性更高。
值類型不在堆棧中的另一個重要示例是當您使用包含值類型元素的數組時。 特別是, .NET Dictionary
集合使用結構數組,以便在內存中連續存儲每個條目的鍵、值和散列。 這極大地提高了內存局部性、緩存效率,從而提高了性能。 值類型(和具體化泛型)是 .NET 在這個哈希表基准測試中比 Java 快 17 倍的原因。
我做了一個簡短的實驗,看看生成的 CIL 有什么不同......
CIL 是一種高級中間語言,因此不會為您提供有關寄存器分配和溢出到堆棧的任何信息,甚至不會為您提供裝箱的准確圖片。 但是,查看 CIL 可以讓您了解前端 C# 或 F# 編譯器如何將某些值類型裝箱,因為它將更高級別的構造(如異步和推導式)轉換為 CIL。
有關垃圾收集的更多信息,我強烈推薦The Garbage Collection Handbook和The Memory Management Reference 。 如果您想深入了解 VM 中值類型的內部實現,那么我建議您閱讀我自己的HLVM 項目的源代碼。 在 HLVM 中,元組是值類型,您可以看到生成的匯編器以及它如何使用 LLVM 盡可能將值類型的字段保存在寄存器中,並優化掉不必要的復制,僅在必要時溢出到堆棧。
請考慮以下事項:
值類型和引用類型之間的區別主要在於賦值語義之一。 值類型在賦值時復制 - 對於struct
,這意味着復制所有字段的內容。 引用類型只復制引用,而不是數據。 堆棧是一個實現細節。 CLI 規范沒有承諾對象的分配位置,依賴規范中沒有的行為通常是一個危險的想法。
臨時對象(本地對象)將存在於第 0 代 GC 中。 GC 已經足夠聰明,可以在它們超出范圍時(幾乎)立即釋放它們 - 或者在實際上最有效的時候釋放它們。 Gen0 運行得足夠頻繁,您無需切換到struct
實例來有效管理臨時對象。
可變值類型已經有導致錯誤的傾向,因為很難理解何時對副本與原始進行變異。 正是出於這個原因, 許多語言設計者自己建議盡可能使值類型不可變,並且該指南得到了本網站上許多頂級貢獻者的回應。
Introducing *reference properties* on those value types, as would be the case with a fluent interface, further violates the [Principle of Least Surprise][3] by creating inconsistent semantics. The expectation for value types is that they are copied, *in their entirety*, on assignment, but when reference types are included among their properties, you will actually only be getting a shallow copy. In the worst case you have a mutable struct containing *mutable* reference types, and the consumer of such an object will likely erroneously assume that one instance can be mutated without affecting the other.
There are always exceptions - [some of them in the framework itself][4] - but as a general rule of thumb, I would not recommend writing "optimized" code that (a) depends on private implementation details and (b) that you know will be difficult to maintain, *unless* you (a) have full control over the execution environment and (b) have actually profiled your code and verified that the optimization would make a significant difference in latency or throughput.
g_initLocal0
和相關字段在那里是因為您使用了對象初始值設定項。 切換到參數化構造函數,你會看到它們消失了。值類型通常在堆棧上分配,引用類型通常在堆上分配,但這實際上不是 .NET 規范的一部分,也不能保證(在第一篇鏈接文章中,Eric 甚至指出了一些明顯的例外)。
更重要的是,它只是不正確的假設,該堆疊在一般價格比堆自動意味着使用堆棧語義的任何程序或算法會比GC-托管堆運行速度更快或更有效。 還有一些論文寫在這個主題,這是完全可能的,往往容易為GC堆跑贏堆棧分配了大量的對象,因為現代GC的實現實際上是對不需要對象的數量更敏感釋放(與完全固定到堆棧上的對象數量的堆棧實現相反)。
換句話說,如果你已經分配的臨時對象十萬或數百萬-即使你對值類型假定有棧語義在特定的環境特定的平台上也是如此-利用它仍然可以讓你的程序慢!
因此,我將回到我最初的建議:讓 GC 完成它的工作,不要假設您的實現在所有可能的執行條件下沒有完整的性能分析就可以勝過它。 如果您從干凈、可維護的代碼開始,您可以隨時進行優化; 但是,如果您以可維護性為代價編寫了您認為是性能優化的代碼,但后來證明您的性能假設是錯誤的,那么您的項目在維護開銷、缺陷計數等方面的成本將高得多。
這是一個 JIT 編譯器實現細節,它將分配 .locals。 現在,我不知道任何不在堆棧框架上分配它們的東西。 它們通過調整堆棧指針來“分配”,並通過將其重置回“釋放”。 非常快,很難改進。 但誰知道呢,從現在起 20 年后,我們可能都會運行帶有 CPU 內核的機器,這些內核經過優化,只運行具有完全不同內部實現的托管代碼。 可能有大量寄存器的核心,JIT 優化器現在已經使用寄存器來存儲局部變量。
臨時變量由 C# 編譯器發出,以在對象初始值設定項拋出異常時提供一些最低一致性保證。 它可以防止您的代碼在 catch 或 finally 塊中看到部分初始化的對象。 也用於 using 和 lock 語句,如果您在代碼中替換對象引用,它可以防止錯誤的對象被釋放或解鎖。
結構體是值類型,用於局部變量時分配在堆棧上。 但是,如果將局部變量強制轉換為Object
或接口,則該值會被裝箱並在堆上分配。
結果,結構在超出范圍后被釋放,除了它們被裝箱並移動到堆之后,垃圾收集器負責在不再有對對象的任何引用時釋放它們。
我不確定所有編譯器生成局部變量的原因,但我認為使用它們是因為您使用了對象初始值設定項。 對象首先使用編譯器生成的局部變量進行初始化,並且只有在復制到局部變量的對象初始值設定項完成后才進行初始化。 這確保您永遠不會看到僅執行了一些對象初始值設定項的實例。
聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.