【JVM第八篇--垃圾回收】GC和GC演算法

就行222發表於2020-11-17

寫在前面的話:本文是在觀看尚矽谷JVM教程後,整理的學習筆記。其觀看地址如下:尚矽谷2020最新版宋紅康JVM教程

1、垃圾

1.1、什麼是垃圾

垃圾(Garbage)在Java語言中是指在執行程式中沒有任何指標指向的物件,這個物件就是需要被回收的垃圾。

如果不及時對記憶體中的垃圾進行清理,那麼這些垃圾物件所佔用的記憶體空間就會一直保留到應用程式結束,被保留的空間也無法被其他物件所使用,極可能導致記憶體溢位。

1.2、垃圾回收

垃圾回收(Garbage Collection)即常說的GC。GC的作用就是清理記憶體中的垃圾,釋放被佔用的記憶體空間,高效地利用記憶體。如果不進行垃圾回收,釋放記憶體,則記憶體遲早會被消耗完畢,最終導致程式崩潰。因為程式在執行過程中是會不斷產生物件來佔用記憶體的。

除了釋放成為垃圾的物件,垃圾回收有時也可以清理記憶體裡的記憶體碎片,使其能在物理空間上能連成一片,以便JVM能將記憶體分配給新的物件。

1.3、Java的垃圾回收區域

在JVM中,只有方法區和堆區有垃圾回收的行為。其中堆又是垃圾回收的重點區域,即頻繁收集新生代的垃圾,較少收集老年代,基本不動永久代/元空間。

實際上,方法區的垃圾回收價效比較低。方法區中的垃圾回收主要是常量的回收和型別的解除安裝。但型別的解除安裝條件非常苛刻,需要同時滿足一下三個條件:

①該類的所有例項都已經被回收,也就是堆中不再有該類及其子類的例項。
②載入該類的類載入器已經被回收。
③該類對應的java.lang.class物件沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法。

實際上,很難同時滿足這三個條件。如類載入器這一條,JVM所建立的3個預設的類載入器是不會被回收的,即只有自己寫的類載入器才有可能會被回收。但除非有特殊需求,大部分情況下,我們並不會為每一個使用者類實現對應的類載入器。這也意味著絕大部分的類都不會在方法區被解除安裝並回收。

所以,實際上垃圾回收的重點就是堆區。

2、如何判斷垃圾

2.1、引用計數演算法

垃圾回收操作應該有如下兩種行為:

①判斷那些物件屬於垃圾。
②將判斷為垃圾的物件清除。

首先是判斷物件是否存活(是否已成為垃圾)。

在堆中存放著幾乎所有的Java例項,在GC執行垃圾回收時,首先需要區分出那些例項是存活的物件,那些是已經死亡的物件。只有被標記為已經死亡的物件,GC才會在執行垃圾回收時,釋放掉其所佔用的記憶體空間。

當一個物件已經不再被任何存活的物件繼續引用時,就可稱之為死亡物件,即垃圾。判斷物件是否已死一般有兩種方式:引用計數演算法和可達性演算法。

引用計數演算法(Reference counting),其具體實現就是,對每個物件儲存一個整型的引用計數器屬性,被引用幾次就將該屬性設為這個值,用於記錄物件被引用的情況。

比如,對於物件A,只要有任何一個物件引用了A,則A的引用計數器就加一。當引用失效時,引用計數器的值就減一。若物件A的引用計數器值為0,即表示物件A不被使用,可以進行回收。

引用計數器的優點:
實現簡單,垃圾物件便於標識,效率高,回收也沒有延遲。

引用計數器的缺點:
①每個物件都會有引用計數器欄位,這樣的做法增加了儲存空間的開銷。
②每次引用的變化都需要更新引用計數器,加法和減法的操作又增加了時間開銷。
③就是引用計數器最嚴重的缺陷,即無法處理迴圈引用的物件。

比如,有物件ObjA和ObjB,這兩個物件都有一個屬性Object instance;若令ObjA.instance = ObjB;,ObjB.instance = ObjA; 。那麼ObjA和ObjB的引用計數器的值就始終無法為0(因為始終有一個引用指向他們),這就意味著,即使已經沒有其他物件引用ObjA和ObjB了。這兩個物件也無法被回收,這將會導致記憶體洩漏。

有程式碼如下,

public class ReferenceCountTest {
    //成員變數,沒有static,即非類獨有,每個物件一份。作用就是佔記憶體
    private byte[] bigSize = new byte[5 * 1024 * 1024]; //5MB

    Object ref = null;

