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


寫在前面的話:本文是在觀看尚硅谷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產生的停頓。

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


免責聲明!

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



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