.NET 之 垃圾回收機制GC

1、GC的必要性

  一、應用程序對資源操做,一般簡單分爲如下幾個步驟:爲對應的資源分配內存 → 初始化內存 → 使用資源 → 清理資源 → 釋放內存。程序員

  二、應用程序對資源(內存使用)管理的方式,常見的通常有以下幾種:web

  [1] 手動管理:C,C++算法

  [2] 計數管理:COM數據庫

  [3] 自動管理:.NET,Java,PHP,GO…編程

  三、可是,手動管理和計數管理的複雜性很容易產生如下典型問題:windows

  [1] 程序員忘記去釋放內存安全

  [2] 應用程序訪問已經釋放的內存服務器

  產生的後果很嚴重,常見的如內存泄露、數據內容亂碼,並且大部分時候,程序的行爲會變得怪異而不可預測,還有Access Violation等。數據結構

  .NET、Java等給出的解決方案,就是經過自動垃圾回收機制GC進行內存管理。這樣,問題1天然獲得解決,問題2也沒有存在的基礎。多線程

  總結:沒法自動化的內存管理方式極容易產生bug,影響系統穩定性,尤爲是線上多服務器的集羣環境,程序出現執行時bug必須定位到某臺服務器而後dump內存再分析bug所在,極其打擊開發人員編程積極性,並且源源不斷的相似bug讓人厭惡。

 

2、GC是如何工做的

  GC的工做流程主要分爲以下幾個步驟:

  標記(Mark) → 計劃(Plan) → 清理(Sweep) → 引用更新(Relocate) → 壓縮(Compact)

  GC

  一、標記

  目標:找出全部引用不爲0(live)的實例

  方法:找到全部的GC的根結點(GC Root), 將他們放到隊列裏,而後依次遞歸地遍歷全部的根結點以及引用的全部子節點和子子節點,將全部被遍歷到的結點標記成live。弱引用不會被考慮在內

  二、計劃和清理

  [1] 計劃

  目標:判斷是否須要壓縮

  方法:遍歷當前全部的generation上全部的標記(Live),根據特定算法做出決策

  [2] 清理

  目標:回收全部的free空間

  方法:遍歷當前全部的generation上全部的標記(Live or Dead),把全部處在Live實例中間的內存塊加入到可用內存鏈表中去

  三、引用更新和壓縮

  [1] 引用更新

  目標: 將全部引用的地址進行更新

  方法:計算出壓縮後每一個實例對應的新地址,找到全部的GC的根結點(GC Root), 將他們放到隊列裏,而後依次遞歸地遍歷全部的根結點以及引用的全部子節點和子子節點,將全部被遍歷到的結點中引用的地址進行更新,包括弱引用。

  [2] 壓縮

  目標:減小內存碎片

  方法:根據計算出來的新地址,把實例移動到相應的位置。

 

3、GC的根節點

  本文反覆出現的GC的根節點也即GC Root是個什麼東西呢?

  每一個應用程序都包含一組根(root)。每一個根都是一個存儲位置,其中包含指向引用類型對象的一個指針。該指針要麼引用託管堆中的一個對象,要麼爲null。

  在應用程序中,只要某對象變得不可達,也就是沒有根(root)引用該對象,這個對象就會成爲垃圾回收器的目標。

  用一句簡潔的英文描述就是:GC roots are not objects in themselves but are instead references to objects.並且,Any object referenced by a GC root will automatically survive the next garbage collection.

  .NET中能夠看成GC Root的對象有以下幾種:

  一、全局變量

  二、靜態變量

  三、棧上的全部局部變量(JIT)

  四、棧上傳入的參數變量

  五、寄存器中的變量

  注意,只有引用類型的變量才被認爲是根,值類型的變量永遠不被認爲是根。由於值類型存儲在堆棧中,而引用類型存儲在託管堆上。

4、何時發生GC

  一、當應用程序分配新的對象,GC的代的預算大小已經達到閾值,好比GC的第0代已滿;

  二、代碼主動顯式調用System.GC.Collect();

  三、其餘特殊狀況,好比,windows報告內存不足、CLR卸載AppDomain、CLR關閉,甚至某些極端狀況下系統參數設置改變也可能致使GC回收。