    public static void main(String[] args) {

        ReferenceCountTest obj1 = new ReferenceCountTest();
        ReferenceCountTest obj2 = new ReferenceCountTest();

        /**
         * 互相引用,則兩個物件中的ref屬性都儲存著另一個物件的引用
         */
        obj1.ref = obj2;
        obj2.ref = obj1;

        /**
         * 此時,將引用變數obj1和obj2都置為空,
         * 則在當前執行緒的虛擬機器棧中,再無變數引用剛new出來的兩個物件
         */
        obj1 = null;
        obj2 = null;

        /**
         * 此時,兩個物件在棧中的引用已經為空,即除了在堆中依然保留著互相引用外,
         * 再無任何引用指向它們,故應該被判定為垃圾。
         *
         * 1、先不顯式地執行GC,看堆區中的佔用情況
         * 2、顯式地執行垃圾回收,再看堆區中的佔用情況
         * 使用虛擬機器引數列印出GC細節:-XX:+PrintGCDetaile
         */
        System.gc();
    }
}

則在為引用型別obj1和obj2賦值,以及為他們所指向的物件的屬性ref賦值後,他們在記憶體中關係圖如下,
在這裡插入圖片描述

在將obj1和obj2置為null後,main方法中的obj1和obj2的引用斷開,示例圖如下,

在這裡插入圖片描述

此時,除了堆中的ReferenceCountTest類物件例項1和例項2互相引用外,已經再無任何引用指向他們。按理來說,此時的物件例項1和例項2都應該被回收,但由於這兩個例項物件中的屬性ref的值仍然儲存著對方的地址,故引用計數器的值依然為1。則意味著這兩個物件無法回收,這是引用計數演算法的最大的缺陷。

其實,引用計數演算法在極端情況下,也有很高的延遲性。比如,在物件連環引用的情況下:若有引用指向物件A,而物件A又指向物件B,B又指向C,C又指向D。。。;如此情況下,如果指向物件A的引用消失,那麼將引發連環的回收反應。而只有上一個物件被回收,它指向的下一個物件才能在下一次的GC中被判斷為垃圾回收,這就有了延遲性,引用計數演算法就顯得不那麼及時。

目前主流的JVM都沒有采用引用計數器演算法。

2.2、可達性分析演算法

當前主流的商業語言,如Java、C#等都採用了可達性分析演算法來判斷物件是否存活。這種型別的垃圾收集通常叫做追蹤性垃圾收集(Tracing Garbage Collection)。

相對於引用計數演算法,可達性分析演算法不僅也有簡單高效的特點,重要的是該演算法可以有效解決迴圈引用的問題。

可達性分析演算法的基本思路如下,
以根物件(GC Roots)集合為起點,按照從上到下的方式搜尋被根物件集合所連線的目標物件是否可達。在可達性演算法中,記憶體中的存活物件都會被根物件集合直接或間接的連線,而死亡的物件則不會被連線。

示意圖如下,
在這裡插入圖片描述

可以看出object7、8、9、10都沒有再被根物件集合裡的物件直接或者間接引用,故都被判斷為垃圾物件。但並不是被判斷為垃圾物件就必然會被回收,實際上還有機會通過finalization機制,重新被判斷為存活物件,這將在後面介紹。

使用可達性分析演算法後,存活物件都會被GC Roots集合直接或間接連線著,搜尋存活物件時走過的路徑被稱為引用鏈(Reference Chian),沒有被引用鏈相連的物件就是垃圾物件。

GC Roots

我們知道只有被GC Roots集合引用的物件才會被判定為存活物件,那麼GC Roots集合中又包含了什麼樣的物件呢?

在Java語言中,GC Roots集合包含以下的元素:

  • 1、虛擬機器棧中引用的物件
    如各個執行緒被呼叫的,方法中的引用型別的引數、引用型別的區域性變數等,這些引用都儲存在虛擬機器棧對應棧幀的區域性變數表中。這些引用指向的物件都會被視為GC Roots中的物件。
  • 2、本地方法棧中JNI(即native方法)所引用的物件
    這些物件被傳入本地方法中進行呼叫,且都還沒有進行釋放。
  • 3、類靜態屬性引用的物件
    類靜態屬性屬於類,它隨著類的生命週期存在,而類是很少被回收的(類的回收條件剛才已提到)。如果類靜態屬性是一個引用型別,並且該引用指向一個物件,那麼該物件也會被加入到GC Roots中。
  • 4、常量所引用的物件
    執行時常量池中常量所引用的物件,字串常量池中的引用所指向的物件。
  • 5、所有被同步鎖(Synchronized關鍵字)所持有的物件。
  • 6、JVM內部的引用
    如類的class物件,又如一些異常物件(NullPointerException、OutOfMemoryError等)

