24-垃圾回收算法有哪些?

說到 Java 虛擬機不得不提的一個詞就是**“垃圾回收”(GC,Garbage Collection)**,而垃圾回收的執行速度則影響着整個程序的執行效率,所以我們需要知道更多關於垃圾回收的具體執行細節,以便爲我們選擇合適的垃圾回收器提供理論支持。

我們本課時的面試題是,如何判斷一個對象是否“死亡”?垃圾回收的算法有哪些?

典型回答

垃圾回收器首先要做的就是,判斷一個對象是存活狀態還是死亡狀態,死亡的對象將會被標識爲垃圾數據並等待收集器進行清除。

判斷一個對象是否爲死亡狀態的常用算法有兩個:引用計數器算法和可達性分析算法。

引用計數算法(Reference Counting) 屬於垃圾收集器最早的實現算法了,它是指在創建對象時關聯一個與之相對應的計數器,當此對象被使用時加 1,相反銷燬時 -1。當此計數器爲 0 時,則表示此對象未使用,可以被垃圾收集器回收。

引用計數算法的優缺點很明顯,其優點是垃圾回收比較及時,實時性比較高,只要對象計數器爲 0,則可以直接進行回收操作;而缺點是無法解決循環引用的問題,比如以下代碼:

class CustomOne {
    private CustomTwo two;
    public CustomTwo getCustomTwo() {
        return two;
    }
    public void setCustomTwo(CustomTwo two) {
        this.two = two;
    }
}
class CustomTwo {
    private CustomOne one;
    public CustomOne getCustomOne() {
        return one;
    }
    public void setCustomOne(CustomOne one) {
        this.one = one;
    }
}
public class RefCountingTest {
    public static void main(String[] args) {
        CustomOne one = new CustomOne();
        CustomTwo two = new CustomTwo();
        one.setCustomTwo(two);
        two.setCustomOne(one);
        one = null;
        two = null;
    }
}

即使 one 和 two 都爲 null,但因爲循環引用的問題,兩個對象都不能被垃圾收集器所回收。

可達性分析算法(Reachability Analysis) 是目前商業系統中所採用的判斷對象死亡的常用算法,它是指從對象的起點(GC Roots)開始向下搜索,如果對象到 GC Roots 沒有任何引用鏈相連時,也就是說此對象到 GC Roots 不可達時,則表示此對象可以被垃圾回收器所回收,如下圖所示:
在這裏插入圖片描述
當確定了對象的狀態之後(存活還是死亡)接下來就是進行垃圾回收了,垃圾回收的常見算法有以下幾個:

  • 標記-清除算法;
  • 標記-複製算法;
  • 標記-整理算法。

標記-清除(Mark-Sweep)算法屬於最早的垃圾回收算法,它是由標記階段和清除階段構成的。標記階段會給所有的存活對象做上標記,而清除階段會把沒有被標記的死亡對象進行回收。而標記的判斷方法就是前面講的引用計數算法和可達性分析算法。

標記-清除算法的執行流程如下圖所示
在這裏插入圖片描述
從上圖可以看出,標記-清除算法有一個最大的問題就是會產生內存空間的碎片化問題,也就是說標記-清除算法執行完成之後會產生大量的不連續內存,這樣當程序需要分配一個大對象時,因爲沒有足夠的連續內存而導致需要提前觸發一次垃圾回收動作。

標記-複製算法是標記-清除算法的一個升級,使用它可以有效地解決內存碎片化的問題。它是指將內存分爲大小相同的兩塊區域,每次只使用其中的一塊區域,這樣在進行垃圾回收時就可以直接將存活的東西複製到新的內存上,然後再把另一塊內存全部清理掉。這樣就不會產生內存碎片的問題了,其執行流程如下圖所示:
在這裏插入圖片描述
標記-複製的算法雖然可以解決內存碎片的問題,但同時也帶來了新的問題。因爲需要將內存分爲大小相同的兩塊內存,那麼內存的實際可用量其實只有原來的一半,這樣此算法導致了內存的可用率大幅降低了。

