Java GC 的那些事(1)

佔小狼發表於2016-09-05

前言

與C語言不同,Java記憶體(堆記憶體)的分配與回收由JVM垃圾收集器自動完成,這個特性深受大家歡迎,能夠幫助程式設計師更好的編寫程式碼,本文以HotSpot虛擬機器為例,說一說Java GC的那些事。

Java堆記憶體

JVM記憶體的那些事 一文中,我們已經知道Java堆是被所有執行緒共享的一塊記憶體區域,所有物件例項和陣列都在堆上進行記憶體分配。為了進行高效的垃圾回收,虛擬機器把堆記憶體劃分成新生代(Young Generation)、老年代(Old Generation)和永久代(Permanent Generation)3個區域。

新生代

新生代由 Eden 與 Survivor Space(S0,S1)構成,大小通過-Xmn引數指定,Eden 與 Survivor Space 的記憶體大小比例預設為8:1,可以通過-XX:SurvivorRatio 引數指定,比如新生代為10M 時,Eden分配8M,S0和S1各分配1M。

Eden:希臘語,意思為伊甸園,在聖經中,伊甸園含有樂園的意思,根據《舊約·創世紀》記載,上帝耶和華照自己的形像造了第一個男人亞當,再用亞當的一個肋骨創造了一個女人夏娃,並安置他們住在了伊甸園。

大多數情況下,物件在Eden中分配,當Eden沒有足夠空間時,會觸發一次Minor GC,虛擬機器提供了-XX:+PrintGCDetails引數,告訴虛擬機器在發生垃圾回收時列印記憶體回收日誌。

Survivor:意思為倖存者,是新生代和老年代的緩衝區域。
當新生代發生GC(Minor GC)時,會將存活的物件移動到S0記憶體區域,並清空Eden區域,當再次發生Minor GC時,將Eden和S0中存活的物件移動到S1記憶體區域。

存活物件會反覆在S0和S1之間移動,當物件從Eden移動到Survivor或者在Survivor之間移動時,物件的GC年齡自動累加,當GC年齡超過預設閾值15時,會將該物件移動到老年代,可以通過引數-XX:MaxTenuringThreshold 對GC年齡的閾值進行設定。

老年代

老年代的空間大小即-Xmx 與-Xmn 兩個引數之差,用於存放經過幾次Minor GC之後依舊存活的物件。當老年代的空間不足時,會觸發Major GC/Full GC,速度一般比Minor GC慢10倍以上。

永久代

在JDK8之前的HotSpot實現中,類的後設資料如方法資料、方法資訊(位元組碼,棧和變數大小)、執行時常量池、已確定的符號引用和虛方法表等被儲存在永久代中,32位預設永久代的大小為64M,64位預設為85M,可以通過引數-XX:MaxPermSize進行設定,一旦類的後設資料超過了永久代大小,就會丟擲OOM異常。

虛擬機器團隊在JDK8的HotSpot中,把永久代從Java堆中移除了,並把類的後設資料直接儲存在本地記憶體區域(堆外記憶體),稱之為元空間。

這樣做有什麼好處?

有經驗的同學會發現,對永久代的調優過程非常困難,永久代的大小很難確定,其中涉及到太多因素,如類的總數、常量池大小和方法數量等,而且永久代的資料可能會隨著每一次Full GC而發生移動。

而在JDK8中,類的後設資料儲存在本地記憶體中,元空間的最大可分配空間就是系統可用記憶體空間,可以避免永久代的記憶體溢位問題,不過需要監控記憶體的消耗情況,一旦發生記憶體洩漏,會佔用大量的本地記憶體。

ps:JDK7之前的HotSpot,字串常量池的字串被儲存在永久代中,因此可能導致一系列的效能問題和記憶體溢位錯誤。在JDK8中,字串常量池中只儲存字串的引用。

如何判斷物件是否存活

GC動作發生之前,需要確定堆記憶體中哪些物件是存活的,一般有兩種方法:引用計數法和可達性分析法。

1、引用計數法

在物件上新增一個引用計數器,每當有一個物件引用它時,計數器加1,當使用完該物件時,計數器減1,計數器值為0的物件表示不可能再被使用。

引用計數法實現簡單,判定高效,但不能解決物件之間相互引用的問題。

public class GCtest {
    private Object instance = null;
    private static final int _10M = 10 * 1 << 20;
    // 一個物件佔10M,方便在GC日誌中看出是否被回收
    private byte[] bigSize = new byte[_10M];

    public static void main(String[] args) {
        GCtest objA = new GCtest();
        GCtest objB = new GCtest();

        objA.instance = objB;
        objB.instance = objA;

        objA = null;
        objB = null;

        System.gc();
    }
}

通過新增-XX:+PrintGC引數,執行結果:

[GC (System.gc()) [PSYoungGen: 26982K->1194K(75776K)] 26982K->1202K(249344K), 0.0010103 secs]

GC日誌中可以看出objA和objB雖然相互引用,但是它們所佔的記憶體還是被垃圾收集器回收了。

2、可達性分析法

通過一系列稱為 “GC Roots” 的物件作為起點,從這些節點開始向下搜尋,搜尋路徑稱為 “引用鏈”,以下物件可作為GC Roots:

  • 本地變數表中引用的物件
  • 方法區中靜態變數引用的物件
  • 方法區中常量引用的物件
  • Native方法引用的物件

當一個物件到 GC Roots 沒有任何引用鏈時,意味著該物件可以被回收。

在可達性分析法中,判定一個物件objA是否可回收,至少要經歷兩次標記過程:

1、如果物件objA到 GC Roots沒有引用鏈,則進行第一次標記。
2、如果物件objA重寫了finalize()方法,且還未執行過,那麼objA會被插入到F-Queue佇列中,由一個虛擬機器自動建立的、低優先順序的Finalizer執行緒觸發其finalize()方法。finalize()方法是物件逃脫死亡的最後機會,GC會對佇列中的物件進行第二次標記,如果objA在finalize()方法中與引用鏈上的任何一個物件建立聯絡,那麼在第二次標記時,objA會被移出“即將回收”集合。

看看具體實現

public class FinalizerTest {
    public static FinalizerTest object;
    public void isAlive() {
        System.out.println("I'm alive");
    }

    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("method finalize is running");
        object = this;
    }

    public static void main(String[] args) throws Exception {
        object = new FinalizerTest();

        // 第一次執行,finalize方法會自救
        object = null;
        System.gc();

        Thread.sleep(500);
        if (object != null) {
            object.isAlive();
        } else {
            System.out.println("I'm dead");
        }

        // 第二次執行,finalize方法已經執行過
        object = null;
        System.gc();

        Thread.sleep(500);
        if (object != null) {
            object.isAlive();
        } else {
            System.out.println("I'm dead");
        }
    }
}

執行結果:

method finalize is running
I'm alive
I'm dead

從執行結果可以看出:

第一次發生GC時,finalize方法的確執行了,並且在被回收之前成功逃脫;
第二次發生GC時,由於finalize方法只會被JVM呼叫一次,object被回收。

當然了,在實際專案中應該儘量避免使用finalize方法。

Java GC 的那些事(1)

Java GC的那些事(2)

相關文章