C#筆記 – 類型基礎(二) 類型、對象在運行時的相互關係
所有類型都從System.Object派生
-
「運行時」要求每個類型最終都從System.Object派生
- 因此,每個類型的每個對象都保証了一組最基本的,來自System.Object的行為
-
System.Object方法 訪問權限 說明 Equals public 兩個對象具有相同值就返回true GetHashCode public 返回對象的值的哈希碼。如果對象要在哈希表集合(如Dictionary)中作為鍵使用,類型就應重寫該方法並為對象提供良好分布 ToString public 返回類型的完整名稱(this.GetType().FullName),但經常被重寫為顯示對象各字段的值的字符串 GetType public 返回從Type派生的一個類型的實例,指出調用GetType的對象的類型。返回的Type對象可以和反射類結合獲得與對象類型相關的元數據信息。GetType是非虛方法,無法重寫,目的是防止類型因為方法被重寫後所隱瞞,進而破壞類型安全 MemberwiseClone protected 非虛方法,創建類型的新實例,並將新對象的實例字段設與this對象的實例字段完全一致。返回對新實例的引用。 Finalize protected GC判斷對象為垃圾後,到對象被實際回收之前會調用該虛方法。回收內存前如果有清理工作要做的話,應重寫該方法。
-
CLR要求所有對象用new操作符創建
-
Employee e = new Employee("John"); -
new具體的工作流程:
- 計算類型及其所有基類(到System.Object為止)中定義的所有實例字段需要的字節數。
- 每個對象都需要額外的成員,包括「類型對象指針」(type object pointer)和「同步塊索引」(sync block index)。CLR利用這些成員管理對象,這些額外成員的字節數要計入對象大小
- 從托管堆中分配類型要求的字節數,從而分配對象的內存,分配的所有字節都設為0
- 初始化對象的「類型對象指針」和「同步塊索引」成員
- 調用類型的構造器,傳遞在調用new時指定的實參(如上例的”John”)。
- 編譯器一般會自動生成代碼來調用基類構造器,每個類的構造器初始化該類型的實例字段,最終調用System.Object的構造器,該構造器會簡單地返回。
- 返回指向新建對象一個引用,把引用保存到變量中(如上例的e)
- 計算類型及其所有基類(到System.Object為止)中定義的所有實例字段需要的字節數。
-
沒有與new對應的delete操作符,因此不能顯式釋放對象的內存。
- 而是CLR會自動檢測沒有被引用的對象,並在特定時候自動釋放內存
-
運行時,類型、對象、線程棧和托管堆相互關係
- 程序運行時,會在一個Windows進程中加載CLR
- 該進程可能有多個線程
- 線程創建時會分配到1MB的棧
- 棧空間用於向方法傳遞實參,以及保存方法內部定義的局部變量
- 棧從高位內存地址向低位內存地址構建
- 方法包含兩種代碼:
- 「序幕」(Prologue)代碼 => 在方法開始做工作前對其進行初始化
- 「尾聲」(Epilogue)代碼 => 在方法做完工作後對其進行清理,以便返回調用者
- 例子1-方法的執行與線程棧的變化:
-
假設以下代碼需要執行:
-
public class Program { public static void Main() { M1(); } void M1() { string name = "Joe"; M2(name); return; } void M2(string s) { int length = s.Length; int tally; ... return; } }
-
-
其執行模型如下圖,約四步:
-

-
-
- 首先,M1開始執行時,Prologue代碼在線程棧上分配其方法內部定義的局部變量的內存
- 然後,M1調用M2方法,將局部變量作為參數傳遞。使局部變量的地址入棧,並被M2使用參數變量s標識棧位置
- M2方法接收參數並將參數入棧後,同時把「返回地址」入棧,M2執行完畢後,CPU指針指向該位置
- 隨後,M2方法開始執行,M2的Prologue代碼在線程棧上分配其定義的局部變量內存
- 最後,當M2方法執行完畢且抵達return語句後,使CPU的指令指針被設置成棧中的返回地址,M2的棧幀展開(unwind),M1繼續執行調用M2之後的代碼
- 棧幀:當前線程的調用棧中的一個方法調用。執行線程的過程中,進行的每個方法調用都會在調用棧中創建並壓入一個StackFrame(棧幀)
- 展開:調用方法時壓入棧幀(wind);方法執行完畢並彈出棧幀(unwind)
- 然後,M1調用M2方法,將局部變量作為參數傳遞。使局部變量的地址入棧,並被M2使用參數變量s標識棧位置
- 首先,M1開始執行時,Prologue代碼在線程棧上分配其方法內部定義的局部變量的內存
-
- 例子2:方法執行時,線程棧與托管堆的交互過程
-
假設以下代碼要執行
-
class Program { static void Main(string[] args) { M3(); } static void M3() { Employee e; int year; e = new Manager(); e = Employee.Lookup("Joe"); year = e.GetYearEmployed(); e.GetProgressReport(); } } public class Employee { public int GetYearEmployed() { ... } public virtual string GetProgressReport() { ... } public static Employee Lookup(string name){ ... } } public class Manager : Employee { public override string GetProgressReport(){ ... } }
-
-
具體執行過程
- 啟動Windows進程,加載CLR至其中,初始化托管堆,創建一個線程以及其1MB棧空間。線程已執行了一些代碼,即將執行M3方法
-

