深入理解Java虛擬機系列——JVM的GC理論詳解

GC的概念

    GC:Garbage Collection 垃圾收集。這裏所謂的垃圾指的是在系統運行過程當中所產生的一些無用的對象,這些對象佔據着一定的內存空間,如果長期不被釋放,可能導致OOM(堆溢出)。內存區域中的程序計數器、虛擬機棧、本地方法棧這3個區域隨着線程而生,線程而滅;棧中的棧幀隨着方法的進入和退出而有條不紊地執行着出棧和入棧的操作,每個棧幀中分配多少內存基本是在類結構確定下來時就已知的。在這幾個區域不需要過多考慮回收的問題,因爲方法結束或者線程結束時,內存自然就跟着回收了。而Java堆和方法區則不同,一個接口中的多個實現類需要的內存可能不同,一個方法中的多個分支需要的內存也可能不一樣,我們只有在程序處於運行期間時才能知道會創建哪些對象,這部分內存的分配和回收都是動態的,GC關注的也是這部分內存,如果涉及到“內存”分配與回收也僅指着一部分內存

回收算法

引用計數算法:(老牌垃圾回收算法。無法處理循環引用,沒有被Java採納)

 1、引用計數算法的概念:

給對象中添加一個引用計數器,每當有一個地方引用它時,計數器值就加1;當引用失效時,計數器值就減1;任何時刻計數器爲0的對象就是不可能再被使用的。


2、使用者舉例:

    引用計數算法的實現簡單,判定效率也高,大部分情況下是一個不錯的算法。很多地方應用到它。例如:

微軟公司的COM技術:Computer Object Model

使用ActionScript3的FlashPlayer

Python

    但是,主流的java虛擬機並沒有選用引用計數算法來管理內存,其中最主要的原因是:它很難解決對象之間相互循環引用的問題

3、引用計數算法的問題:

  • 引用和去引用伴隨加法和減法,影響性能
  • 致命的缺陷:對於循環引用的對象無法進行回收

上面的3個圖中,對於最右邊的那張圖而言:循環引用的計數器都不爲0,但是他們對於根對象都已經不可達了,但是無法釋放。

public class Object {

    Object field = null;
    
    public static void main(String[] args) {
        Thread thread = new Thread(new Runnable() {
            public void run() {
                Object objectA = new Object();
                Object objectB = new Object();//位置1
                objectA.field = objectB;
                objectB.field = objectA;//位置2
                //to do something
                objectA = null;
                objectB = null;//位置3
            }
        });
        thread.start();
        while (true);
    }

    上方代碼看起來有點刻意爲之,但其實在實際編程過程當中,是經常出現的,比如兩個一對一關係的數據庫對象,各自保持着對方的引用。最後一個無限循環只是爲了保持JVM不退出,沒什麼實際意義。

代碼解釋:代碼中標註了1、2、3三個數字,當位置1的語句執行完以後,兩個對象的引用計數全部爲1。當位置2的語句執行完以後,兩個對象的引用計數就全部變成了2。當位置3的語句執行完以後,也就是將二者全部歸爲空值以後,二者的引用計數仍然爲1。根據引用計數算法的回收規則,引用計數沒有歸0的時候是不會被回收的。

對於我們現在使用的GC來說,當thread線程運行結束後,會將objectA和objectB全部作爲待回收的對象。而如果我們的GC採用上面所說的引用計數算法,則這兩個對象永遠不會被回收,即便我們在使用後顯示的將對象歸爲空值也毫無作用。

根搜索算法

1、根搜索算法的概念:

  由於引用計數算法的缺陷,所以JVM一般會採用一種新的算法,叫做根搜索算法。它的處理方式就是,設立若干種根對象,當任何一個根對象到某一個對象均不可達時,則認爲這個對象是可以被回收的


    如上圖所示,ObjectD和ObjectE是互相關聯的,但是由於GC roots到這兩個對象不可達,所以最終D和E還是會被當做GC的對象,上圖若是採用引用計數法,則A-E五個對象都不會被回收。

2、可達性分析:

    我們剛剛提到,設立若干種根對象,當任何一個根對象到某一個對象均不可達時,則認爲這個對象是可以被回收的。我們在後面介紹標記-清理算法/標記整理算法時,也會一直強調從根節點開始,對所有可達對象做一次標記,那什麼叫做可達呢?這裏解釋如下:

可達性分析:

