← 筆記

C#筆記 – 托管堆與垃圾回收

  • 在面向對象的環境,每個類型都代表可供程序使用的「一種資源」
    • 要使用這些資源,必須為資源分配內存
    • 訪問一個資源有以下步驟
      • 調用IL指令newobj(C#的new指令),為資源分配內存
      • 初始化內存,調用實例構造器來設置資源的初始狀態
      • 訪問成員(使用資源)
      • 摧毀成員狀態,進行清理
      • GC釋放內存
分配資源基礎
  • CLR要求所有對象都要從托管堆分配

    • 進程初始化時,CLR划出一個地址空間區域作為托管堆
    • 同時維護一個指針(NextObjPrt):該指針指向下一個對象在堆中的分配位置
      • 該指針一開始指向托管堆的地址空間區域的基地址
    • 區域被非垃圾對象填滿後,CLR會分配更多的區域
      • 直至整個進程地址空間都被填滿
      • 應用程序的內存受進程的虛擬地址空間限制
        • 32位進程最多能分配1.5GB
        • 64位進程最多能分配8TB
  • C#的new操作符導致CLR執行以下步驟

    • 計算類型的字段的所需字節數
    • 加上開銷字段的所需字節數
      • 類型對象指針
      • 同步塊索引
      • 32位應用程序中,該兩個字段各需要8個字節
      • 64位應用程序中,該兩個字段各需要16個字節
    • CLR檢查托管堆空間是否有足夠空間
      • 如有,則在NextObjPtr指針指向的地址放入對象,為對象分配的字節會被清零

        • 調用類型的構造器,準備把對象引用返回至new操作符
        • 返回前,NextObjPtr指針的值會加上對象占用的字節數得到一個新值,作為下個對象放入托管堆時的地址
      • 如果沒有,則執行GC

        • 基本的GC算法
          • 引用跟蹤算法

            • 只關心引用類型的變量,只有這種變量能引用堆上的對象
              • 值類型變量直接包含值類型實例
              • 所有引用類型的變量都稱之為「根」
              • 靜態字段引用的對象一直存在,直到用於加載類型的AppDomain卸載為止
                • 濫用靜態字段/讓靜態字段引用的對象過於龐大容易導致內存泄漏
          • 步驟:

            • 開始階段:

              • 會暫停進程中的所有線程,防止線程在CLR檢查期間訪問對象並改變其狀態
            • 標記階段:

              • CLR遍歷堆中的所有對象,將同步塊索引中的位設為0,表明所有對象都應刪除
              • 檢查所有活動根,查看它們引用了哪些對象
                • 如果一個根包含null,CLR忽略這個根並繼續檢查下個根
                • 如果根引用了堆上的對象,CLR會標記這個對象,將其同步塊索引中的位設為1
                  • 然後CLR會繼續檢查對象中的根,標記這些根引用的對象
                  • 如果發現對象已被標記,就不重新檢查對象的字段
              • 檢查完畢後,堆中的對象要麼已標記,要麼未標記
                • 已標記的對象不能被GC,因為至少有一個根在引用它,可以通過該引用它的變量抵達(訪問)它,是「可達的」
                • 未標記的對象代表不存在可以抵達<(訪問)它的變量,是「不可達的」
                  • 一旦根離開作用域,對象就會變得「不可達」,GC接著就會回收其內存

                    class Program
                    {
                        static void Main(string[] args)
                        {
                            Timer t = new Timer(TimerCallback, null, 0, 2000);
                            Console.ReadLine();
                        }
                    
                        static void TimerCallback(Object o)
                        {
                            Console.WriteLine("In TimerCallback: " + DateTime.Now);
                            GC.Collect();
                        }
                    }  
                  • 如不用任何特殊編譯器開關編譯該代碼並運行,TimerCallback只會運行一次

                    • 因為GC發現在初始化後,Main方法再也沒有用過變量t,t會被回收並被停止觸發
                    • 除非在另外一個地方,顯式調用其Dispose方法,t才可以活到被釋放的一刻
            • 壓縮階段:

              • CLR壓縮所有幸存下來的對象,使它們占用連續的空間
                • 恢復引用的「局部性」,減少應用程序的工作集
                • 解決空間碎片化的問題

代(Generation)
  • CLR的GC是基於代的垃圾回收器,它假設
    • 對象越新,生存期越短
    • 對象越老,生存期越長
    • 回收堆的一部分的速度快於回收整個堆
運作邏輯
  • 初始化:
    • 托管堆在初始化時不包含對象,添加到堆的對象稱為第0代對象
      • 新構造的對象
      • 從未被GC檢查
    • CLR初始化時會為這些0代對象選擇一個預算容量,如果分配新對象時導致需求容量 > 預算容量,便啟動一次GC
  • 第一次GC:
    • 在首次GC後,存活下來的對象(經歷了一次GC檢查的對象)會成為「第1代對象」
    • GC過1次後,第0代對象被清空(該回收掉的回收掉,該升至第1代的升至第1代)
    • 此後新增的對象,又會再次分配到第0代中
    • 而第0代又一次超出預算後,GC會啟動
      • 現在GC還會檢查第1代占用的內存
        • 如果內存占用少於CLR為第1代選擇的預算,GC就會只檢查第0代的對象並進行回收
        • 否則,GC會同時為第0代和第1代執行檢查和回收
    • 忽略第1代的對象,可提升相當的性能
      • 不必遍歷托管堆中的每個對象
        • 如果根引用了老一代的對象,GC可以忽略該老對象內部的所有引用
        • 除非老對象引用了新對象
          • GC會利用JIT內部的機制,該機制在對象的引用字段發生變化時,會設置一個對應的位標志
            • GC會知道自上一次GC以來,哪些老對象已被寫入
            • 只有字段發生變化的老對象需要檢查是否引用了第0代中的新對象
    • 在代際的GC中,越老的對象活得越長,而由於第1代的對象有可能會被跳過,不被GC檢查,因此,1代對象可能會有一些不可達的對象留存著
      • 1代的對象會在每次GC後緩慢增長,而當1代的內存占用 > CLR為第1代選擇的預算後,GC將會清理第1代和第0代的對象
  • 第二次GC:
    • 第1代對象被清理後,存活的對象會升至第2代(經過了2次或多次檢查)
    • 托管堆只支持3代(第0代、第1代、第2代)
    • CLR的GC是自調節的
      • 初始化時,CLR會為每一代選擇預算
      • 而每次回收時,也會根據所回收的代數的情況,重新評估所需要的預算
        • 如果GC發現回收0代後存活下來的對象很少,就可能減少0代的預算
          • 分配空間減少 -> 回收更頻繁
          • 事實上,如果0代中所有對象都是垃圾,GC就不必壓縮任何內存,NextObjPtr指針指回第0代的起始處
            • 如果應用程序的線程大多數時候都在棧頂閒置,GC工作會更有效率
              • 線程有事做就被喚醒,創建一組短期存活的對象,返回,然後繼續睡眠。如:
                • GUI
                  • 用戶產生輸入 -> 線程被喚醒 -> 創建對象處理輸入 -> 返回 -> 創建的對象成為垃圾
                • 服務器應用
                  • 客戶端請求 -> 創建對象代表客戶端執行工作 -> 結果發回客戶端 -> 線程回到線程池 -> 創建的對象成為垃圾
        • 如果GC發現回收0代後存活的對象很多,就會增大0代的預算
          • GC次數減少
          • 每次回收的內存更多
        • 1代和2代的情況也類似

GC觸發條件
  • 第0代超過預算
  • 顯式調用GC.Collect
    • 大多數時候避免使用,除非發生了一些具有特殊性質的事件
      • 非重複性
      • 大量舊對象死亡
  • Windows報告低內存情況
  • CLR正在卸載AppDomain
    • CLR認為其中一切都不是根,執行涵蓋所有代的GC
    • 仍然會壓縮/釋放內存
  • CLR正在關閉
    • 進程正常終止(不是從外部,如任務管理器終止)
    • CLR認為其中一切都不是根,執行涵蓋所有代的GC
    • 不會壓縮/釋放內存
大對象
  • 一般超過85000字節(未來可能有更改,不是常量)的會被認為是大對象
    • 大對象不在小對象的地址空間分配,在進程地址空間的其他地方分配
    • 目前GC不壓縮大對象,在內存中移動它們代價過高
    • 大對象總是第2代,所以只能為需要長時間存活的資源創建大對象
      • 分配短時間存活的大對象會導致第2代會頻繁回收,損害性能
    • 大對象一般是大字符串(XML/Json)、I/O操作的字節數組等等
GC模式
  • GC模式的選擇以進程為單位,一旦決定了模式,進程結束前不會改變
  • 兩個模式
    • 工作站
      • 針對客戶端
      • GC造成的線程掛起時間很短
      • 假定機器上運行的其他程序不會消耗太多CPU資源
    • 服務器
      • 針對服務器
      • 假定機器上沒有運行其他程序
      • 所有CPU用來輔助GC
        • 托管堆會被拆分成幾個區域,每個CPU一個
        • GC在每個CPU上運行一個特殊線程,每個線程和其他線程并發回收它自己的區域
  • 兩個子模式
    • 并發
      • GC有一個額外的後台線程,能在應用程序運行時并發標記對象
      • 消耗比非并發大
    • 非并發
需要特殊清理的類型
  • 有的類型除了需要內存外,還需要本機資源

    • 如FileStream類
  • 包含本機資源的類型被GC時,GC會回收對象在托管堆中使用的內存,但是由於GC對本機資源一無所知,因此會導致本機資源的泄漏

    • CLR提供了「終結」機制,允許對象被判定為垃圾後,在對象「真正」回收前執行一些代碼
      • 實際上,被視為垃圾的可終結對象會經歷兩次垃圾回收
        • Finalize方法會在第一次垃圾回收後執行,一次回收後,這些對象並沒有真正被回收,因為在「終結」時必須存活
        • 從而導致這些對象,包括它們所引用的字段,一律會被提升到另一代,增大內存耗用
        • 應避免為引用類型字段定義可終結對象
    • 任何包裝了本機資源的類型都支持「終結」
  • System.Object定義了Finalize虛方法,如果對象的類重寫了該方法,對象被判定為垃圾後,會執行該方法

    •   public class DisposeType
        {
            ~DisposeType()
            {
                Console.WriteLine("Target Disposed");
            }
        }
    • 重寫該方法後,C#編譯器會在元數據中生成一個Finalize方法

      •   Method #1 (06000004) 
          -------------------------------------------------------
          MethodName: Finalize (06000004)
          Flags     : [Family] [Virtual] [HideBySig] [ReuseSlot]  (000000c4)
          RVA       : 0x000020c0
          ImplFlags : [IL] [Managed]  (00000000)
          CallCnvntn: [DEFAULT]
          hasThis 
          ReturnType: Void
          No arguments.
    • 在IL中,該方法裡的代碼放到一個try塊中,並在finally塊中放入一個base.Finalize的調用

      •   .method family hidebysig virtual instance void 
                  Finalize() cil managed
          {
            .override [System.Runtime]System.Object::Finalize
            // 程式碼大小       24 (0x18)
            .maxstack  1
            IL_0000:  nop
            .try
            {
              IL_0001:  nop
              IL_0002:  ldstr      "Target Disposed"
              IL_0007:  call       void [System.Console]System.Console::WriteLine(string)
              IL_000c:  nop
              IL_000d:  leave.s    IL_0017
            }  // end .try
            finally
            {
              IL_000f:  ldarg.0
              IL_0010:  call       instance void [System.Runtime]System.Object::Finalize() //base.Finalize
              IL_0015:  nop
              IL_0016:  endfinally
            }  // end handler
            IL_0017:  ret
          } // end of method DisposeType::Finalize
  • Finalize的執行時間是無法控制的

    • Finalize只有在GC完成後才會執行,而GC又只有在程序請求更多內存時才發生
    • 多個Finalize的調用順序也無法控制
IDisposable接口
  • 包裝了本機資源的類一般都有實現IDisposable接口,從而讓開發者手動去釋放其本機資源
    • 實際上,並非一定要調用Dispose才能保證本機資源得到清理
    • 本機資源的清理最終總會發生,調用Dispose只是控制這個動作的發生時間
      • 調用Dispose本身不會將托管對象從托管堆刪除,只有GC後,內存才會得以回收
  • Dispose的使用最好是在確定必須清理資源的時候
    • 如果確定要顯式調用Dispose,最好將其放在異常處理的finally塊中,保證清理代碼得到執行

      •   byte[] bytes = new byte[] { 1, 2, 3, 4 };
          FileStream fs = new FileStream("Test.dat", FileMode.Create);
          try
          {
              fs.Write(bytes, 0, bytes.Length);
          }
          finally
          {
              if(fs != null)
              {
                  fs.Dispose();
              }
          }
    • 也可以使用using語句

      • using語句初始化一個對象並將其引用保存至一個變量中

      • 編譯using代碼時,編譯器自動生成對應的try和finally塊,並在finally塊中生成代碼將變量轉型為IDisposable並調用Dispose方法

      • 因此,using語句只能用於那些實現了IDisposable接口的類型

      •   byte[] bytes = new byte[] { 1, 2, 3, 4 };
          using (FileStream fs2 = new FileStream("Test.dat", FileMode.Create))
          {
              fs2.Write(bytes, 0, bytes.Length);
          }
          File.Delete("Test.dat");
      •   IL_001e:  stloc.1
          .try
          {
            IL_001f:  nop
            IL_0020:  ldloc.1
            IL_0021:  ldloc.0
            IL_0022:  ldc.i4.0
            IL_0023:  ldloc.0
            IL_0024:  ldlen
            IL_0025:  conv.i4
            IL_0026:  callvirt   instance void [System.Runtime]System.IO.Stream::Write(uint8[],
                                                                                       int32,
                                                                                       int32)
            IL_002b:  nop
            IL_002c:  nop
            IL_002d:  leave.s    IL_003a
          }  // end .try
          finally
          {
            IL_002f:  ldloc.1
            IL_0030:  brfalse.s  IL_0039
            IL_0032:  ldloc.1
            IL_0033:  callvirt   instance void [System.Runtime]System.IDisposable::Dispose()
            IL_0038:  nop
            IL_0039:  endfinally
          }  // end handler

終結的內部工作原理
  • 當新對象被創建時,new操作從堆中分配內存
  • 如果對象類型重寫了System.Object的Finalize方法,在其實例構造器被調用之前,會將指向該對象的指針放到「終結列表」(finalization list)裡
    • 「終結列表」
      • 由GC控制的一個內部數據結構,每一項都指向一個需要在回收其內存前調用其Finalize方法的對象

  • 第一次GC時,不可達的對象(D、F、H)被判定為垃圾
    • GC掃描終結列表,查找被判定為垃圾的對象是否在列表裡(代表有重寫終結器)
      • 在列表裡的話,會將對象引用從終結列表中移除並添加到freachable(F-reachable, F for Finalize)隊列中
        • 「freachable」隊列
          • GC的一種內部數據結構,隊列每個引用都代表其Finalize方法已準備好調用的一個對象

  • Finalize方法會由一個高優先級的CLR線程所調用 - 當freachable隊列為空時,該線程進入睡眠 - 一旦隊列出現記錄項,線程被喚醒,移除隊列裡每個元素的同時調用它們的Finalize方法
    • 元素在freachable隊列時,其引用仍會被保留,因此此時它們仍然是「可達的」
      • 當一個重寫了Finalize方法的對象「不可達」,進行首次GC時將它們的引用從終結列表移除,放入freachable隊列時,對象會重新變成「可達」
        • 此時對象內存則無法被回收,甚至會因為「經歷了一次GC」,而提升至較老的一代
  • 在第二次GC時,「已經在上次GC中調用過Finalize的對象」就會變成真正的垃圾,因為freachable隊列也不再保留它們的引用,此時內存才會真正被回收

  • 然而,由於可終結對象需要執行兩次GC才能回收內存,而對象可能會被提升至另一代,在提升至另一代的情況下,可能就不是兩次GC就能回收的事情了
對象生存期的控制和監視
  • CLR為每個AppDomain提供了一個GC句柄表,允許應用程序監視/控制對象生存期
    • 該表創建時是空白的,使用GCHandle.Alloc靜態方法新增記錄項
      • GCHandle.Alloc接受1~2個參數
        • 對象引用
        • GCHandleType
          • 監視生存期,檢測GC甚麼時候判定對象不可達
            • Weak:執行時對象的Finalize方法的執行狀態不確定,內存可能還沒回收
            • WeakTrackResurrection:執行時對象的Finalize方法已經執行,且內存已經回收
          • 控制生存期,告訴GC,即使沒有根引用該對象,該對象也必須留在內存中
            • Normal:GC發生時,內存可以壓縮
            • Pinned:GC發生時,內存不能壓縮
    • 表中每個記錄項都包含兩種信息
      • 對托管堆中的一個對象的引用
      • 指出如何監視/控制對象的flag
  • GC對GC句柄表的使用(GC發生時,GC的行為)
    • GC標記所有可達對象,掃描句柄表,所有Normal/Pinned對象被看成「根」,標記這些對象及其裡面字段引用的對象
    • 掃描句柄表,查找所有Weak記錄項,如果引用了未標記的對象,引用值改為null
    • GC掃描終結列表,把不可達對象從終結列表移至freachable隊列,使對象重新變成可達(復活)
    • 掃描句柄表,查找所有WeakTrackResurrection記錄項(由freachable隊列的記錄項所引用),如果引用了未標記的對象,引用值改為null
    • GC壓縮內存,碎片整理
      • Pinned對象不會「移動」

參考書目

  • 《CLR via C#》(第4版) Jeffrey Richter