文章目錄
.NET中的類型分為實值型別和參考型別,他們在記憶體布局,分配,相等性,賦值,儲存以及一些其他的特性上有很多不同,這些不同將會直接影響到我們應用程式的效率。本文視圖對.NET 基礎類型中的實值型別和參考型別在記憶體中的布局,方法的調用,實值型別如何?介面,以及其他一些細節問題進行一些簡要的討論,文章主要參考《Pro .NET Performance》 和 《Advanced .NET Debugging》 ,希望給大家一點兒協助。
一 簡單例子
舉一個簡單的例子,我們有一個名為Point2D的對象,用來表示二維空間中的座標,每一個座標值x,y都用一個short類型表示,整個對象佔4個位元組。現在假設我們需要在記憶體中儲存1000萬個這樣的座標點集合對象。那麼他們會佔用多大記憶體呢?這個問題的答案其實在很大程度上依賴Point2D是實值型別還是參考型別。如果他是參考型別,1000萬個這樣的點的集合實際上是儲存的對這1000萬個點的引用。在一個32位的系統上,光儲存對這十萬個點的引用就要佔用將近40MB的記憶體。對象本身也要佔用同樣大小的記憶體。實際上,如果我們較真的話,每一個Point2D執行個體對象要佔用12個位元組(同步塊索引,物件類型指標,實體),使得要儲存10萬個這樣的對象需要將近160MB的記憶體。但是,如果Point2D是一個實值型別,1000萬個這樣的點的集合就儲存的是1000萬個對象的執行個體,沒有浪費一個位元組的記憶體空間,總共只需要佔用40MB的記憶體。這比使用參考型別將近少了四分之一,記憶體的密度在某些情況下使得我們偏愛使用實值型別。
儲存實際的點資料值比儲存引用還有一個好處,那就是如果你想遍曆一個儲存了很多該類型的對象,編譯器和硬體能夠很容易的遍曆實值型別對象,因為他們在記憶體中是連續分配的,而參考型別則不同,在託管堆(heap)上的對象不一定在記憶體中是連續分配的。對於實值型別集合對象的話,CPU的緩衝機制可以對連續對象進行更快的讀取。
所以理解實值型別和參考型別在記憶體中的布局,以及他們的區別對於應用程式的效能至關重要。下面首先在語言特性的層面上看看實值型別和參考型別的區別,然後我們再看看實值型別和參考型別的內部細節。
二 實值型別和參考型別在語義上的區別
.NET 中,參考型別包括:類、介面、委託、以及集合類型。String類型在.Net中是一個很特殊的類型,他也是參考型別。實值型別包括結構、枚舉、以及一些基本類型,如int,float,decimal,我們可以使用struct結構來定義我們自己的實值型別。
在語言層面上,參考型別具有引用語義,就是我們首先考慮的是對象的唯一標識,而不是其包含的內容,而實值型別則具有值語義,對象沒有唯一標識,不通過引用訪問對象,我們對對象的處理是通過其包含的內容實現的,這些不同影響體現在.NET語言的不同方面。
區別地方 |
參考型別 |
實值型別 |
傳參 |
傳遞引用,方法內對該對象的更改會更改所以的其他對象 |
對象的內容被拷貝為一個副本傳遞到方法內部,除非是有(ref或者out關鍵字),對該參數的更改不會影響到方法體外的改對象 |
複製 |
拷貝引用,兩個對象儲存了對同一個對象的引用 |
拷貝對象內容,兩個對象具有相同的內容,兩者之間沒有關係 |
比較 |
對引用進行比較,如果引用相等,那麼這兩個對象相等 |
按儲存的內容進行比較,如果兩個對象的所有欄位都相等,那麼這兩個對象相等 |
這些語義上的不同直接影線到.NET中我們寫代碼的方式。但是這些不同僅僅是實值型別和參考型別傳達不同用途的表現。下面首先來看看他們在記憶體中的布局,分配和銷毀。
三 實值型別和參考型別的儲存,分配和銷毀
參考型別在託管堆(manage heap)上分配,託管堆也是.NET記憶體回收行程的工作區域。在託管堆上分配一個對象涉及到遞增指標,這個操作很容易。在多處理器的作業系統上,如果多個處理器同時訪問託管堆上的同一個對象,那麼就需要一些同步機制,但即使這樣,相對於在非託管的環境下比如使用malloc,在託管堆上分配一塊記憶體還是非常廉價的。
記憶體回收行程以一種非確定性方式進行記憶體回收,一次完整的記憶體回收代價非常高。但是記憶體回收的平均花費和同樣的非託管環境下的記憶體管理相比,耗費還是很小的。
準確來講,有些參考型別也可以線上程堆棧(stack)上分配的。一些基礎類型的集合類型,比如Int集合可以在unsafe環境下使用stackalloc關鍵字線上程堆棧上分配,或者使用一個自訂的struct類型,在裡面使用fixed關鍵字嵌入一個固定長度的集合。使用stackalloc和fixed關鍵字建立的集合類型並不是真正意義上的數群組類型。他和在託管堆上分配的標準的集合類型在記憶體布局上是有差別的。
單純的實值型別通常在當前執行線程的線程堆棧(stack)上分配的。但是實值型別通常可以嵌入到參考型別中,在這種情況下,實值型別在託管堆上分配,他能夠進行裝箱,將其儲存的值轉移到託管堆上。線上程堆棧上分配一個實值型別也是一個非常容易的操作,只需要修改一下棧指標寄存器(x86 ESP),並且在同時一次性分配多個對象時也有很大優勢。實際上,方法的”開場白”代碼一般會使用一條CPU指令來為方法的所有的局部變數在棧上分配儲存空間。
回收棧上的記憶體空間也非常高效,只需要修改一下棧指標的寄存器即可。由於方法編譯為機器碼的方式不同,通常編譯器不需要最終統計方法中本地變數所佔的大小,而是直接刪除整個棧幀,這是通過標準的一系列三個指令來完成的,通常稱之為 “收場白”代碼。
在C#或者其他託管類型語言中,new關鍵字不僅用在在託管堆上建立對象。也可以使用new關鍵字在棧上為實值型別分配空間。比如下面DateTime newYear=new DateTime(2011,12,31)。就是使用new關鍵字為實值型別在棧上分配空間。
託管堆和線程堆棧的區別:
和一般大家認為的不同,.NET線程中的線程堆棧和託管堆並沒有太大的區別。棧和託管堆都是在虛擬記憶體中的一系列地址空間而已。某一個線程上的堆棧的地址空間並不一定比託管堆上的更有優勢。訪問託管堆上的記憶體位址也不一定比訪問棧上的地址空間慢或者快。在一些特定情況下,考慮到下面幾種情況,訪問棧上的地址空間總體來說比訪問託管堆上的地址空間要快。
- 在堆棧上,地址具有時間局部性,也就是說,同一次分配的對象在地址空間上很可能是連續分布的,也就是說有空間的局部性。同樣,時間分配的局部性意味著時間訪問也具有局部性,就是說同一時間分配的對象可能同一時間會被訪問到。連續的堆棧儲存能夠充分利用CPU緩衝和作業系統的分頁系統從而具有更好的效能。
- 因為參考型別具有額外的一些儲存比如類型對象指標,同步塊索引等,所以實值型別在堆棧上的記憶體配置密度可能會比託管堆上的要大。更高的記憶體配置密度意味著更高的效能,比如更多的多想能夠適應CPU緩衝的大小。
- 線程堆棧可能相當小,Windows上的預設最大線程堆棧的大小為1MB,大多說的線程通常只用了一點線程堆棧。在現代作業系統上,應用程式線程的堆棧能夠適應CPU緩衝的大小,使得對堆棧上對象的訪問非常快。相反託管堆上的對象通常很少能夠適應CPU緩衝的大小。
但是並不意味這我們應該將所有的對象分配都放到線程堆棧上。Windows上的線程堆棧是有限制的,通常一些不正確的遞迴或者比較大的堆棧分配操作就會耗盡線程堆棧空間。
在簡單的討論了實值型別和參考型別之後,我們再來看看他們的實現細節,這些細節也解釋了實值型別和參考型別在記憶體中的分配密度的極大不同。
四 參考型別的內部實現
我們先從參考型別開始,參考型別的類存布局比較複雜,其布局在很大程度上會影響運行時效率。為了方便討論,先建一個簡單的Employee參考型別,他有幾個欄位,以及一些方法。
public class Employee{ private int id; private string name; private static CompanyPolicy policy; public virtual void Work() { Console.WriteLine("Zzzz..."); } public void TakeVacation(int days) { if (policy.CanTakeVacation(this)) Console.WriteLine("Zzzz..."); } public static void SetCompanyPolicy(CompanyPolicy newPolicy) { policy = newPolicy; }}
現在,我們在託管堆上建立了一個該對象的執行個體。下面描述了在32位.NET進程中該執行個體對象的布局。
該對象的兩個欄位_id和_name在記憶體中布局的前後順序是不確定的(雖然這個可以使用StructLayout屬性來進行控制)。對象在記憶體中的儲存的開始的4個位元組的稱之為對象頭位元組(object header word)也叫同步塊索引 (sync block index),緊接著是另外的稱之為方法表指標(method table pointer也叫類型對象指標)的四個位元組。這些欄位我們在.NET中是不可以直接存取的,它是為JIT和CLR本身服務的。對象的引用,在對象內部其實就是一個記憶體位址,該地址指向的是方法表指標的開始處,因此對象頭位元組是從對象地址向前位移了四個位元組處開始的。
在32位機器上,託管堆上的對象在記憶體中是對齊到最近的4個位元組的. 這就意味著一個對象中即使只有一個位元組的byte類型的欄位,由於記憶體對齊,仍然在託管堆上會佔用12個位元組,即使該類沒有任何執行個體欄位,在執行個體化時仍然會佔用12個位元組。但是在64為的系統上,情況則有所不同。首先,對象的方法表指標欄位在記憶體中會佔用8個位元組,對象頭位元組會佔用8個位元組。對象在託管堆上會對齊到最近的8個位元組。
方法表
方法表指標指向CLR內部的一個名為方法表(MT)的資料結構,該指標最終指向另外一個稱之為EEClass(Execution Engine執行引擎)的內部結構。方法表和EEClass包含了為調用虛方法,介面方法,訪問靜態變數,確定運行時對象的類型以及有效訪問基類中對應方法,以及其他目的提供了一些有用的資訊。方法表包含最頻繁訪問的一些資訊,這些資訊在一些關鍵的機制中如虛方法的調用中至關重要。而EEClass則包含一些較少訪問的資訊,但是在一些運行機制如反射中會用到。這些資料結構的內容我們可以使用SOS命令列中的!DumpMT以及DumpClass擷取。需要注意的是,我們下面討論的可能在不同的CLR版本中有所不同。
對象的靜態欄位的位置資訊是包含在EEClass中的。一些基礎類型(primitive field)欄位動態線上程的啟動堆上儲存和分配,而使用者自訂的實值型別以及參考型別通過間接引用堆上的位置(通過AppDomain全域對象數組)來儲存。要訪問靜態欄位,我們不需要存取方法表或者EEClass,JIT編譯器會將這些靜態欄位的地址寫入程式碼到產生的機器碼中。對靜態欄位的數組引用的地址也是固定下來的,因此,他們的地址在記憶體回收的整個過程中不會發生變化。這些基礎靜態欄位駐留在方法表中的,記憶體回收行程接觸不到。這就保證了,可以通過硬式編碼地址直接存取這些欄位。
方法表中,最明顯的就是他包含一個地址數組,每一個地址對應一個類型方法,包括任何一些從基類繼承的虛方法、例如,下面展示了Employee類方法表中可能的布局,假定這些方法是直接繼承自System.Object對象。
和C++中虛函數指標表不同,CLR的方法表包含包括非虛方法的所有方法的代碼地址。方法表中方法的順序並沒有規定。一般滴,排列順序依次是,繼承的虛方法(包括重寫的虛方法),新引入的虛方法,非虛執行個體方法,以及靜態方法。
一個真正的方法表包含了包括前面討論到的更多的資訊。理解這些額外的欄位對於理解方法調用的細節直觀很重要。這就是為什麼我們花了很長時間來查看Employee執行個體的方法表結構。這裡我們假定Employee類實現了三個介面:IComparable,IDisposable和ICloneable介面。
中,在我們前面理解的方法表布局中又多了一些其他的內容。首先,在方法表的頭部包含了一些雜項(Miscellanence),比如虛方法的個數,類型實現的介面的個數等等,其次,方法表包含一個指向其父類型方法表的指標,一個指向其模組的指標以及一個指向其EEClass的指標(他包含了一個對方法表的後向引用),再次,類型自身的方法位於一系列類型實現的介面方法表之前。這就是為什麼在方法表中有一個指向方法列表的指標,該指標針位於方法表開始位置的位移40個位元組的地方。
擷取類型方法的在方法表中的地址可能還需要其他一些額外的步驟,因為類型的方法表和對象的方法表可能分開儲存在不同的記憶體位址中。比如,如果你查看System.Object的方法表,你會發現,方法的代碼地址是儲存在另外一個地方的。更進一步,有許多虛方法的類將會有很多第一層級的表指標,使得在衍生類別中可以複用一部分方法表。
調用參考型別執行個體對象的方法
很明顯,方法表可以用來調用執行個體對象的方法。假設在記憶體棧(stack)的EBP-64位置包含Employee對象的地址,該對象的方法表布局和前面的圖類似。可以使用下面的指令序列來調用名為Word的虛方法。
mov ecx, dword ptr [ebp-64]mov eax, dword ptr [ecx] ; 方法表指標mov eax, dword ptr [eax+40] ; 方法表中,該方法的實際位置call dword ptr [eax+16] ;
第一條指令將棧上的引用拷貝到ECX寄存器中,第二條指令使用eax來儲存對象的方法表指標。第三條指令擷取方法表起始地址(有一個40位元組的位移),第四條指令擷取內部方法表在起始地址處的16個位元組出的位移,然後擷取到了Work方法的地址,並調用Work方法。為了理解為什麼調用虛方法需要藉助方法表,我們需要瞭解運行時的方法綁定是如何工作的。比如說,多態是如何通過虛方法來實現的。
假設我們有一個繼承自Employee的名為Manager的類,然後該類實現了另一個名為ISerializable的介面:
public class Manager : Employee, ISerializable{ private List<Employee> _reports; public override void Work() ... //...implementation of ISerializable omitted for brevity}
編譯器可能需要通過對Employee靜態類型的引用,調用Manager.Work方法,如下:
Manager employee = new Manager(...);employee.Work();
在這種特殊的情況下,編譯器可能需要使用靜態串流分析(static flow analysis)方法來推斷需要調用Manager的Work方法(但是在當前C#和CLR還不會調用Manager的Work方法)。在一般情況下,當使用Employee靜態類型引用時,編譯器需要在運行時綁定。事實上,唯一的能正確Binder 方法的辦法是,在運行時,判斷employee對象實際引用的類型,然後基於類型資訊來調用虛方法。這就是方法表協助JIT編譯器所做的工作。
如所示,Manager對象的方法表布局中的Work方法槽中覆寫了一個和Employee不同的代碼地址,方法的調用順序仍然保持一致。注意到被覆寫的槽距離方法表開始的位移和之前的不同,但是,方法表的指標欄位的位移仍然是相同的。
調用非虛方法
我們也可以使用相似的調用順序類調用非虛方法。但是,對於非虛方法,我們並不需要使用方法表來進行調用:需要調用的方法的代碼地址在JIT編譯該方法時已經確定下來了。
實體物件在調用非虛方法時,會對自身是否為空白進行檢驗。如果查看Employee的Work方法調用,可以看到
mov edx, 5 ; parameter passing through register – custom calling conventionmov ecx, dword ptr [ebp-64] ; still required because ECX contains ‘this’ by conventioncmp ecx, dword ptr [ecx]call dword ptr [0x004a1260]
使用CMP指令來用第一個運算元減去第二個運算元,並且將計算結果設定為CPU的標識位。上面的代碼並沒有使用比較兩者的結果,並將其儲存在CPU標識位中。因此,如何使用CMP指令來協助我們避免調用null對象的執行個體方法呢? CMP指令會試圖訪問ECX寄存器上的記憶體位址,上面儲存有對象的引用,如果對象的引用為null,那麼這種訪問就會產生非法訪問,因為方位記憶體位址為0的地方在Windows線程中總是非法的。在CLR中,這種非法訪問通常被轉換為在調用點上拋出NullReferenceException類型異常;這種方式比在方法調用時,在方法體中產生檢查是否為null指令要好。更進一步,CMP指令在記憶體中只佔用2個位元組,它能夠檢查無效訪問,而不僅僅是檢查是否為null。
Note:在調用虛方法是,就不必要產生類似的CMP指令了。非空檢查已經被隱式執行了,因為標準的虛方法調用流程會存取方法表指標,這就保證了該方法表指標是有效。即使是在虛方法調用時,也不總是能夠看到編譯器產生的CMP指令。在最近版本的CLR中,JIT編譯器足夠聰明來避免不必要的重複的檢查。比如,如果程式流從虛方法調用中返回一個對象,那麼就已經包含了非空檢查,所以JIT編譯器就不需要產生CMP指令了。
之所以如此關注非虛方法和虛方法的調用細節不僅是因為額外的記憶體訪問或者是額外的指令產生。虛方法的最大的問題在於它會阻止編譯器對方法進行內聯最佳化,方法內聯在現代高效能應用程式中至關重要。方法內聯是一個相對簡單的編譯技巧,他犧牲代碼的大小來提高執行速度,對於比較小的方法,會在調用的地方直接放置方法體。例如,下面代碼中,內聯調用會,直接調用一個Add操作指令。
int Add(int a, int b){ return a + b;}int c = Add(10, 12);
在沒有最佳化的指令中,上面的調用至少需要10條指令:三條指令用來設定參數和調用方法,兩個指令用來設定方法架構,一個指令用來將兩個整數加到一起,兩個指令用來銷毀方法,一個指令用來儲存方法的傳回值。採用內聯最佳化過的指令則只有一個操作指令。這個指令就是ADD指令,然而在一些編譯器中,常數展開技術可以在編譯時間計算一些操作指令的結果,然後將常量C設定為22。
使用內聯最佳化,和非內聯最佳化的代碼的執行效率會有很大差別,尤其是像上面這種比較簡單的方法體。例如,屬性,也非常適合進行內聯最佳化,對於編譯時間自動產生的屬性尤其如此,因為他們不需要包含一些處理邏輯,而是簡單的訪問欄位。但是,虛方法的調用會組織編譯器的內聯,因為內聯操作只有生在編譯時間編譯器知道所有對象的執行行為時才能產生(而虛方法需要在運行時才能判斷對象實際引用的類型)。在運行時,確定了所需的類型資訊及所需要調用的方法之後,將相關資訊嵌入到對象中,這就導致了編譯器沒有辦法為虛方法調用產生正確的內聯代碼。如果所有的方法和屬性預設都是虛方法,那麼調用這些虛方法由於無法進行內聯最佳化,將會產生很大的效能損失。
調用靜態方法和介面方法
為了討論的完整性,還有額外兩種類型的方法:靜態方法和介面方法。調用靜態方法相對簡單,編譯器並不需要載入對象的引用,直接調用方法(先行編譯塊pre-JIT STUB)就可以。因為對靜態方法的調用並不需要通過方法表,JIT編譯器為調用非虛的執行個體方法而採用了一些編譯技巧:通過一個特殊的記憶體位址,在JIT編譯完成之後會更新該地址,來實現方法的間接調用。
但是對於介面方法,有一套完全不同的機制,看起來,調用介面方法和調用虛方法所有不同。實際上,介面方法和經典的虛方法一樣,他能夠實現某種形式的多態。不幸的是,對於多個實現了相同介面的類,其在方法表中,並不能保證介面方法處於相同的槽中。看看下面的代碼,這兩個類都實現了IComparable介面。
class Manager : Employee, IComparable { public override void Work() ... public void TakeVacation(int days) ... public static void SetCompanyPolicy(...) ... public int CompareTo(object other) ...}class BigNumber : IComparable { public long Part1, Part2; public int CompareTo(object other) ...}
很顯然,上面兩個對象的方法表會有很大差別,CompareTo在方法表中的槽數也不相同。一些複雜的對象繼承和對介面實現會使得編譯器會產生額外的調用步驟來確定方法表中介面方法所在的位置。
在早期的CLR版本中,這些資訊在介面被首次載入時,將該介面的ID存放在一個全域(AppDomain)的表中。方法表有一個特殊的入口(在方法表起始位移量為12個位元組處),它指向全域介面表的合適位置,然後全域介面表的所有入口返回給方法表,然後對應的介面指向其介面方法指標的儲存位置。介面方法的調用需要多個步驟來實現,如下:
mov ecx, dword ptr [ebp-64] ; 引用對象mov eax, dword ptr [ecx] ; 方法表指標mov eax, dword ptr [eax+12] ; 介面表指標mov eax, dword ptr [eax+48] ; 介面表指標中的具體方法,位移call dword ptr [eax] ;第一個方法在EAX, 第二個方法在 EAX+4, 等等.
調用介面方法很複雜也很昂貴。以上代碼需要四次記憶體訪問來擷取介面方法的代碼地址並執行。對於一些介面,這種訪問頻率可能太高。然而JIT使用了一些技巧來有效地對介面方法進行了內聯。
熱徑分析 (hot-path analysis)當JIT探測到一些介面實現經常被調用時,他會使用最佳化好了代碼來替換特殊的調用地址,這樣能夠在介面實現中進行內聯。
頻率分析 (Frequency analysis) 當JIT探測到對一些調用上對熱徑的選擇不再準確時,他會使用新的熱徑來替換之前的猜測到的熱徑,然後再每次猜測錯誤時進行替換。
同步塊索引和lock關鍵字
所有參考型別執行個體對象的標頭檔中的第二個欄位就是對象頭指標,或者稱之為同步塊索引。和方法表指標不同,對象頭位元組有很多用處,包括同步、GC 、對象雜湊碼儲存等。對象頭位元組的最複雜的一個應用是同步,是用CLR的監視機制,通過lock關鍵字來實現的。常見情景如下:幾個線程相同時進入一個被lock關鍵字包圍的代碼,但是只有一個線程能夠進入代碼內,達到互斥的目的。
public class Counter{ private int _i; private object _syncObject = new object(); public int Increment() { lock (_syncObject) { return ++_i; } }}
為了保證互斥,同步機制可以與每個對象相關聯。因為為所有的對象都建立同步機制的話,太昂貴。這種綁定機制發生在需要的時候,當對象在第一次需要同步的時候綁定。當需要同步時,CLR會從同步塊索引表的全域數組中分配一個稱之為同步塊索引的結構。同步塊索引包含一個擁有它的對象的後向引用(雖然這種引用時一個弱引用,不能阻止對象被GC掉),在這些機制中,同步機制又稱之為監視機制,在內部使用Win32事件實現。大量分配的同步塊索引被儲存到對象的頭位元組中。進而使用這個對象來同步識別出存在的同步塊索引以及使用與之關聯的監視對象來實現同步。
對象的同步塊索引欄位僅僅儲存同步塊表中的索引,使得允許CLR在記憶體中改變和移動同步塊表而不用修改同步塊索引。當同步塊索引長時間不用時,記憶體回收行程將會對其進行回收,然後解除對象對其的引用,將對象的同步塊索引值賦予一個非法的索引。在回收之後,同步塊可以和其他對象進行結合,這樣就節省了大量的作業系統資源來實現同步機制。
五 結語
本文簡要分析了.NET中的參考型別的記憶體布局,方法調用,同步塊等的內部實現,相較於實值型別,這是由於參考型別的這些複雜的結構賦予了其更多的用處和功能,希望本篇文章對您理解參考型別有所協助。