深入理解 Java 虛擬機(二)垃圾收集算法

程序計數器、虛擬機棧、本地方法棧的生命週期與線程相同,且一個棧幀分配多少內存在編譯期就已經確定,方法執行完畢後內存即被回收,因此在這幾個區域不需要過多考慮回收的問題。

而 Java 堆和方法區則不同,內存的分配和回收都是動態的,因此垃圾收集器所關注的是這部分內存。

對象已死嗎

引用計數算法

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

引用計數算法的實現簡單,判定效率也很高,在大部分情況下它都是一個不錯的算法。但它很難解決對象之間相互循環引用的問題,因此主流的 Java 虛擬機沒有選用引用計數算法來管理內存。

可達性分析算法

這個算法的基本思路是通過一系列稱爲 “GC Roots” 的對象作爲起始點,從這些結點開始向下搜索,搜索所經過的路徑被成爲引用鏈,當一個對象到 GC Roots 沒有任何引用鏈相連時,則證明此對象是不可用的。

可達性分析算法

在 Java 語言中,可作爲 GC Roots 的對象包括以下幾種:
1) 虛擬機棧(棧幀中的本地變量表)中引用的對象
2) 方法區中類靜態屬性引用的對象
3) 方法區中常量引用的對象
4) 本地方法棧中 JNI 引用的對象

再談引用

在 JDK 1.2 之前,Java 中的引用的定義很傳統:如果 reference 類型的數據中存儲的數值代表的是另外一塊內存的起始地址,就成這塊內存代表着一個引用。

JDK 1.2 之後,Java 對引用的概念進行了擴充,將引用分爲了以下四種:

1) 強引用。即程序代碼中最普通的,使用 new 創建的對象。只要強引用還存在,垃圾收集器永遠不會回收掉被引用的對象

2) 軟引用。Java 提供了 SoftReference 來實現軟引用。在系統將要發生內存溢出異常之前,將會把這些對象列進回收範圍之中進行第二次回收。如果這次回收還沒有足夠的內存,纔會拋出內存溢出異常。

3) 弱引用。使用 WeakReference 實現。弱引用關聯的對象只能生存到下一次垃圾收集發生之前。

4) 虛引用。使用 PhantomReference 實現。一個對象是否有虛引用,不會對其生存時間造成影響,也無法通過虛引用來獲取一個對象的示例。爲一個對象設置虛引用關聯的唯一目的是在這個對象被收集器回收時受到一個系統通知。

生存還是死亡

即使在可達性分析算法中不可達的對象,也並非是非死不可的。要真正宣告一個對象死亡,至少要經歷兩次標記過程:如果對象在進行可達性分析後發現沒有與 GC Roots 相連接的引用鏈,那麼它會被第一次標記;之後會進行一次篩選,篩選的條件是此對象是否有必要執行 finalize 方法,finalize 是對象逃脫死亡命運的最後一次機會,並且只能使用一次,如果對象沒有覆蓋 finalize 方法,或者 finalize 已經被虛擬機調用過,那麼它會被直接回收。

/**
* 此代碼演示了兩點:
* 1.對象可以在被GC時自我拯救。
* 2.這種自救的機會只有一次,因爲一個對象的finalize()方法最多隻會被系統自動調用一次
* @author zzm
*/
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();
        // 因爲Finalizer方法優先級很低,暫停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();
        // 因爲Finalizer方法優先級很低,暫停0.5秒,以等待它
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("no, i am dead :(");
        }
    }
}

任何一個對象的 finalize 方法都只會被系統自動調用一次,但建議避免使用這個方法,這個方法是 Java 設計之初爲了讓 C/C++ 程序員更容易接受而設計的,它運行的代價高昂,不確定大,無法保證各個對象的調用順序,建議完全忘掉這個方法的存在。

回收方法區

方法區(也是 HotSpot 虛擬機的永久代)的垃圾收集主要分爲兩部分:廢棄常量和無用的類。

判定一個常量是否爲廢棄常量很簡單,比如字符串 “abc”,只需檢查有沒有 String 對象爲 “abc” 即可。

但要判定一個類是否是無用的類,條件則相對苛刻許多:
1) Java 堆中不存在該類的任何實例
2) 加載該類的 ClassLoader 已經被回收
3) 該類對應的 java.land.Class 對象沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法

虛擬機可以對滿足上述 3 個條件的無用類進行回收,至於是否回收,HotSpot 還提供了 -Xnoclassgc 參數進行控制。

垃圾收集算法

標記 - 清除算法

即首先標記出所有需要回收的對象,在標記完成後統一回收所有被標記的對象:

標記 - 清除算法

它的主要不足有 2 個:
1) 效率問題,標記、清除兩個過程的效率都不高
2) 空間問題,標記、清除後會產生大量不連續的內存碎片

複製算法

複製算法是爲了解決效率問題而出現的 ,它將可用的內存按容量劃分大小相等的兩塊,當這一塊的內存用完了,就將還存活着的對象複製到另外一塊上面,然後再把已使用過的內存空間一次清理掉。這樣每次都是對整個半區進行內存回收,內存分配時也就不用考慮內存碎片等情況,實現簡單,運行高效,代價是將內存縮小爲了原來的一半,未免高了點。

