十五、垃圾回收相關算法

1、標記階段:引用計數算法

 

垃圾標記階段:對象存活判斷

  • 在堆裏存放着幾乎所有的Java對象實例,在GC執行垃圾回收之前,首先需要區分出內存中哪些是存活對象,哪些是已經死亡的對象。只有被標記爲己經死亡的對象, GC纔會在執行垃圾回收時,釋放掉其所佔用的內存空間,因此這個過程我們可以稱爲垃圾標記階段。
  • 那麼在JVM中究竟是如何標記一個死亡對象呢?簡單來說,當一個對象已經不再被任何的存活對象繼續引用時,就可以宣判爲已經死亡。
  • 判斷對象存活一般有兩種方式:引用計數算法和可達性分析算法。

 

 

方式一:引用計數算法

 

  • 引用計數算法(Reference Counting)比較簡單,對每個對象保存一個整型的引用計數器屬性。用於記錄對象被引用的情況。
  • 對於一個對象A,只要有任何一個對象引用了A,則A的引用計數器就加1;當引用失效時,引用計數器就減1。只要對象A的引用計數器的值爲0,即表示對象A不可能再被使用,可進行回收。
  • 優點:實現簡單,垃圾對象便於辨識;判定效率高,回收沒有延遲性。
  • 缺點:

> 它需要單獨的字段存儲計數器,這樣的做法增加了存儲空間的開銷。

> 每次賦值都需要更新計數器,伴隨着加法和減法操作,這增加了時間開銷。

> 引用計數器有一個嚴重的問題,即無法處理循環引用的情況。這是一條致命缺陷,導致在Java的垃圾回收器中沒有使用這類算法。

 

 

 

 

 

/**
 * -XX:+PrintGCDetails
 * 證明:java使用的不是引用計數算法
 * @author shkstart
 * @create 2020 下午 2:38
 */
public class RefCountGC {
    //這個成員屬性唯一的作用就是佔用一點內存
    private byte[] bigSize = new byte[5 * 1024 * 1024];//5MB

    Object reference = null;