標記-整理算法的誕生晚於標記-清除算法和標記-複製算法,它也是由兩個階段組成的:標記階段和整理階段。其中標記階段和標記-清除算法的標記階段一樣,不同的是後面的一個階段,標記-整理算法的後一個階段不是直接對內存進行清除,而是把所有存活的對象移動到內存的一端,然後把另一端的所有死亡對象全部清除,執行流程圖如下圖所示:
在這裏插入圖片描述

考點分析

本題目考察的是關於垃圾收集的一些理論算法問題,都屬於概念性的問題,只要深入理解之後還是挺容易記憶的。和此知識點相關的面試題還有這些:

  • Java 中可作爲 CG Roots 的對象有哪些?
  • 說一下死亡對象的判斷細節?

知識擴展

CG Roots

在 Java 中可以作爲 CG Roots 的對象,主要包含以下幾個:

  • 所有被同步鎖持有的對象,比如被 synchronize 持有的對象;
  • 字符串常量池裏的引用(String Table);
  • 類型爲引用類型的靜態變量;
  • 虛擬機棧中引用對象;
  • 本地方法棧中的引用對象。

死亡對象判斷

當使用可達性分析判斷一個對象不可達時,並不會直接標識這個對象爲死亡狀態,而是先將它標記爲“待死亡”狀態再進行一次校驗。校驗的內容就是此對象是否重寫了 finalize() 方法,如果該對象重寫了 finalize() 方法,那麼這個對象將會被存入到 F-Queue 隊列中,等待 JVM 的 Finalizer 線程去執行重寫的 finalize() 方法,在這個方法中如果此對象將自己賦值給某個類變量時,則表示此對象已經被引用了。因此不能被標識爲死亡狀態,其他情況則會被標識爲死亡狀態。

以上流程對應的示例代碼如下:

public class FinalizeTest {
    // 需要狀態判斷的對象
    public static FinalizeTest Hook = null;
    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("執行了 finalize 方法");
        FinalizeTest.Hook = this;
    }
    public static void main(String[] args) throws InterruptedException {
        Hook = new FinalizeTest();
        // 卸載對象,第一次執行 finalize()
        Hook = null;
        System.gc();
        Thread.sleep(500); // 等待 finalize() 執行
        if (Hook != null) {
            System.out.println("存活狀態");
        } else {
            System.out.println("死亡狀態");
        }
        // 卸載對象,與上一次代碼完全相同
        Hook = null;
        System.gc();
        Thread.sleep(500); // 等待 finalize() 執行
        if (Hook != null) {
            System.out.println("存活狀態");
        } else {
            System.out.println("死亡狀態");
        }
    }
}

上述代碼的執行結果爲:

執行了 finalize 方法
存活狀態
死亡狀態

從結果可以看出,卸載了兩次對象,第一次執行了 finalize() 方法,成功地把自己從待死亡狀態拉了回來;而第二次同樣的代碼卻沒有執行 finalize() 方法,從而被確認爲了死亡狀態,這是因爲任何對象的 finalize() 方法都只會被系統調用一次

雖然可以從 finalize() 方法中把自己從死亡狀態“拯救”出來,但是不建議這樣做,因爲所有對象的 finalize() 方法只會執行一次。因此同樣的代碼可能產生的結果是不同的,這樣就給程序的執行帶來了很大的不確定性。

小結

本課時講了對象狀態判斷的兩種算法:引用計數算法和可達性分析算法。其中引用計數算法無法解決循環引用的問題,因此對於絕大多數的商業系統來說使用的都是可達性分析算法;同時還講了垃圾回收的三種算法:標記-清除算法、標記-複製算法、標記-整理算法,其中,標記-清除算法會帶來內存碎片的問題,而標記-複製算法會降低內存的利用率。所以,標記-整理算法算是一個不錯的方案。

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