-
-
- 在M3方法執行之前,CLR會檢測到M3中引用的所有類型,因此,CLR會為這些類型分配內部數據結構來管理對引用類型的訪問。 - M3內部引用了Employee, int, string, Manager(假設int和string已經分配了對應的數據結構,不顯示於下圖)。利用程序集的元數據,CLR提取與這些類型相關的信息,創建對應的內部數據結構來表示類型本身。 - 這些類型對象包含以下內容: - 類型對象指針 - 同步塊索引 - 靜態字段 - 為這些字段提供支援的字節在類型對象自身中分配 - 方法表 - 類型定義的每個方法都有一個記錄項,每個記錄項含有一個地址,根據該地址可以找到方法的IL實現 - 結構初始化時,CLR將每個記錄項指向包含在CLR內部的一個未編檔函數(JIT Compiler) - 方法被首次調用時,JIT Compiler會被調用,該函數負責把對應的IL編譯成CPU指令,並保存下來,下次再調用記錄項的方法時,就直接執行保存下來的CPU指令
-

-
-
- 當CLR確認所有類型對象創建完畢,M3代碼完成編譯後,線程就開始執行M3的本機代碼。M3的Prologue代碼先為局部變量分配內存。 - 雖然CLR會把局部變量初始化為null或0,但是代碼試圖訪問未顯式初始化的局部變量,C#會報錯
-

-
-
- 然後,M3執行代碼「e = new Manager();」,構造了一個Manager對象,在托管堆中生成了一個實例。 - 該對象包含了: - 類型對象指針 - 在堆上新建對象時,CLR會自動初始化該指針,引用與該對象對應的「類型對象」(Manager類型對象) - 同步塊索引 - 在調用類型的構造器之前,CLR會初始化該索引,將對象的所有實例字段設置0或null - 實例字段 - Manager自己定義的 - Manager的所有基類定義的 - new操作符返回Manager對象的內存地址,並保存到變量e中
-

-
-
- 隨後,M3調用「e = Employee.Lookup(“Joe”);」。這是對Employee的靜態方法Lookup的調用。 - 調用靜態方法時,CLR會定位與定義靜態方法的類型對應的類型對象,然後從該類型對象的方法表中查找與被調用方法對應的記錄項。 - 如果該方法是首次被調用,則執行JIT編譯,並把編譯出來的本機代碼進行保存和調用;如果不是首次調用,就直接調用事先保存好的本機代碼 - 在本例中,假設Lookup(“Joe”)返回的是一個Manager對象。此舉導致堆上構造一個新的Manager對象,並用Joe去初始化它,返回該對象的地址並保存到局部變量e中 - 第一個Manager此時不再被任何變量所引用,成為了GC的目標。在GC執行的時候會被自動回收。
-

-
-
- 下一步是「year = e.GetYearsEmployed」,GetYearsEmployed是一個非虛實例方法。調用非虛實例方法時,JIT編譯器會找到「調用方法的那個變量(e)的聲明類型(Employee)對應的類型對象(Employee類型對象)」 - 雖然此時的e引用指向的是Manager,但是其所定義的類型仍然是Employee - 如果類型對象中沒有嘗試調用的方法,由於每個類型對象都有一個字段引用了它們的基類型,因此JIT編譯器會回溯類層次結構,一直回溯到Object為止,在沿途每個類型中查找目標方法 - 查找到目標方法後,同樣的,對方法進行JIT編譯(如果是首次調用),並調用JIT編譯好的本機代碼
-

-
-
- 最後,M3調用虛實例方法「GetProgressReport」。調用虛實例方法時,JIT編譯器會在方法中生成一些額外代碼,並在每次調用時執行 - 這些代碼首先檢查發出調用的變量(e),並找到該變量所「引用的地址上對象」的「類型對象指針引用的實際類型」(不同於上面的非虛實例方法,只找到變量的聲明類型,而是直接找到變量引用的「實際類型」)。 - 在本例中,e的實際類型為Manager。JIT就檢查Manager類型對象中的方法表中對應的記錄項。然後JIT編譯(如是首次調用),調用其編譯後的本機代碼。 - 由於目前e引用的是Manager對象,因此調用Manager裡的GetProgressReport;如果引用的是Employee對象,則調用Employee的GetProgressReport
-

-
- 另外,不難發現,「Manager對象」的類型對象指針指向「Manager類型對象」,而「Manager和Employee類型對象」也有他們的自己的類型對象指針。 - 因為「類型對象」本質上也是對象,CLR創建類型對象時,會自動初始化該指針,引用與該對象對應的「類型對象」。 - CLR開始在一個進程運行時,會立即為MSCorLib.dll中的System.Type類型創建一個特殊類型對象,「Manager類型對象」和「Employee類型對象」都是「System.Type類型的實例」 - 因此,Manager和Employee類型對象的類型對象指針引用會在初始化時指向System.Type類型對象 - 另一方法,System.Type類型對象本身也是對象,內部也有「類型對象指針」。但System.Type的這個指針指向自身——因為System.Type類型對象本身是一個「類型對象的實例」

參考書目
- 《CLR via C#》(第4版) Jeffrey Richter