除了上述這些固定的GC Roots集合外,根據使用者所選擇的垃圾收集器以及當前回收的記憶體區域的不同外,還會選擇其他物件“臨時加入”到GC Roots。

比如,在只針對新生代的回收中,可能在老年代中有些物件引用了新生代中的物件,為了避免這些被老年代中的物件所引用的新生代物件被回收,所以需要將與新生代中有關聯的老年代中的物件也臨時加入到GC Roots中。

應用可達性演算法的注意點

如果要使用可達性分析來判斷物件是否可回收,那麼分析工作必須在一個保證一致性的快照中進行。這點不滿足,分析結果就不會準確。

意思是,在進行可達性分析的期間,系統必須停止,不能出現在分析過程中,物件的引用關係還在不斷變化的現象。所謂的一致性快照就是,在JVM執行的某個時間點進行記錄,記錄此時間點JVM的所有狀態。然後才能根據這個快照進行可達性分析。這也是為什麼GC時必須進行“Stop the Word”(系統暫停)的重要原因。

3、物件的finalization機制

Java語言提供了物件的終止(finalization)機制來允許開發人員提供物件被銷燬之前的自定義邏輯處理。實現這個機制的finalize方法在Object類中,由於Object類是所有類的父類或祖先類,同時finalize方法允許在子類中被重寫,所以實際上每個類都可以實現finalize方法。

物件的finalization機制就是:
經過可達性分析後,如果某個物件無法從所有的根物件訪問,那麼說明這個物件已經不再被使用了。此時就會對這些不再被使用的物件進行第一次標記。然後,會檢視這些無法到達的物件是否實現了finalize方法:

  • 如果物件沒有實現finalize方法,則直接可以進行回收。

  • 如果物件實現了finalize方法,那麼就會將物件的finalize方法交給Finalizer執行緒來執行(一個由虛擬機器建立的,低優先順序的後臺執行緒)。GC會對這些交給Finalizer執行緒執行後的物件進行第二次標記,如果在finalize方法中,物件又重新與GC Roots進行了關聯,比如將自己(this關鍵字)賦值給某個引用鏈上的物件的屬性(如objectA = this;),那麼物件將會重新存活 ,即該物件會被移出將要進行回收的集合中。如果沒有在finalize方法中復活自己,則會被第二次標記,此時物件才可以直接被回收。

Finalizer執行緒:
Finalizer執行緒是一個後臺執行緒,用於執行finalize方法。所有實現了finalize方法的物件都會被放在一個F-Queue佇列中,Finalizer執行緒會去執行這個佇列。Finalizer執行緒執行完佇列中的一個元素,則執行緒中虛擬機器棧儲存的這個物件的引用就會被釋放,此時GC就可以根據該物件是否還與引用鏈相連線,來進行第二次標記,並決定是否回收這個物件。

我們不應該主動去實現finalize方法,因為:

①在Finalizer執行緒執行時,如果執行緒執行緩慢(比如某個物件的finalize方法有大量的迴圈),那麼其他的finalize方法就會一直處於等待狀態。這也意味著含有finalize方法的物件會一直被Finalizer執行緒所引用,那麼GC就無法回收這些物件。finalize方法會影響GC的效率,尤其是大量的finalize方法或者一個糟糕的finalize方法(如前面說的大量迴圈)。

②我們想在finalize方法中實現某種操作,比如關閉連線,但是finalize方法的執行時間是沒有保證的。Finalizer執行緒是一個優先順序很低的執行緒,則意味著不會馬上執行,它何時執行完全由GC執行緒決定,即在進行GC時,才會把finalize方法交給Finalizer執行緒去執行。如果沒有進行GC那麼Finalizer執行緒就不會執行。則我們的操作就由於執行時間的不確定而給程式帶來隱患。

所以強烈建議不再使用finalize方法!!

4、垃圾收集演算法

4.1、標記-清除演算法

在經過可達性演算法分析和finalization機制後,一個物件是否存活已經能夠判斷出來了。那麼接下來的操作就是回收死亡物件的記憶體。目前在JVM中,常見的垃圾收集演算法有三種,分別是①標記-清除演算法②標記-複製演算法③標記-整理演算法。

標記-清除演算法是一種非常基礎和常見的垃圾收集演算法,其執行過程如下:
當堆中的有效空間(available memory)被耗盡時,就會停止整個程式(STW),然後執行標記和清除的操作。