5、GC中的代

  代(Generation)引入的緣由主要是爲了提升性能(Performance),以免收集整個堆(Heap)。一個基於代的垃圾回收器作出了以下幾點假設:

  一、對象越新,生存期越短;

  二、對象越老,生存期越長;

  三、回收堆的一部分,速度快於回收整個堆。

  .NET的垃圾收集器將對象分爲三代(Generation0,Generation1,Generation2)。不一樣的代裏面的內容以下:

  一、G0 小對象(Size<85000Byte):新分配的小於85000字節的對象。

  二、G1:在GC中倖存下來的G0對象

  三、G2:大對象(Size>=85000Byte);在GC中倖存下來的G1對象

object o = new Byte[85000]; //large object
Console.WriteLine(GC.GetGeneration(o)); //output is 2,not 0

  這裏必須知道,CLR要求全部的資源都從託管堆(managed heap)分配,CLR會管理兩種類型的堆,小對象堆(small object heap,SOH)和大對象堆(large object heap,LOH),其中全部大於85000byte的內存分配都會在LOH上進行。

  代收集規則:當一個代N被收集之後,在這個代裏的倖存下來的對象會被標記爲N+1代的對象。GC對不一樣代的對象執行不一樣的檢查策略以優化性能。每一個GC週期都會檢查第0代對象。大約1/10的GC週期檢查第0代和第1代對象。大約1/100的GC週期檢查全部的對象。

6、謹慎顯式調用GC

  GC的開銷一般很大,並且它的運行具備不肯定性,微軟的編程規範裏是強烈建議你不要顯式調用GC。但你的代碼中仍是可使用framework中GC的某些方法進行手動回收,前提是你必需要深入理解GC的回收原理,不然手動調用GC在特定場景下很容易干擾到GC的正常回收甚至引入不可預知的錯誤。

