1. 程式人生 > >Android開發之淺談垃圾回收機制GC以及如何用好GC

Android開發之淺談垃圾回收機制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佔據的記憶體

還有的情況是程式設計不規範可能導致死鎖,比如流傳很廣的一段程式碼:

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