  從根(GC Roots)的對象作爲起始點,開始向下搜索,搜索所走過的路徑稱爲“引用鏈”,當一個對象到GC Roots沒有任何引用鏈相連(用圖論的概念來講,就是從GC Roots到這個對象不可達)時,則證明此對象是不可用的。

3、根(GC Roots):

說到GC roots(GC根),在JAVA語言中,可以當做GC roots的對象有以下幾種:

1、棧(棧幀中的本地變量表)中引用的對象。

2、方法區中的靜態成員。

3、方法區中的常量引用的對象(全局變量)

4、本地方法棧中JNI(一般說的Native方法)引用的對象。

注:第一和第四種都是指的方法的本地變量表,第二種表達的意思比較清晰,第三種主要指的是聲明爲final的常量值。在根搜索算法的基礎上,現代虛擬機的實現當中,垃圾蒐集的算法主要有三種,分別是標記-清除算法、複製算法、標記-整理算法。這三種算法都擴充了根搜索算法,不過它們理解起來還是非常好理解的。

標記-清除算法

1、標記清除算法的概念:

    標記-清除算法是現代垃圾回收算法的思想基礎。標記-清除算法將垃圾回收分爲兩個階段:標記階段和清除階段。一種可行的實現是,在標記階段,首先通過根節點,標記所有從根節點開始的可達對象。因此,未被標記的對象就是未被引用的垃圾對象;然後,在清除階段,清除所有未被標記的對象。


2、標記-清除算法詳解:

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

  • 標記:標記的過程其實就是,遍歷所有的GC Roots,然後將所有GC Roots可達的對象標記爲存活的對象。
  • 清除:清除的過程將遍歷堆中所有的對象,將沒有標記的對象全部清除掉。

也就是說,就是當程序運行期間,若可以使用的內存被耗盡的時候,GC線程就會被觸發並將程序暫停,隨後將依舊存活的對象標記一遍,最終再將堆中所有沒被標記的對象全部清除掉,接下來便讓程序恢復運行

來看下面這張圖:

    上圖代表的是程序運行期間所有對象的狀態,它們的標誌位全部是0(也就是未標記,以下默認0就是未標記,1爲已標記),假設這會兒有效內存空間耗盡了,JVM將會停止應用程序的運行並開啓GC線程,然後開始進行標記工作,按照根搜索算法,標記完以後,對象的狀態如下圖:


    上圖中可以看到,按照根搜索算法,所有從root對象可達的對象就被標記爲了存活的對象,此時已經完成了第一階段標記。接下來,就要執行第二階段清除了,那麼清除完以後,剩下的對象以及對象的狀態如下圖所示:


    上圖可以看到,沒有被標記的對象將會回收清除掉,而被標記的對象將會留下,並且會將標記位重新歸0。接下來就不用說了,喚醒停止的程序線程,讓程序繼續運行即可。

疑問:爲什麼非要停止程序的運行呢?

答:這個其實也不難理解,假設我們的程序與GC線程是一起運行的,各位試想這樣一種場景。

假設我們剛標記完圖中最右邊的那個對象,暫且記爲A,結果此時在程序當中又new了一個新對象B,且A對象可以到達B對象。但是由於此時A對象已經標記結束,B對象此時的標記位依然是0,因爲它錯過了標記階段。因此當接下來輪到清除階段的時候,新對象B將會被苦逼的清除掉。如此一來,不難想象結果,GC線程將會導致程序無法正常工作。

上面的結果當然令人無法接受,我們剛new了一個對象,結果經過一次GC,忽然變成null了,這還怎麼玩?

3、標記-清除算法的缺點:

(1)首先,它的缺點就是效率比較低(遞歸與全堆對象遍歷),導致stop the world的時間比較長,尤其對於交互式的應用程序來說簡直是無法接受。試想一下,如果你玩一個網站,這個網站一個小時就掛五分鐘,你還玩嗎?

(2)第二點主要的缺點,則是這種方式清理出來的空閒內存是不連續的,這點不難理解,我們的死亡對象都是隨即的出現在內存的各個角落的,現在把它們清除之後,內存的佈局自然會亂七八糟。而爲了應付這一點,JVM就不得不維持一個內存的空閒列表,這又是一種開銷。而且在分配數組對象的時候,尋找連續的內存空間會不太好找。

複製算法:(新生代的GC)

複製算法的概念:

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

