深入理解JVM虛擬機器-物件引用,GC與記憶體分配回收

王廷駿發表於2019-05-08

1 物件的生存和死亡

在堆裡面存放著Java世界中幾乎所有的物件例項,垃圾收集器在對堆進行回收前,第一件事情就是要確定這些物件之中哪些還“存活”著,哪些已經“死去”(即不可能再被任何途徑使用的物件),那在GC是如何判斷一個物件是否存活還是死亡呢?

1.1 引用計數演算法(Reference Counting)

很多教科書判斷物件是否存活的演算法是這樣的:給物件中新增一個引用計數器,每當有一個地方引用它時,計數器值就加1;當引用失效時,計數器值就減1;任何時刻計數器為0的物件就是不可能再被使用的。這就是引用計數演算法(Reference Counting)。 引用計數演算法(Reference Counting)的實現簡單,判定效率也很高,但主流的Java虛擬機器裡面沒有選用引用計數演算法來管理記憶體,其中最主要的原因是它很難解決物件之間相互迴圈引用的問題。

相互迴圈引用即為物件A中拿著物件B的引用,物件B中拿著物件A的引用。

1.2 可達性分析演算法(Reachability Analysis)

在java中,是通過可達性分析(Reachability Analysis)來判定物件是否存活的。這個演算法的基本思路就是通過一系列的稱為“GC Roots”的物件作為起始點,從這些節點開始向下搜尋,搜尋所走過的路徑稱為引用鏈(Reference Chain),當一個物件到GC Roots沒有任何引用鏈相連時,則證明此物件是不可用的。 如圖示,物件object 5、object 6、object 7雖然互相有關聯,但是它們到GC Roots是不可達 的,所以它們將會被判定為是可回收的物件。

在這裡插入圖片描述
在Java語言中,可作為GC Roots的物件包括下面幾種:

  1. 虛擬機器棧(棧幀中的本地變數表)中引用的物件。
  2. 方法區中類靜態屬性引用的物件。
  3. 方法區中常量引用的物件。
  4. 本地方法棧中JNI(即一般說的Native方法)引用的物件。

1.3 物件引用

在JDK1.2之前,Java中的引用的定義是如果reference型別的資料中儲存的數值代表的是另外一塊記憶體的起始地址,就稱這塊記憶體代表著一個引用。這種定義太過狹隘,一個物件在這種定義下只有被引用或者沒有被引用兩種狀態。 在JDK 1.2之後,Java對引用的概念進行了擴充,將引用分為** 強引用(Strong Reference)、軟引用(Soft Reference)、弱引用(Weak Reference)、虛引用(Phantom Reference) 4種**,這4種引用強度依次逐漸減弱。

  1. 強引用:是指在程式程式碼之中普遍存在的,類似“Object obj=new Object()”這類的引 用,只要強引用還存在,垃圾收集器永遠不會回收掉被引用的物件。
  2. 軟引用:是用來描述一些還有用但並非必需的物件。對於軟引用關聯著的物件,在系統將 要發生記憶體溢位異常之前,將會把這些物件列進回收範圍之中進行第二次回收。如果這次回 收還沒有足夠的記憶體,才會丟擲記憶體溢位異常。在JDK 1.2之後,提供了SoftReference類來實現軟引用。
  3. 弱引用:也是用來描述非必需物件的,但是它的強度比軟引用更弱一些,被弱引用關聯的物件只能生存到下一次垃圾收集發生之前。在JDK 1.2之後,提供了WeakReference類來實現弱引用。
  4. 虛引用:也稱為幽靈引用或者幻影引用,它是最弱的一種引用關係。一個物件是否有虛引 用的存在,完全不會對其生存時間構成影響,也無法通過虛引用來取得一個物件例項。為一 個物件設定虛引用關聯的唯一目的就是能在這個物件被收集器回收時收到一個系統通知。在 JDK 1.2之後,提供了PhantomReference類來實現虛引用。