好比以下代碼:

        void SomeMethod()
        {
            object o1 = new Object();
            object o2 = new Object();

            o1.ToString();
            GC.Collect(); // this forces o2 into Gen1, because it's still referenced
            o2.ToString();
        }

  若是沒有GC.Collect(),o1和o2都將在下一次垃圾自動回收中進入Gen0,可是加上GC.Collect(),o2將被標記爲Gen1,也就是0代回收沒有釋放o2佔據的內存

  還有的狀況是編程不規範可能致使死鎖,好比流傳很廣的一段代碼:

    public class MyClass
    {
        private bool isDisposed = false;

        ~MyClass()
        {
            Console.WriteLine("Enter destructor...");

            lock (this) //some situation lead to deadlock
            {
                if (!isDisposed)
                {
                    Console.WriteLine("Do Stuff...");
                }
            }
        }
    }

  經過以下代碼進行調用:

       var instance = new MyClass();

            Monitor.Enter(instance);
            instance = null;

            GC.Collect();
            GC.WaitForPendingFinalizers();
          
            Console.WriteLine("instance is gabage collected");

  上述代碼將會致使死鎖。緣由分析以下:

  一、客戶端主線程調用代碼Monitor.Enter(instance)代碼段lock住了instance實例

  二、接着手動執行GC回收,主(Finalizer)線程會執行MyClass析構函數

  三、在MyClass析構函數內部,使用了lock (this)代碼,而主(Finalizer)線程尚未釋放instance(也即這裏的this),此時主線程只能等待

  雖然嚴格來講,上述代碼並非GC的錯,和多線程操做彷佛也無關,而是Lock使用不正確形成的。

  同時請注意,GC的某些行爲在Debug和Release模式下徹底不一樣(Jeffrey Richter在<<CLR Via C#>>舉過一個Timer的例子說明這個問題)。好比上述代碼,在Debug模式下你可能發現它是正常運行的,而Release模式下則會死鎖。

7、當GC遇到多線程

  前面討論的垃圾回收算法有一個很大的前提就是:只在一個線程運行。而在現實開發中,常常會出現多個線程同時訪問託管堆的狀況,或至少會有多個線程同時操做堆中的對象。一個線程引起垃圾回收時,其它線程絕對不能訪問任何線程,由於垃圾回收器可能移動這些對象,更改它們的內存位置。CLR想要進行垃圾回收時,會當即掛起執行託管代碼中的全部線程,正在執行非託管代碼的線程不會掛起。而後,CLR檢查每一個線程的指令指針,判斷線程指向到哪裏。接着,指令指針與JIT生成的表進行比較,判斷線程正在執行什麼代碼。

  若是線程的指令指針剛好在一個表中標記好的偏移位置,就說明該線程抵達了一個安全點。線程可在安全點安全地掛起,直至垃圾回收結束。若是線程指令指針不在表中標記的偏移位置,則代表該線程不在安全點,CLR也就不會開始垃圾回收。在這種狀況下,CLR就會劫持該線程。也就是說,CLR會修改該線程棧,使該線程指向一個CLR內部的一個特殊函數。而後,線程恢復執行。當前的方法執行完後,他就會執行這個特殊函數,這個特殊函數會將該線程安全地掛起。然而,線程有時長時間執行當前所在方法。因此,當線程恢復執行後,大約有250毫秒的時間嘗試劫持線程。過了這個時間,CLR會再次掛起線程,並檢查該線程的指令指針。若是線程已抵達一個安全點,垃圾回收就能夠開始了。可是,若是線程尚未抵達一個安全點,CLR就檢查是否調用了另外一個方法。若是是,CLR再一次修改線程棧,以便從最近執行的一個方法返回以後劫持線程。而後,CLR恢復線程,進行下一次劫持嘗試。全部線程都抵達安全點或被劫持以後,垃圾回收才能使用。垃圾回收完以後,全部線程都會恢復,應用程序繼續運行,被劫持的線程返回最初調用它們的方法。

  實際應用中,CLR大多數時候都是經過劫持線程來掛起線程,而不是根據JIT生成的表來判斷線程是否到達了一個安全點。之因此如此,緣由是JIT生成表須要大量內存,會增大工做集,進而嚴重影響性能。

  這裏再說一個真實案例。某web應用程序中大量使用Task,後在生產環境發生莫名其妙的現象,程序時靈時不靈,根據數據庫日誌(其實還能夠根據Windows事件跟蹤(ETW)、IIS日誌以及dump文件),發現了Task執行過程當中有不規律的未處理的異常,分析後懷疑是CLR垃圾回收致使,固然這種狀況也只有在高併發條件下才會暴露出來。

8、開發中的一些建議和意見

  因爲GC的代價很大,平時開發中注意一些良好的編程習慣有可能對GC有積極正面的影響,不然有可能產生不良效果。

  一、儘可能不要new很大的object,大對象(>=85000Byte)直接歸爲G2代,GC回收算法歷來不對大對象堆(LOH)進行內存壓縮整理,由於在堆中下移85000字節或更大的內存塊會浪費太多CPU時間;

  二、不要頻繁的new生命週期很短object,這樣頻繁垃圾回收頻繁壓縮有可能會致使不少內存碎片,可使用設計良好穩定運行的對象池(ObjectPool)技術來規避這種問題

  三、使用更好的編程技巧,好比更好的算法、更優的數據結構、更佳的解決策略等等

  update:.NET4.5.1及其以上版本已經支持壓縮大對象堆,可經過System.Runtime.GCSettings.LargeObjectHeapCompactionMode進行控制實現須要壓縮LOH。

 

9、GC線程和Finalizer線程

  GC在一個獨立的線程中運行來刪除再也不被引用的內存。

  Finalizer則由另外一個獨立(高優先級CLR)線程來執行Finalizer的對象的內存回收。

  對象的Finalizer被執行的時間是在對象再也不被引用後的某個不肯定的時間,並不是和C++中同樣在對象超出生命週期時當即執行析構函數。

  GC把每個須要執行Finalizer的對象放到一個隊列(從終結列表移至freachable隊列)中去,而後啓動另外一個線程而不是在GC執行的線程來執行全部這些Finalizer,GC線程繼續去刪除其餘待回收的對象。

  在下一個GC週期,這些執行完Finalizer的對象的內存纔會被回收。也就是說一個實現了Finalize方法的對象必需等兩次GC才能被徹底釋放。這也代表有Finalize的方法(Object默認的不算)的對象會在GC中自動「延長」生存週期。

  特別注意:負責調用Finalize的線程並不保證各個對象的Finalize的調用順序,這可能會帶來微妙的依賴性問題(見<<CLR Via C#>>一個有趣的依賴性問題)。

相關文章
相關標籤/搜索