  • 與標記-清除算法相比,複製算法是一種相對高效的回收方法
  • 不適用於存活對象較多的場合,如老年代(複製算法適合做新生代的GC

  • 複製算法的最大的問題是:空間的浪費

    所以從以上描述不難看出,複製算法要想使用,最起碼對象的存活率要非常低纔行,而且最重要的是,我們必須要克服50%內存的浪費。

    現在的商業虛擬機都採用這種收集算法來回收新生代,新生代中的對象98%都是“朝生夕死”的,所以並不需要按照1:1的比例來劃分內存空間,而是將內存分爲一塊比較大的Eden空間和兩塊較小的Survivor空間,每次使用Eden和其中一塊Survivor。當回收時,將Eden和Survivor中還存活着的對象一次性地複製到另外一塊Survivor空間上,最後清理掉Eden和剛纔用過的Survivor空間。HotSpot虛擬機默認Eden和Survivor的大小比例是8:1,也就是說,每次新生代中可用內存空間爲整個新生代容量的90%(80%+10%),只有10%的空間會被浪費。

    當然,98%的對象可回收只是一般場景下的數據,我們沒有辦法保證每次回收都只有不多於10%的對象存活,當Survivor空間不夠用時,需要依賴於老年代進行分配擔保,所以大對象直接進入老年代。整個過程如下圖所示:

上圖中,綠色箭頭的位置代表的是大對象,大對象直接進入老年代。

根據上面的複製算法,現在我們來看下面的這個gc日誌的數字,就應該能看得懂了吧:

    上方GC日誌中,新生代的可用空間是13824K(eden區的12288K+from space的1536K)。而根據內存的地址計算得知,新生代的總空間爲15M,而這個15M的空間是 = 13824K +to space 的 1536K。

標記-整理算法:(老年代的GC)

引入:

    如果在對象存活率較高時就要進行較多的複製操作,效率將會變低。更關鍵的是,如果不想浪費50%的空間,就需要有額外的空間進行分配擔保,以應對被使用的內存中所有對象都100%存活的極端情況,所以在老年代一般不能直接選中這種算法。

概念:

    標記-壓縮算法適合用於存活對象較多的場合,如老年代。它在標記-清除算法的基礎上做了一些優化。和標記-清除算法一樣,標記-壓縮算法也首先需要從根節點開始,對所有可達對象做一次標記;但之後,它並不簡單的清理未標記的對象,而是將所有的存活對象壓縮到內存的一端;之後,清理邊界外所有的空間。

  • 標記:它的第一個階段與標記/清除算法是一模一樣的,均是遍歷GC Roots,然後將存活的對象標記。
  • 整理:移動所有存活的對象,且按照內存地址次序依次排列,然後將末端內存地址以後的內存全部回收。因此,第二階段才稱爲整理階段。

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

標記/整理算法不僅可以彌補標記/清除算法當中,內存區域分散的缺點,也消除了複製算法當中,內存減半的高額代價。

  • 但是,標記/整理算法唯一的缺點就是效率也不高。

不僅要標記所有存活對象,還要整理所有存活對象的引用地址。從效率上來說,標記/整理算法要低於複製算法。

分代收集算法:(新生代的GC+老年代的GC)

標記-清除算法、複製算法、標記整理算法的總結:

三個算法都基於根搜索算法去判斷一個對象是否應該被回收,而支撐根搜索算法可以正常工作的理論依據,就是語法中變量作用域的相關內容。因此,要想防止內存泄露,最根本的辦法就是掌握好變量作用域,而不應該使用C/C++式內存管理方式。

在GC線程開啓時,或者說GC過程開始時,它們都要暫停應用程序(stop the world)。

它們的區別如下:(>表示前者要優於後者,=表示兩者效果一樣)

(1)效率複製算法>標記/整理算法>標記/清除算法(此處的效率只是簡單的對比時間複雜度,實際情況不一定如此)。

(2)內存整齊度:複製算法=標記/整理算法>標記/清除算法。

(3)內存利用率:標記/整理算法=標記/清除算法>複製算法。

注1:可以看到標記/清除算法是比較落後的算法了,但是後兩種算法卻是在此基礎上建立的。

注2:時間與空間不可兼得。

當前商業虛擬機的GC都是採用的“分代收集算法”,這並不是什麼新的思想,只是根據對象的存活週期的不同將內存劃分爲幾塊兒,根據上面三種算法的選取應用。。一般是把Java堆分爲新生代和老年代:短命對象歸爲新生代,長命對象歸爲老年代

  • 少量對象存活,適合複製算法:在新生代中,每次GC時都發現有大批對象死去,只有少量存活,那就選用複製算法,只需要付出少量存活對象的複製成本就可以完成GC。
  • 大量對象存活,適合用標記-清理/標記-整理:在老年代中,因爲對象存活率高、沒有額外空間對他進行分配擔保,就必須使用“標記-清理”/“標記-整理”算法進行GC。

注:老年代的對象中,有一小部分是因爲在新生代回收時,老年代做擔保,進來的對象;絕大部分對象是因爲很多次GC都沒有被回收掉而進入老年代

Minor GC和Full GC

Minor GC:

  Minor GC是發生在新生代中的垃圾收集動作,採用的是複製算法。

對象在Eden和From區出生後,在經過一次Minor GC後,如果對象還存活,並且能夠被to區所容納,那麼在使用複製算法時這些存活對象就會被複制到to區域,然後清理掉Eden區和from區,並將這些對象的年齡設置爲1,以後對象在Survivor區每熬過一次Minor GC,就將對象的年齡+1,當對象的年齡達到某個值時(默認是15歲,可以通過參數 --XX:MaxTenuringThreshold設置),這些對象就會成爲老年代。

但這也是不一定的,對於一些較大的對象(即需要分配一塊較大的連續內存空間)則是直接進入老年代

Full GC:

  Full GC是發生在老年代的垃圾收集動作,採用的是標記-清除/整理算法。

老年代裏的對象幾乎都是在Survivor區熬過來的,不會那麼容易死掉。因此Full GC發生的次數不會有Minor GC那麼頻繁,並且做一次Full GC要比做一次Minor GC的時間要長。

另外,如果採用的是標記-清除算法的話會產生許多碎片,此後如果需要爲較大的對象分配內存空間時,若無法找到足夠的連續的內存空間,就會提前觸發一次GC。

 可觸及性

所有的算法,需要能夠識別一個垃圾對象,因此需要給出一個可觸及性的定義。

可觸及的:

  從根節點可以觸及到這個對象。

    其實就是從根節點掃描,只要這個對象在引用鏈中,那就是可觸及的。

可復活的:

  一旦所有引用被釋放,就是可復活狀態

  因爲在finalize()中可能復活該對象

不可觸及的:

  在finalize()後,可能會進入不可觸及狀態

  不可觸及的對象不可能復活

   要被回收。

finalize方法復活對象的代碼舉例:


public class CanReliveObj {
    public static CanReliveObj obj;

    //當執行GC時,會執行finalize方法,並且只會執行一次
    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("CanReliveObj finalize called");
        obj = this;   //當執行GC時,會執行finalize方法,然後這一行代碼的作用是將null的object復活一下,然後變成了可觸及性
    }

    @Override
    public String toString() {
        return "I am CanReliveObj";
    }

    public static void main(String[] args) throws
            InterruptedException {
        obj = new CanReliveObj();
        obj = null;   //可復活
        System.out.println("第一次gc");
        System.gc();
        Thread.sleep(1000);
        if (obj == null) {
            System.out.println("obj 是 null");
        } else {
            System.out.println("obj 可用");
        }
        obj = null;    //不可復活
        System.out.println("第二次gc");
        System.gc();
        Thread.sleep(1000);
        if (obj == null) {
            System.out.println("obj 是 null");
        } else {
            System.out.println("obj 可用");
        }
    }
}

我們需要注意第14行的註釋。一開始,我們在第25行將obj設置爲null,然後執行一次GC,本以爲obj會被回收掉,其實並沒有,因爲GC的時候會調用11行的finalize方法,然後obj在第14行被複活了。緊接着又在第34行設置obj設置爲null,然後執行一次GC,此時obj就被回收掉了,因爲finalize方法只會執行一次,並不是gc一次執行一次


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