C#筆記 – 類型基礎(二) 類型、對象在運行時的相互關係

所有類型都從System.Object派生
  • 「運行時」要求每個類型最終都從System.Object派生

    • 因此,每個類型的每個對象都保証了一組最基本的,來自System.Object的行為

    • System.Object方法訪問權限說明
      Equalspublic兩個對象具有相同值就返回true
      GetHashCodepublic返回對象的值的哈希碼。如果對象要在哈希表集合(如Dictionary)中作為鍵使用,類型就應重寫該方法並為對象提供良好分布
      ToStringpublic返回類型的完整名稱(this.GetType().FullName),但經常被重寫為顯示對象各字段的值的字符串
      GetTypepublic返回從Type派生的一個類型的實例,指出調用GetType的對象的類型。返回的Type對象可以和反射類結合獲得與對象類型相關的元數據信息。GetType是非虛方法,無法重寫,目的是防止類型因為方法被重寫後所隱瞞,進而破壞類型安全
      MemberwiseCloneprotected非虛方法,創建類型的新實例,並將新對象的實例字段設與this對象的實例字段完全一致。返回對新實例的引用。
      FinalizeprotectedGC判斷對象為垃圾後,到對象被實際回收之前會調用該虛方法。回收內存前如果有清理工作要做的話,應重寫該方法。
  • CLR要求所有對象用new操作符創建

    • Employee e = new Employee("John");
    • new具體的工作流程:

      1. 計算類型及其所有基類(到System.Object為止)中定義的所有實例字段需要的字節數。

        • 每個對象都需要額外的成員,包括「類型對象指針」(type object pointer)和「同步塊索引」(sync block index)。CLR利用這些成員管理對象,這些額外成員的字節數要計入對象大小

      2. 從托管堆中分配類型要求的字節數,從而分配對象的內存,分配的所有字節都設為0

      3. 初始化對象的「類型對象指針」和「同步塊索引」成員

      4. 調用類型的構造器,傳遞在調用new時指定的實參(如上例的”John”)。

        • 編譯器一般會自動生成代碼來調用基類構造器,每個類的構造器初始化該類型的實例字段,最終調用System.Object的構造器,該構造器會簡單地返回。

      5. 返回指向新建對象一個引用,把引用保存到變量中(如上例的e)

    • 沒有與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)

  • 例子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