← 筆記

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