複製算法

現在的商業虛擬機都採用這種收集算法來回收新生代,IBM 公司研究表明,新生代中的對象 98% 都是“朝生夕死”的,因此並不需要按照 1:1 的比例來劃分內存空間。而是將內存分爲一塊較大的 Eden 空間和兩塊較小的 Survivor 空間,每次使用 Eden 和其中一塊 Survivor,當回收時,Eden 和 Survivor 中還存活的對象一次性複製到另外一塊 Survivor 空間上,最後清理 Eden 和剛纔用過的 Survivor 空間。HotSpot 虛擬機默認 Eden 和 Survivor 的比例爲 8:1,即新生代中可用內存空間爲整個新生代容量的 90%,只有 10% 的空間會被“浪費”。當然,我們沒有辦法保證每次回收都只有不多於 10% 的對象存活,當 Survivor 空間不夠用時,需要依賴其它內存(老年代)來進行分配擔保。

標記 - 整理算法

複製收集算法在存活率較高的區域時要進行較多的複製操作,且需要有額外的空間進行分配擔保,因此老年代一般不能直接使用這種算法。根據老年代的特點,有人提出了 “標記 - 整理” 算法,讓所有存活的對象都向一端移動,然後直接清除端邊界以外的內存:

標記 - 整理算法

分代收集算法

當代商業虛擬機的垃圾收集都採用“分代收集”算法,這種算法並沒有什麼新的思想,只是根據對象存活週期的不同將內存劃分爲幾塊。一般是把 Java 堆劃分爲新生代和老年代,這樣就可以根據各個年代的特點採用最適合的收集算法。新生代中,每次垃圾收集都有大量對象死去,只有少量對象能夠存活,因此採用複製算法。老年代中對象存活率高,沒有額外空間進行分配擔保,因此必須使用標記 - 整理算法。

HotSpot 的算法實現

枚舉根節點

以可達性分析算法中從 GC Roots 節點尋找引用鏈爲例,可作爲 GC Roots 的節點主要是全局性的引用(常量或靜態屬性)與執行上下文(例如棧幀的本地變量表)。現在很多應用僅僅方法區就有幾百兆,如果要逐個檢查,必然要消耗很多時間;此外,GC 進行時必須停頓所有 Java 執行線程(Sun 把這件事稱爲 Stop The World)。

目前的主流 Java 虛擬機使用的都是準確式 GC,系統停頓後並不需要一個不漏地檢查完所有執行上下文和全局的引用位置,在 HotSpot 的實現中,是使用一組 OopMap 的數據結構來達到這個目的的,類加載完成的時候,HotSpot 就會把對象內什麼偏移量對應什麼類型的數據計算出來,在 JIT 編譯過程中,會在特定的位置記錄引用的地址,這樣在 GC 掃描時就能直接獲取這些信息了。

安全點

前面提到,HotSpot 會在“特定的位置”記錄引用信息,這些位置稱爲安全點(SafePoint)。即程序執行時並非在所有地方都能停頓下來開始 GC,只有在到達安全點的時候才能暫停。安全點的選定是以程序“是否具有讓程序長時間執行的特徵”爲標準決定的,長時間執行的最明顯特徵是序列複用,如方法調用、循環跳轉、異常跳轉等,因此具有這些功能的指令纔會產生 SafePoint。

對於 SafePoint,另一個需要考慮的問題是如何在 GC 發生時讓所有線程都跑到安全點上再停頓下來。解決方案有兩個,一個是搶先式中斷,即 GC 時首先把所有線程全部中斷,如果發現線程中斷的位置不在安全點上,就恢復線程,讓它跑到安全點的位置,現在幾乎沒有虛擬機採用這個方案。

另一個是主動式中斷,即 GC 時不直接對線程操作,僅僅簡單地設置一個標誌,各個線程執行時主動去輪詢這個標誌,標誌爲真時就自己中斷掛起,輪詢標誌的位置和安全點是重合的。

安全區域

使用 SafePoint 還沒有完美解決進入 GC 的問題,因爲程序可能不在執行狀態,即線程可能處於 Sleep 或 Blocked 狀態,此時就無法響應中斷請求。對於這種情況,就需要安全區域(Safe Region)來解決。

安全區域是指一段代碼中,引用關係不會發生變化(線程處於 Sleep 或 Blocked 的狀態時,引用關係不會發生變化),在這個區域的任意地方開始 GC 都是安全的。在線程執行到 Safe Region 中的代碼時,首先標識自己已經進入了 Safe Region,那樣,當 JVM 發起 GC 時,就不用管 Safe Region 中的線程了。當線程離開 Safe Region 時,它要檢查是否已經完成了根節點枚舉(或者整個 GC 過程),如果完成了就繼續執行線程,否則必須等待直到收到可以離開 Safe Region 的信號爲止。

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