垃圾回收機制GC知識再總結兼談如何用好GC


一、為什么需要GC

應用程序對資源操作,通常簡單分為以下幾個步驟:

1、為對應的資源分配內存

2、初始化內存

3、使用資源

4、清理資源

5、釋放內存

應用程序對資源(內存使用)管理的方式,常見的一般有如下幾種:

1、手動管理:C,C++

2、計數管理:COM

3、自動管理:.NET,Java,PHP,GO…

但是,手動管理和計數管理的復雜性很容易產生以下典型問題:

1.程序員忘記去釋放內存

2.應用程序訪問已經釋放的內存

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

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

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

 

二、GC是如何工作的

GC的工作流程主要分為如下幾個步驟:

1、標記(Mark)

2、計划(Plan)

3、清理(Sweep)

4、引用更新(Relocate)

5、壓縮(Compact)

GC

(一)、標記

目標:找出所有引用不為0(live)的實例

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

(二)、計划和清理

1、計划

目標:判斷是否需要壓縮

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

2、清理

目標:回收所有的free空間

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

(三)、引用更新和壓縮

1、引用更新

目標: 將所有引用的地址進行更新

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

2、壓縮

目標:減少內存碎片

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

 

三、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的對象有如下幾種:

1、全局變量

2、靜態變量

3、棧上的所有局部變量(JIT)

4、棧上傳入的參數變量

5、寄存器中的變量

注意,只有引用類型的變量才被認為是根,值類型的變量永遠不被認為是根。只有深刻理解引用類型和值類型的內存分配和管理的不同,才能知道為什么root只能是引用類型。

順帶提一下JAVA,在Java中,可以當做GC Root的對象有以下幾種:

1、虛擬機(JVM)棧中的引用的對象

2、方法區中的類靜態屬性引用的對象

3、方法區中的常量引用的對象(主要指聲明為final的常量值)

4、本地方法棧中JNI的引用的對象

 

四、什么時候發生GC

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

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

3、其他特殊情況,比如,windows報告內存不足、CLR卸載AppDomain、CLR關閉,甚至某些極端情況下系統參數設置改變也可能導致GC回收

 

五、GC中的代

代(Generation)引入的原因主要是為了提高性能(Performance),以避免收集整個堆(Heap)。一個基於代的垃圾回收器做出了如下幾點假設:

1、對象越新,生存期越短

2、對象越老,生存期越長

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

.NET的垃圾收集器將對象分為三代(Generation0,Generation1,Generation2)。不同的代里面的內容如下:

1、G0 小對象(Size<85000Byte)

2、G1:在GC中幸存下來的G0對象

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

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

ps,這里必須知道,CLR要求所有的資源都從托管堆(managed heap)分配,CLR會管理兩種類型的堆,小對象堆(small object heap,SOH)和大對象堆(large object heap,LOH),其中所有大於85000byte的內存分配都會在LOH上進行。一個有趣的問題是為什么是85000字節?

代收集規則:當一個代N被收集以后,在這個代里的幸存下來的對象會被標記為N+1代的對象。GC對不同代的對象執行不同的檢查策略以優化性能。每個GC周期都會檢查第0代對象。大約1/10的GC周期檢查第0代和第1代對象。大約1/100的GC周期檢查所有的對象。

 

六、謹慎顯式調用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...");
                }
            }
        }
    }
MyClass

通過如下代碼進行調用:

           var instance = new MyClass();

            Monitor.Enter(instance);
            instance = null;

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

上述代碼將會導致死鎖。原因分析如下:

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

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

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

雖然嚴格來說,上述代碼並不是GC的錯,和多線程操作似乎也無關,而是Lock使用不正確造成的。

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

 

七、當GC遇到多線程

這一段主要參考<<CLR Via C#>>的線程劫持一節。

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

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

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

概念敘述到此結束,手都抄軟了^_^,這書賣的貴和書里面的理論水平一樣有道理。

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

 

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

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

1、盡量不要new很大的object,大對象(>=85000Byte)直接歸為G2代,GC回收算法從來不對大對象堆(LOH)進行內存壓縮整理,因為在堆中下移85000字節或更大的內存塊會浪費太多CPU時間

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

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

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

根據經驗,有時候編程思想里的空間換時間真不能亂用,用的不好,不但系統性能不能保證,說不定就會導致內存溢出(Out Of Memory),關於OOM,可以參考我之前寫過的一篇文章有效預防.NET應用程序OOM的經驗備忘

之前在維護一個系統的時候,發現有很多大數據量的處理邏輯,但竟然都沒有批量和分頁處理,隨着數據量的不斷膨脹,隱藏的問題會不斷暴露。然后我在重寫的時候,都按照批量多次的思路設計實現,有了多線程、多進程和分布式集群技術,再大的數據量也能很好處理,而且性能不會下降,系統也會變得更加穩定可靠。

 

九、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#>>一個有趣的依賴性問題)。

最后感慨一下,反復看一本好書遠遠比看十本二十本不那么靠譜的書收獲更多。

參考:

<<CLR Via C#>>

<<深入理解Java虛擬機>>

<<C# In Depth>>

<<Think In Java>>

https://msdn.microsoft.com/en-us/library/ms979205.aspx

http://msdn.microsoft.com/zh-cn/magazine/cc188793%28en-us%29.aspx

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM