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壓縮所有幸存下來的對象,使它們占用連續的空間

                  • 恢復引用的「局部性」,減少應用程序的工作集

                  • 解決空間碎片化的問題

                • CLR移動了內存的對象後,需要刷新引用的位置

                  • 從每個根減去所引用的對象在內存中偏移的字節數

                • 完成壓縮後,NextObjPtr指向最後一個幸存對象之後的位置,恢復所有線程的進行

代(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