1.4 死亡與自救

真正宣告一個物件死亡,至少要經歷兩次標記過程:如果物件在進行可達性分析後發現沒有與GC Roots相連線的引用鏈,那它將會被第一次標記並且進行一次篩選,篩選的條件是此物件是否有必要執行finalize()方法。當物件沒有覆蓋finalize()方法,或者finalize()方法已經被虛擬機器呼叫過,這兩種情況下物件都會被回收。 如果這個物件被判定為有必要執行finalize()方法,那麼這個物件將會放置在一個叫做F-Queue的佇列之中,並在稍後由一個由虛擬機器自動建立的、低優先順序的Finalizer執行緒去觸發這個方法,但並不承諾會等待它執行結束,這樣做的原因是,如果一個物件在finalize()方法中執行緩慢,或者發生了死迴圈,將很可能會導致F-Queue佇列中其他物件永久處於等待,甚至導致整個記憶體回收系統崩潰。

package com.wtj.myjvm;

/**
 * Created on 2019/4/11.
 *
 * @author wangtingjun
 * @since 1.0.0
 */

/**
 * 1.物件可以在被GC時自我拯救。
 * 2.這種自救的機會只有一次,因為一個物件的finalize()方法最多隻會被系統自動呼叫一次
 *
 * @author wtj
 */
public class FinalizeEscapeGC {
    public static FinalizeEscapeGC SAVE_HOOK = null;

    public void isAlive() {
        System.out.println("yes,i am still alive:)");
    }

    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("finalize mehtod executed!");
        FinalizeEscapeGC.SAVE_HOOK = this;
    }

    public static void main(String[] args) throws Throwable {
        SAVE_HOOK = new FinalizeEscapeGC();
        //物件第一次成功拯救自己
        SAVE_HOOK = null;
        System.gc();
        //因為finalize方法優先順序很低,所以暫停0.5秒以等待它
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("no,i am dead:(");
        }
        //下面這段程式碼與上面的完全相同,但是這次自救卻失敗了
        SAVE_HOOK = null;
        System.gc();
        //因為finalize方法優先順序很低,所以暫停0.5秒以等待它
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("no,i am dead:(");
        }
    }
}
複製程式碼

執行結果:

finalize mehtod executed!
yes,i am still alive:)
no,i am dead:(
複製程式碼

從上面的程式碼可以看出,物件的finalize()方法確實被GC收集器觸發過,並且在被收集前成功逃脫了。 物件的finalize()方法最多隻會被系統自動呼叫一次。

2 垃圾收集演算法

2.1 標記-清除演算法

標記-清除演算法是最基礎的收集演算法之一,演算法分為“標記”和“清除”兩個階段:首先標記出所有需要回收的物件,在標記完成後統一回收所有被標記的物件。 它的主要不足有兩個:一個是效率問題,標記和清除兩個過程的效率都不高;另一個是空間問題,標記清除之後會產生大量不連續的記憶體碎片,空間碎片太多可能會導致以後在程式執行過程中需要分配較大物件時,無法找到足夠的連續記憶體而不得不提前觸發另一次垃圾收集動作。

2.2 複製演算法

它將可用記憶體按容量劃分為大小相等的兩塊,每次只使用其中的一塊。當這一塊的記憶體用完了,就將還存活著的物件複製到另外一塊上面,然後再把已使用過的記憶體空間一次清理掉。 這樣使得每次都是對整個半區進行記憶體回收,記憶體分配時也就不用考慮記憶體碎片等複雜情況,只要移動堆頂指標,按順序分配記憶體即可,執行高效。但代價是將記憶體縮小為原來的二分之一。 現在的商業虛擬機器都採用這種收集演算法來回收新生代,研究表明,新生代中的物件98%是“朝生夕死”的,所以並不需要按照1:1的比例來劃分記憶體空間,而是將記憶體分為一塊較大的Eden空間和兩塊較小的Survivor空間,每次使用Eden和其中一塊Survivor。當回收時,將Eden和Survivor中還存活著的物件一次性地複製到另外一塊Survivor空間上,最後清理掉Eden和剛才用過的Survivor空間。HotSpot虛擬機器預設Eden和Survivor的大小比例是8:1,也就是每次新生代中可用記憶體空間為整個新生代容量的90%(80%+10%),只有10%的記憶體會被“浪費”。 但是沒有辦法保證每次回收都只有不多於10%的物件存活,當Survivor空間不夠用時,需要依賴其他記憶體進行分配擔保(Handle Promotion)。即如果另外一塊Survivor空間沒有足夠空間存放上一次新生代收集下來的存活物件時,這些物件將直接通過分配擔保機制進入老年代

2.3 標記-整理演算法(Mark-Compact)

標記-整理演算法的標記過程仍然與“標記-清除”演算法一樣,但後續步驟不是直接對可回收物件進行清理,而是讓所有存活的物件都向一端移動,然後直接清理掉端邊界以外的記憶體。

2.4 分代收集演算法(Generational Collection)

當前商業虛擬機器的垃圾收集都採用“分代收集”,是根據物件存活週期的不同將記憶體劃分為幾塊。一般是把Java堆 分為新生代和老年代,這樣就可以根據各個年代的特點採用最適當的收集演算法。在新生代中,每次垃圾收集時都發現有大批物件死去,只有少量存活,那就選用複製演算法,只需要付出少量存活物件的複製成本就可以完成收集。而老年代中因為物件存活率高、沒有額外空間對它進行分配擔保,就必須使用“標記—清理”或者“標記—整理”演算法來進行回收。

3 HotSpot虛擬機器的演算法

3.1 列舉根節點OopMap

HotSpot虛擬機器使用可達性分析演算法來判斷一個物件的存活狀態,如果逐個檢查每個GC Roots節點,勢必會消耗很多時間。並且這項分析工作必須在一個能確保一致性的快照中進行,即在整個分析期間整個執行系統看起來就像被凍結在某個時間點上,不可以出現分析過程中物件引用關係還在不斷變化的情況,該點不滿足的話分析結果準確性就無法得到保證。這點是導致GC進行時必須停頓所有Java執行執行緒(Sun將這件事情稱為“Stop The World”)的其中一個重要原因。 在HotSpot的實現中,是使用一組稱為OopMap的資料結構存放著物件引用,在類載入完成的時候,HotSpot就把物件內什麼偏移量上是什麼型別的資料計算出來,在JIT編譯過程中,也會在特定的位置記錄下棧和暫存器中哪些位置是引用。這樣,GC在掃描時就可以直接得知這些資訊了。

3.2 安全點 Safepoint

HotSpot通過OopMap可以快速的完成根節點的列舉,OopMap內容變化的指令非常多,如果為每一 條指令都生成對應的OopMap,那將會需要大量的額外空間,這樣GC的空間成本將會變得很高。 實際上,HotSpot只是在“特定的位置”記錄了這些資訊,這些位置稱為安全點(Safepoint),即程式執行時並非在所有地方都能停頓下來開始GC,只有在到達安全點時才能暫停。 安全點的選定基本上是以程式“是否具有讓程式長時間執行的特徵”為標準進行選定的,例如方法呼叫、迴圈跳轉、異常跳轉等,所以具有這些功能的指令才會產生Safepoint。

4 HotSpot使用的GC與記憶體分配回收

我使用的JDK版本是JDK1.8,通過命令java -XX:+PrintCommandLineFlags -version可以檢視到虛擬機器預設使用的垃圾收集器。

在這裡插入圖片描述
從引數-XX:+UseParallelGC可以看出,預設使用的垃圾收集器是Parallel Scavenge(新生代)+ Serial Old(老年代)

4.1 Parallel Scavenge

Parallel Scavenge收集器是一個新生代收集器,他使用的是複製演算法,並且是多執行緒的收集器。Parallel Scavenge的目標是打到一個可控制的吞吐量,也就是CPU用於執行使用者程式碼的時間與CPU總耗時時間的比, Parallel Scavenge收集器也被稱為“吞吐量優先”收集器。 Parallel Scavenge收集器提供了兩個引數用於精確控制吞吐量,分別是控制最大垃圾收集停頓時間的-XX:MaxGCPauseMillis引數以及直接設定吞吐量大小的-XX:GCTimeRatio引數。也可以通過引數-XX:+UseAdaptiveSizePolicy來開啟自適應的調節策略,虛擬機器會根據當前系統的執行情況收集效能監控資訊進行動態分配。

4.2 Serial Old

Serial Old是Serial收集器的老年代版本,它同樣是一個單執行緒收集器,使用“標記-整理”演算法。

4.3 CMS(Concurrent Mark Sweep)

CMS(Concurrent Mark Sweep)收集器是一種以獲取最短回收停頓時間為目標的收集器。目前很大一部分的Java應用集中在網際網路站或者B/S系統的服務端上,這類應用尤其重視服務的響應速度,希望系統停頓時間最短,以給使用者帶來較好的體驗。CMS收集器就非常符合這類應用的需求。 CMS收集器是基於“標記—清除”演算法實現的,運作分為四個步驟:初始標記(CMS initial mark)併發標記(CMS concurrent mark)重新標記(CMS remark)併發清除(CMS concurrent sweep)。其中併發標記和併發清除最為耗時,但可以與使用者執行緒一起工作。

4.4 記憶體的分配與回收

java程式在執行的時候,會建立大量的物件,這些物件的生命週期可不相同,生命週期短的物件主要被分配在新生代的Eden(伊甸園)區上,少數情況下會被分配在老年代,分配規則並不百分百固定,對於生命週期長的物件則被分配在老年代。 年輕代:是主要存放新建的物件,年輕代的垃圾回收比較頻繁。年輕代中分為一個Eden區和兩個survior區(from和to),比例為8:1。年輕代中的物件壽命較短,使用的是複製演算法。 在GC開始的時候,物件只會存在於Eden區和名為“From”的Survivor區,Survivor區“To”是空的。緊接著進行GC,Eden區中所有存活的物件都會被複制到“To”,而在“From”區中,仍存活的物件會根據他們的年齡值來決定去向。年齡達到一定值(年齡閾值,可以通過-XX:MaxTenuringThreshold來設定)的物件會被移動到年老代中,沒有達到閾值的物件會被複制到“To”區域。經過這次GC後,Eden區和From區已經被清空。這個時候,“From”和“To”會交換他們的角色,也就是新的“To”就是上次GC前的“From”,新的“From”就是上次GC前的“To”。不管怎樣,都會保證名為To的Survivor區域是空的。Minor GC會一直重複這樣的過程,直到“To”區被填滿,“To”區被填滿之後,會將所有物件移動到年老代中。

在這裡插入圖片描述
年老代:老年代,用於存放新生代中經過多次垃圾回收仍然存活的物件,也有可能是新生代分配不了記憶體的大物件會直接進入老年代。經過多次垃圾回收且年齡達到MaxTenuringThreshold的物件就會放入到老年代。 當老年代被放滿的之後,虛擬機器會進行垃圾回收,稱之為Major GC。由於Major GC除併發GC外均需對整個堆進行掃描和回收,因此又稱為Full GC,Full GC會導致程式的暫時停止,大量的Full GC會導致系統響應速度降低,而且引來巨大的風險Perm Gen(永久代) (JDK1.8之後被元空間替代):Perm Gen全稱是Permanent Generation space,稱之為永久代,其實就是這個方法區,只不過在HotSpot虛擬機器中常被稱為永久代。

部分參考:www.cnblogs.com/haitaofeiya…

相關文章