    public static void main(String[] args) {
        RefCountGC obj1 = new RefCountGC();
        RefCountGC obj2 = new RefCountGC();

        obj1.reference = obj2;
        obj2.reference = obj1;

        obj1 = null;
        obj2 = null;
        //顯式的執行垃圾回收行爲
        //這裏發生GC,obj1和obj2能否被回收?
        System.gc();

        try {
            Thread.sleep(1000000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

 

 

如果不小心直接把obj1-reference 和obj2-reference置null。則在Java堆當中的兩塊內存依然保持着互相引用,無法回收。

 

 

 

小結

 

  • 引用計數算法,是很多語言的資源回收選擇,例如因人工智能而更加火熱的Python,它更是同時支持引用計數和垃圾收集機制。
  • 具體哪種最優是要看場景的,業界有大規模實踐中僅保留引用計數機制,以提高吞吐量的嘗試。
  • Java並沒有選擇引用計數,是因爲其存在一個基本的難題,也就是很難處理循環引用關係。
  • Python如何解決循環引用?

> 手動解除:很好理解,就是在合適的時機,解除引用關係。

> 使用弱引用weakref, weakref是Python提供的標準庫,旨在解決循環引用。

 

 

 

2、標記階段:可達性分析算法

 

 

方式二:可達性分析(或根搜索算法、追蹤性垃圾收集)

 

  • 相對於引用計數算法而言,可達性分析算法不僅同樣具備實現簡單和執行高效等特點,更重要的是該算法可以有效地解決在引用計數算法中循環引用的問題,防止內存泄漏的發生。
  • 相較於引用計數算法,這裏的可達性分析就是Java,C#選擇的。這種類型的垃圾收集通常也叫作追蹤性垃圾收集(Tracing Garbage Collection)。

 

  • 所謂"GC Roots"根集合就是一組必須活躍的引用。
  • 基本思路:

> 可達性分析算法是以根對象集合(GC Roots)爲起始點,按照從上至下的方式搜索被根對象集合所連接的目標對象是否可達。

> 使用可達性分析算法後,內存中的存活對象都會被根對象集合直接或間接連接着,搜索所走過的路徑稱爲引用鏈(Reference Chain)

> 如果目標對象沒有任何引用鏈相連,則是不可達的,就意味着該對象己經死亡,可以標記爲垃圾對象。

> 在可達性分析算法中,只有能夠被根對象集合直接或者間接連接的對象纔是存活對象。

 

這個算法目前較爲常用。

 

 

GC Roots

在Java語言中, GC Roots包括以下幾類元素:

  • 虛擬機棧中引用的對象

> 比如:各個線程被調用的方法中使用到的參數、局部變量等。

  • 本地方法棧內JNI (通常說的本地方法)引用的對象
  • 方法區中類靜態屬性引用的對象

> 比如: Java類的引用類型靜態變量

  • 方法區中常量引用的對象

> 比如:字符串常量池(String Table)裏的引用

  • 所有被同步鎖synchronized持有的對象
  • Java虛擬機內部的 引用。

> 基本數據類型對應的Class對象,一些常駐的異常對象(如:NullPointerException, OutOfMemoryError) ,系統類加載器。

  • 反映java虛擬機內部情況的JMXBean, JVMTI中註冊的回調、本地代碼緩存等

 

 

 

  • 除了這些固定的GC Roots集合以外,根據用戶所選用的垃圾收集器以及當前回收的內存區域不同,還可以有其他對象“臨時性”地加入,共同構成完整GC Roots集合。比如:分代收集和局部回收(Partial GC)

> 如果只針對Java堆中的某一塊區域進行垃圾回收(比如:典型的只針對新生代,必須考慮到內存區域是虛擬機自己的實現細節,更不是孤立封閉的,這個區域的對象完全有可能被其他區域的對象所引用,這時候就需要一併將關聯的區域對象也加入GC Roots集合中去考慮,才能保證可達性分析的準確性。

  • 小技巧:

由於Root採用棧方式存放變量和指針,所以如果一個指針,它保存了堆內存裏面的對象,但是自己又不存放在堆內存裏面,那它就是一個Root 。

 

 

注意:

 

  • 如果要使用可達性分析算法來判斷內存是否可回收,那麼分析工作必須在一個能保障一致性的快照中進行。這點不滿足的話分析結果的準確性就無法保證。
  • 這點也是導致GC進行時必須"Stop The World"的一個重要原因。

> 即使是號稱(幾乎)不會發生停頓的CMS收集器中,枚舉根節點時也是必須要停頓的。

 

 

 

 

3、對象的finalization機制

  • Java語言提供了對象終止(finalization)機制來允許開發人員提供對象被銷燬之前的自定義處理邏輯
  • 當垃圾回收器發現沒有引用指向一個對象,即:垃圾回收此對象之前,總會先調用這個對象的finalize ()方法。
  • finalize ()方法允許在子類中被重寫,用於在對象被回收時進行資源釋放。通常在這個方法中進行一些資源釋放和清理的工作,比如關閉文件、套接字和數據庫連接等。

 

  • 永遠不要主動調用某個對象的finalize()方法,應該交給垃圾回收機制調用。理由包括下面三點:

> 在finalize ()時可能會導致對象復活。

> finalize ()方法的執行時間是沒有保障的,它完全由GC線程決定,極端情況下,若不發生GC,則finalize ()方法將沒有執行機會。

> 一個糟糕的finalize ()會嚴重影響GC的性能。

  • 從功能上來說, finalize ()方法與C++中的析構函數比較相似,但是Java採用的是基於垃圾回收器的自動內存管理機制,所以finalize ()方法在本質上不同於C++中的析勾函數
  • , 由於finalize ()方法的存在,虛擬機中的對象一般處於三種可能的狀態。

 

 

生存還是死亡?

 

  • 如果從所有的根節點都無法訪問到某個對象,說明對象已經不再使用了。一般來說,此對象需要被回收。但事實上,也並非是“非死不可”的,這時候它們暫時處於“緩刑”階段。一個無法觸及的對象有可能在某一個條件下“復活”自己,如果這樣,那麼對它的回收就是不合理的,爲此,定義虛擬機中的對象可能的三種狀態。如下:

> 可觸及的:從根節點開始,可以到達這個對象。

> 可復活的:對象的所有引用都被釋放,但是對象有可能在finalize ()中復活。

> 不可觸及的:對象的finalize ()被調用,並且沒有復活,那麼就會進入不可觸及狀態。不可觸及的對象不可能被複活,因爲finalize ()只會被調用一次。

  • 以上3種狀態中,是由於finalize ()方法的存在,進行的區分。只有在對象不可觸及時纔可以被回收。

 

具體過程

 

  • 判定一個對象objA是否可回收,至少要經歷兩次標記過程:

1.如果對象objA到GC Roots沒有引用鏈,則進行第一次標記。

2.進行篩選,判斷此對象是否有必要執行finalize ()方法

①如果對象objA沒有重寫finalize ()方法,或者finalize ()方法已經被虛擬機調用過,則虛擬機視爲“沒有必要執行” , objA被判定爲不可觸及的。

②如果對象obiA重寫了finalize ()方法,且還未執行過,那麼objA會被插入到F-Queue隊列中, 由一個虛擬機自動創建的、低優先級的Finalizer線程觸發其finalize ()方法執行

③ finalize ()方法是對象逃脫死亡的最後機會,稍後GC會對F-Queue隊列中的對象進行第二次標記。如果objA在finalize ()方法中與引用鏈上的任何一個對象建立了聯繫,那麼在第二次標記時, objA會被移出“即將回收”集合。之後,對象會再次出現沒有引用存在的情況。在這個情況下, finalize方法不會被再次調用,對象會直接變成不可觸及的狀態,也就是說,一個對象的finalize方法只會被調用一次。

 

/**
 * 測試Object類中finalize()方法,即對象的finalization機制。
 *
 * @author shkstart
 * @create 2020 下午 2:57
 */
public class CanReliveObj {
    public static CanReliveObj obj;//類變量,屬於 GC Root


    //此方法只能被調用一次
    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("調用當前類重寫的finalize()方法");
        obj = this;//當前待回收的對象在finalize()方法中與引用鏈上的一個對象obj建立了聯繫
    }


    public static void main(String[] args) {
        try {
            obj = new CanReliveObj();
            // 對象第一次成功拯救自己
            obj = null;
            System.gc();//調用垃圾回收器
            System.out.println("第1次 gc");
            // 因爲Finalizer線程優先級很低,暫停2秒,以等待它
            Thread.sleep(2000);
            if (obj == null) {
                System.out.println("obj is dead");
            } else {
                System.out.println("obj is still alive");
            }
            System.out.println("第2次 gc");
            // 下面這段代碼與上面的完全相同,但是這次自救卻失敗了
            obj = null;
            System.gc();
            // 因爲Finalizer線程優先級很低,暫停2秒,以等待它
            Thread.sleep(2000);
            if (obj == null) {
                System.out.println("obj is dead");
            } else {
                System.out.println("obj is still alive");
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

輸出結果:
第1次 gc
調用當前類重寫的finalize()方法
obj is still alive
第2次 gc
obj is dead

 

 

 

4、MAT與JProfiler的GC Roots溯源

 

MAT是Memory Analyzer的簡稱,它是一款功能強大的Java堆內存分析器。用於查找內存泄漏以及查看內存消耗情況。

MAT是基於Eclipse開發的,是一款免費的性能分析工具。大家可以在http://www.eclipse.org/mat/下載並使用MAT.

 

獲取dump文件

方式1:命令行使用jmap

 

 

方式2:使用JVisualVM導出

  • 捕獲的heap dump文件是一個臨時文件,關閉JVisualVM後自動刪除,若要保留,需要將其另存爲文件
  • 可通過以下方法捕獲heap dump:

> 在左側"Application" (應用程序)子窗口中右擊相應的應用程序,選擇Heap Dump(堆Dump)

> 在Monitor (監視)子標籤頁中點擊Heap Dump (堆Dump)按鈕。

  • 本地應用程序的Heap dumps作爲應用程序標籤頁的一個子標籤頁打開。同時,

heap dump在左側的Application (應用程序)欄中對應一個含有時間戳的節點。右擊這個節點選擇save as (另存爲)即可將heap dump保存到本地。

 

 

 

 

 

 

5、清除階段:標記-清除算法

 

垃圾清除階段

 

當成功區分出內存中存活對象和死亡對象後, GC接下來的任務就是執行垃圾回收,釋放掉無用對象所佔用的內存空間,以便有足夠的可用內存空間爲新對象分配內存。

目前在JVM中比較常見的三種垃圾收集算法是標記-清除算法( Mark-Sweep )、複製算法( Copying )、標記-壓縮算法( Mark-Compact) 。

 

 

標記-清除( Mark-Sweep )算法

 

背景:

標記-清除算法( Mark-Sweep )是一種非常基礎和常見的垃圾收集算法,該算法被J.McCarthy等人在1960年提出並應用於Lisp語言。

執行過程:

當堆中的有效內存空間(available memory)被耗盡的時候,就會停止整個程序(也被稱爲stop the world) ,然後進行兩項工作,第一項則是標記,第二項則是清除。

標記: Collector從引用根節點開始遍歷,標記所有被引用的對象。一般是在對象的Header中記錄爲可達對象。

清除: Collector對堆內存從頭到尾進行線性的遍歷,如果發現某個對象在其Header中沒有標記爲可達對象,則將其回收。

 

缺點

> 效率不算高

> 在進行GC的時候,需要停止整個應用程序,導致用戶體驗差

> 這種方式清理出來的空閒內存是不連續的,產生內存碎片。需要維護一個空閒列表

注意:何爲清除?

> 這裏所謂的清除並不是真的置空,而是把需要清除的對象地址保存在空閒的地址列表裏。下次有新對象需要加載時,判斷垃圾的位置空間是否夠,如果夠,就存放。

 

 

 

 

 

6、清除階段:複製算法

 

背景:

爲了解決標記-清除算法在垃圾收集效率方面的缺陷, M.L.Minsky於1963年發表了著名的論文, “使用雙存儲區的Lisp語言垃圾收集器CA LISP Garbage Collector Algorithm Using serial Secondary Storage )",M.L.Minsky在該論文中描述的算法被人們稱爲複製(Copying)算法,它也被M.L.Minsky本人成功地引入到了Lisp語言的一個實現版本中。

核心思想:

將活着的內存空間分爲兩塊,每次只使用其中一塊,在垃圾回收時將正在使用的內存中的存活對象複製到未被使用的內存塊中,之後清除正在使用的內存塊中的所有對象,交換兩個內存的角色,最後完成垃圾回收。

 

 

優點:

  • 沒有標記和清除過程,實現簡單,運行高效
  • 複製過去以後保證空間的連續性,不會出現“碎片”問題

缺點:

  • 此算法的缺點也是很明顯的,就是需要兩倍的內存空間。
  • 對於G1這種分拆成爲大量region的GC,複製而不是移動,意味着GC需要維護region之間對象引用關係,不管是內存佔用或者時間開銷也不小。

特別的:

  • 如果系統中的垃圾對象很多,複製算法不會很理想,複製算法需要複製的存活對象數量並不會太大,或者說非常低纔行。

 

應用場景:

在新生代,對常規應用的垃圾回收,一次通常可以回收70%-99%的內存空間。回收性價比很高。所以現在的商業虛擬機都是用這種收集算法回收新生代。

 

 

 

 

7、清除階段:標記-壓縮算法

 

標記-壓縮(或標記-整理、Mark-Compact )算法

 

背景:

複製算法的高效性是建立在存活對象少、垃圾對象多的前提下的。這種情況在新生代經常發生,但是在老年代,更常見的情況是大部分對象都是存活對象。如果依然使用複製算法,由於存活對象較多,複製的成本也將很高。因此,基於老年代垃圾回收的特性,需要使用其他的算法。

 

標記一清除算法的確可以應用在老年代中,但是該算法不僅執行效率低下,而且在執行完內存回收後還會產生內存碎片,所以JVM的設計者需要在此基礎之上進行改進。標記-壓縮(Mark-Compact)算法由此誕生

1970年前後, G. L. Steele、C. J. Chene和D.S. Wise等研究者發佈標記-壓縮算法。在許多現代的垃圾收集器中,人們都使用了標記-壓縮算法或其改進版本。

 

 

執行過程:

第一階段和標記清除算法一樣從根節點開始標記所有被引用對象

第二階段將所有的存活對象壓縮到內存的一端,按順序排放。之後,清理邊界外所有的空間。

 

標記-壓縮算法的最終效果等同於標記-清除算法執行完成後,再進行一次內存碎片整理,因此,也可以把它稱爲標記-清除-壓縮(Mark-Sweep-Compact)算法。

二者的本質差異在於標記-清除算法是一種非移動式的回收算法,標記一壓縮是移動式的。是否移動回收後的存活對象是一項優缺點並存的風險決策。

可以看到,標記的存活對象將會被整理,按照內存地址依次排列,而未被標記的內存會被清理掉。如此一來,當我們需要給新對象分配內存時,JVM只需要持有一個內存的起始地址即可,這比維護一個空閒列表顯然少了許多開銷。

 

優點:

  • 消除了標記-清除算法當中,內存區域分散的缺點,我們需要給新對象分配內存時, JVM只需要持有一個內存的起始地址即可。
  • 消除了複製算法當中,內存減半的高額代價。

缺點:

  • 從效率上來說,標記-整理算法要低於複製算法。
  • 移動對象的同時,如果對象被其他對象引用,則還需要調整引用的地址。
  • 移動過程中,需要全程暫停用戶應用程序。即: STW

 

 

 

8、小結

 

對比三種算法

 

Mark-Sweep

Mark-Compact

Copying

速度

中等

最慢

最快

空間開銷

少(但會堆積碎片)

少(不堆積碎片)

通常需要活對象的2倍大小不堆積碎片)

移動對象

效率上來說,複製算法是當之無愧的老大,但是卻浪費了太多內存。

而爲了儘量兼顧上面提到的三個指標,標記一整理算法相對來說更平滑一些,但是效率上不盡如人意,它比複製算法多了一個標記的階段,比標記-清除多了一個整理內存的階段

 

 

 

 

9、分代收集算法

 

前面所有這些算法中,並沒有一種算法可以完全替代其他算法,它們都具有自己獨特的優勢和特點。分代收集算法應運而生。

分代收集算法,是基於這樣一個事實:不同的對象的生命週期是不一樣的。因此,不同生命週期的對象可以採取不同的收集方式,以便提高回收效率。一般是把Java堆分爲新生代和老年代,這樣就可以根據各個年代的特點使用不同的回收算法,以提高垃圾回收的效率。

在Java程序運行的過程中,會產生大量的對象,其中有些對象是與業務信息相關,比如Http請求中的Session對象、線程、Socket連接,這類對象跟業務直接掛鉤,因此生命週期比較長。但是還有一些對象,主要是程序運行過程中生成的臨時變量,這些對象生命週期會比較短,比如: String對象,由於其不變類的特性,系統會產生大量的這些對象,有些對象甚至只用一次即可回收。

 

 

目前幾乎所有的GC都是採用分代收集(Generational Collecting)算法執行垃圾回收的。在HotSpot中,基於分代的概念, GC所使用的內存回收算法必須結合年輕代和老年代各自的特點。

  • 年輕代(Young Gen)

年輕代特點:區域相對老年代較小,對象生命週期短、存活率低,回收頻繁。

這種情況複製算法的回收整理,速度是最快的。複製算法的效率只和當前存活對象大小有關,因此很適用於年輕代的回收。而複製算法內存利用率不高的問題,通過hotspot中的兩個survivor的設計得到緩解。

  • 老年代(Tenured Gen)

老年代特點:區域較大,對象生命週期長、存活率高,回收不及年輕代頻繁。

這種情況存在大量存活率高的對象,複製算法明顯變得不合適。一般是由標記-清除或者是標記-清除與標記-整理的混合實現。

> Mark階段的開銷與存活對象的數量成正比.

> Sweep階段的開銷與所管理區域的大小成正相關。

> Compact階段的開銷與存活對象的數據成正比。

 

 

以HotSpot中的CMS回收器爲例, CMS是基於Mark-Sweep實現的,對於對象的回收效率很高。而對於碎片問題, CMS採用基於Mark-Compact算法的Serial Old回收器作爲補償措施:當內存回收不佳(碎片導致的Concurrent Mode Failure時) ,將採用Serial Old執行Full GC以達到對老年代內存的整理

分代的思想被現有的虛擬機廣泛使用。幾乎所有的垃圾回收器都區分新生代和老年代。

 

 

 

 

10、增量收集算法、分區算法

 

 

增量收集算法

 

上述現有的算法,在垃圾回收過程中,應用軟件將處於一種stop the world的狀態。在stop the world狀態下,應用程序所有的線程都會掛起,暫停一切正常的工作,等待垃圾回收的完成。如果垃圾回收時間過長,應用程序會被掛起很久,將嚴重影響用戶體驗或者系統的穩定性。爲了解決這個問題,即對實時垃圾收集算法的研究直接導致了增量收集(Incremental collecting)算法的誕生。

基本思想

如果一次性將所有的垃圾進行處理,需要造成系統長時間的停頓,那麼就可以讓垃圾收集線程和應用程序線程交替執行。每次,垃圾收集線程只收集一小片區域的內存空間,接着切換到應用程序線程。依次反覆,直到垃圾收集完成。

總的來說,增量收集算法的基礎仍是傳統的標記-清除和複製算法。增量收集算法通過對線程間衝突的妥善處理,允許垃圾收集線程以分階段的方式完成標記、清理或複製工作。

 

缺點:

使用這種方式,由於在垃圾回收過程中,間斷性地還執行了應用程序代碼,所以能減少系統的停頓時間。但是,因爲線程切換和上下文轉換的消耗,會使得垃圾回收的總體成本上升,造成系統吞吐量的下降。

 

 

分區算法

一般來說,在相同條件下,堆空間越大,一次GC時所需要的時間就越長,有關GC產生的停頓也越長。爲了更好地控制GC產生的停頓時間,將一塊大的內存區域分割成多個小塊,根據目標的停頓時間,每次合理地回收若干個小區間,而不是整個堆空間,從而減少一次GC所產生的停頓。

分代算法將按照對象的生命週期長短劃分成兩個部分,分區算法將整個堆空間劃分成連續的不同小區間。

每一個小區間都獨立使用,獨立回收。這種算法的好處是可以控制一次回收多少個小區間。

 

 

 

寫在最後:

注意,這些只是基本的算法思路,實際GC實現過程要複雜的多, 目前還在發展中的前沿GC都是複合算法,並且並行和併發兼備。

 

 

 

 

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章