標記:採用可達性分析演算法,從引用根節點遍歷,標記所有被引用的物件,一般是在物件的物件頭(Header)中記錄為可達物件。

清除:對堆記憶體中的所有物件進行從頭到尾的線性遍歷,如果發現某個物件在其物件頭中沒有被標記為可達物件,則就將該物件所佔用的記憶體回收。

圖示如下,
在這裡插入圖片描述
可以看出,標記清除演算法的優點就是簡單易實現,但其缺點也同樣很明顯:
①效率不算高,因為標記和清除都要進行遍歷,這也意味著標記和清除兩個過程都會因為物件的增加而效率下降。
②這種方式清理出來的空閒空間是不連續的,產生了記憶體碎片問題。故需要維護一個空閒列表,才能知道新物件該如何分配記憶體。而碎片問題可能會導致,即使記憶體空間足夠,大物件依然有可能無法存放的問題。

注意:
在垃圾回收中所謂的清除,並不是真的把對應的記憶體置空,而是把需要清除的物件地址儲存在空閒的地址列表中,等有新物件需要分配記憶體空間時,會判斷垃圾物件的位置空間是否足夠。若足夠,則分配給新物件。

4.2、標記-複製演算法

為了解決標記-清除演算法的缺陷,研究出了標記-複製演算法。

其核心思想如下:
將記憶體空間分為大小相等的兩塊,每次只使用其中的一塊。在垃圾回收時,將正在使用的記憶體塊中標記為存活的物件複製到未被使用的記憶體塊中,然後一次性清理正在使用的記憶體塊中的所有物件,交換兩個記憶體塊的角色,完成垃圾回收。

圖示如下,
在這裡插入圖片描述
複製演算法的優點有:
①複製過去後保證了空間的連續性,不會出現“碎片問題”。
②實現比較簡單,不需要空閒連結串列的存在,直接移動指標分配記憶體,所以效率很高。

複製演算法的缺點有:
①可用記憶體空間縮小了一半,浪費了原來的記憶體
②由於需要複製物件至另一半空間,故有一定的空間開銷
③因為物件地址空間被改變,所以在複製過去後,還用花費一定的時間開銷來維護物件之間的引用關係。比如,如果棧中的引用指向了堆中某塊記憶體,經過複製演算法後,還要把這個引用進行修改才行。

特別地,當存活的物件很多時,複製演算法的效率就會降低,因為無論是複製物件本身的開銷還是維護物件間引用的開銷都會提高。所以,複製演算法要在垃圾物件多,而存活物件少的情況下才能發揮出優勢,否則光是複製物件就耗費了許多效能。

目前標記-複製演算法主要應用在新生代中。

在新生代中,對常規的應用程式進行垃圾回收時,通常一次可以回收70%-99%的記憶體空間,回收價效比很高。所以現在的商業虛擬機器(如HotSpot)都是採用複製演算法來回收新生代。

在這裡插入圖片描述

4.3、標記-整理演算法

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

標記-清除演算法也可以應用在老年代中,但是該演算法執行完記憶體回收還會產生記憶體碎片,故需要在標記-清除演算法上進行改進,由此研究出了標記-整理演算法。

標記-整理演算法的基本過程如下:

第一階段:即標記階段,與標記-清除演算法一樣,從根節點開始標記所有被引用的物件。

第二階段:將所有存活物件壓縮(移動)到記憶體的一端,按順序排放。

最後,清理邊界外所有的空間。

實際上,標記-整理演算法的最終效果等同於標記-清除演算法執行完成後,再進行一次記憶體碎片的整理。二者的本質差異在於,標記-清除演算法是非移動式的回收演算法,而標記-整理演算法是移動式的。

在這裡插入圖片描述
可以看到,被標記的存活物件將被整理,按照記憶體地址依次排列,而未被標記的記憶體將被清理掉。如此一來,當我們需要給新物件分配記憶體時,JVM只需要持有一個記憶體的起始地址即可,這比標記-清除演算法需要維護一個空閒列表顯然少了許多開銷。但是由於還要移動物件,所以實際上標記-整理演算法的執行效率低於標記-清除演算法。

標記-整理演算法的優點有:
①消除了標記-清除演算法中產生的碎片問題。我們需要給新物件分配記憶體時,只需要一個記憶體的起始地址即可。
②消除了複製演算法中,記憶體減半的高額代價。

標記-整理演算法的缺點有:
①從效率上看,標記-整理演算法要低於複製演算法和標記-清除演算法。
②移動物件的同時,如果物件被其他物件引用,則還要調整引用地址
③移動過程中,需要全程暫停使用者的應用程式(STW)

三種演算法的對比

標記-整理演算法(Mark-Compact) 標記-清除演算法(Mark-Sweep) 標記-複製演算法(Mark-Copying)
速度 最慢 中等 最快
空間開銷 少,不堆積碎片 少,堆積碎片 多,通常需要存活物件的2倍大小,不堆積碎片
移動物件

所以沒有最優的演算法,主要是看應用場景。

4.4、分代收集演算法

前面提到的三種垃圾收集演算法,並沒有哪一種能完全取代其他演算法,它們都具有各自的優勢和特點。同樣的這三種演算法都無法對所有型別(長生命週期、短生命週期、大物件、小物件)的物件進行回收。因此,根據不同型別的死亡物件,採用不同的垃圾收集演算法,這樣的演算法應用被稱為分代收集演算法(Generational Collection),嚴格來說分代收集演算法應該是一種垃圾收集的理論。

分代收集演算法基於這樣一個事實:不同物件的生命週期不同,因此不同生命週期的物件可以採用不同的收集方式,以便提高回收效率。分代收集演算法根據物件的不同型別將記憶體劃分為不同的區域,一般將堆劃分為新生代和老年代。

在Java程式執行中,會產生大量的物件,其中有些物件是與業務息息相關,比如Http請求中的Session物件,執行緒、Socket連線,這些物件跟業務直接掛鉤,因此生命週期較長。而有些物件的生命週期則較短,如String物件,由於其不可變的特性,系統會產生大量這些物件,有些物件甚至只使用一次即可回收。因此,使用分代垃圾收集演算法,價效比最好。

目前,幾乎所有的垃圾收集器都採用了分代收集演算法執行垃圾回收。

  • 在堆區中新生代的特點是:區域相對老年代較小,物件生命週期短,存活率低,垃圾回收頻繁。

在這種情況下,複製演算法的回收整理速度是最快的。複製演算法的效率只和當前存活物件的多少有關,因此很適合新生代的回收。而複製演算法記憶體利用率不高的問題,通過兩個survivor區的設計得到了緩解。預設情況下,新生代和老年代在堆中的比例是1:2。而新生代中Eden區和兩個survivor區的比例為8:1:1,所以實際上只有新生代記憶體中的1/10來作為複製演算法所需的空閒區域,因此浪費的記憶體空間並不算大。

  • 在堆中老年代的特點是:區域較大,物件生命週期長,存活率高,回收不如新生代頻繁。

在這種情況下,會存在大量的存活物件,複製演算法明顯不合適。故一般是由標記-清除演算法來實現或者是由標記-清除演算法和標記-整理演算法混合實現。原因如下:
①標記階段的開銷實際上是與存活物件的數量成正比(因為要遍歷所有物件)
②清除階段的開銷與所管理的區域的大小成正比(因為要遍歷所管理的記憶體區域)
③壓縮階段的開銷與存活獨享的數量成正比(因為要移動物件)

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

4.5、增量收集演算法

上述的演算法在垃圾回收過程中都不可避免的處於一種Stop The World 的狀態。在STW狀態下,程式所有的使用者執行緒都會掛起,暫停一切正常工作,等待垃圾回收的完成,如果垃圾回收時間過長,應用程式被掛起很久,將嚴重影響使用者體驗或者系統的穩定性。為了解決這一問題,即對實時垃圾收集演算法的研究直接導致了增量收集(Incremental Collecting)演算法的出現。

增量收集演算法的基本思想如下:
如果一次性將所有的垃圾進行處理,需要造成系統長時間的停頓,那麼可以讓垃圾收集執行緒和應用執行緒交替執行。每次,垃圾收集執行緒只收集一小片區域的記憶體空間,接著切換到應用程式執行緒。如此反覆,直到垃圾收集完成。

增量收集演算法的基礎仍然是傳統的標記-清除和複製演算法。增量收集演算法通過對執行緒間衝突的處理,允許垃圾收集執行緒以分階段的方式完成垃圾標記、清理或者複製工作。

增量收集演算法的優點有:
使用這種方式,由於在垃圾回收過程中,間斷性地還執行了應用程式程式碼,故減少了系統的停頓時間。

增量收集演算法的缺點有:
因為執行緒切換和上下文轉換的消耗,會使得垃圾回收的總體成本上升,造成系統吞吐量的下降。

4.6、分割槽演算法

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

分代演算法按照物件的生命週期長短劃分為兩個部分,分割槽演算法將堆空間劃分成連續的不同小區域。
每一塊小區域都獨立使用,獨立回收。這種演算法的好處是可以控制一次回收多少個小區間。

相關文章