[深入理解Java虛擬機器]第三章 物件存活判定演算法

Coding-lover發表於2015-10-05

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

引用計數演算法

很多教科書判斷物件是否存活的演算法是這樣的:給物件中新增一個引用計數器,每當有—個地方引用它時,計數器值就加1 ; 當引用失效時,計數器值就減1 ; 任何時刻計數器為0的物件就是不可能再被使用的。

客觀地說,引用計數演算法( Reference Counting ) 的實現簡單,判定效率也很高,在大部分情況下它都是一個不錯的演算法,也有一些比較著名的應用案例,例如微軟公司的COM ( Component Object Model ) 技術、使用ActionScript 3的FlashPlayer、Python吾言和在遊戲指令碼領域被廣泛應用的Squirrel中都使用了引用計數演算法進行記憶體管理。但是 ,至少主流的Java虛擬機器裡面沒有選用引用計數演算法來管理記憶體,其中最主要的原因是它很難解決物件之間相互迴圈引用的問題。

舉個簡單的例子,請看程式碼清單3-1中的testGC() 方法:物件objA和objB都有欄位 instance , 賦值令objA.instance=objB及objB.instance=objA ,除此之外,這兩個物件再無任何引用 ,實際上這兩個物件已經不可能再被訪問,但是它們因為互相引用著對方,導致它們的引用計數都不為0 ,於是引用計數演算法無法通知GC收集器回收它們。

/**
 * testGC()方法執行後,objA和objB會不會被GC呢? 
 * @author zzm
 */
public class ReferenceCountingGC {

    public Object instance = null;

    private static final int _1MB = 1024 * 1024;

    /**
     * 這個成員屬性的唯一意義就是佔點記憶體,以便在能在GC日誌中看清楚是否有回收過
     */
    private byte[] bigSize = new byte[2 * _1MB];

    public static void testGC() {
        ReferenceCountingGC objA = new ReferenceCountingGC();
        ReferenceCountingGC objB = new ReferenceCountingGC();
        objA.instance = objB;
        objB.instance = objA;

        objA = null;
        objB = null;

        // 假設在這行發生GC,objA和objB是否能被回收?
        System.gc();
    }
}

執行結果:


[Fu11 GC(System)[Tenured:0 K->210K(10240K),0.0149142 secs]4603K->21OK(19456K),[Perm:2999K-> 2999K(2124 8K )] ,0.0150007 secs] [Times :user=0.01 sys=0.00 ,real=0.02 secs ]
Heap
def new generation total 9216K,used 82K[0x00000000055e0000 ,0x0000000005feO000 ,0x0000000005feOO00 )
Eden space 8192K ,llused[0x00000000055e0000 ,0x00000000055f4850 ,0x0000000005de0000 )
from space 1024K, Olusedf0x0000000005de0000 ,0x0000000005de0000 ,0x0000000005ee0000 )
to space 1024K ,0lused[0x0000000005ee0000 ,0x0000000005ee0000 ,0x0000000005fe0000 )
tenured generation total 1024OK,used 21OK[0x0000000005feO000 ,0x00000000069e0000 ,0x00000000069e0000 ) the space 10240K ,2lused[0x0000000005fe0000 ,0x0000000006014al8 ,0x0000000006014cO0 ,0x00000000069e0000 ) compacting perm gen total 21248K,used 3016K[0x00000000069e0000 ,0x0000000007ea0000 ,0x00000000ObdeO000 ) the space 21248K ,14lused[0x00000000069e0000 ,0x0000000006cd2398 ,0x0000000006cd2400 ,0x0000000007ea0000 ) Mo shared spaces configured.

從執行結果中可以清楚看到,GC日誌中包含“4603K->210K”,意味著虛擬機器並沒有因為這兩個物件互相引用就不回收它們,這也從側面說明虛擬機器並不是通過引用計數演算法來判斷物件是否存活的。

可達性分析演算法

在主流的商用程式語言(Java、C # ,甚至包括前面提到的古老的Lisp ) 的主流實現中, 都是稱通過可達性分析( Reachability Analysis)來判定物件是否存活的。這個演算法的基本思路就是通過一系列的稱為“GC Roots”的物件作為起始點,從這些節點開始向下搜尋,搜尋所走過的路徑稱為引用鏈( Reference Chain ) ,當一個物件到GC Roots沒有任何引用鏈相連 (用圖論的話來說,就是從GC Roots到這個物件不可達)時,則證明此物件是不可用的。如圖3-1所示,物件object 5、 object 6、 object 7雖然互相有矣聯,但是它們到GC Roots是不可達的 ,所以它們將會被判定為是可回收的物件。

在Java語言中,可作為GC Roots的物件包括下面幾種:

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

再談引用

無論是通過引用計數演算法判斷物件的引用數量,還是通過可達性分析演算法判斷物件的引用鏈是否可達,判定物件是否存活都與“引用”有關。在JDK 1.2以前,Java中的引用的定義很傳統 :如果reference型別的資料中儲存的數值代表的是另外一塊記憶體的起始地址,就稱這塊記憶體代表著一個引用。這種定義很純粹,但是太過狹隘,一個物件在這種定義下只有被引用或者沒有被引用兩種狀態,對於如何描述一些“食之無味,棄之可惜”的物件就顯得無能為力。我們希望能描述這樣一類物件:當記憶體空間還足夠時,則能保留在記憶體之中;如果記憶體空間在進行垃圾收集後還是非常緊張,則可以拋棄這些物件。很多系統的快取功能都符合這樣的應用場景。

在JDK 1.2之後 ,Java對引用的概念進行了擴充,將引用分為強引用(Strong Reference )、軟引用(Soft Reference )、弱引用(Weak Reference )、虛引用(Phantom Reference) 4種 , 引用強度依次逐漸減弱。

強引用就是指在程式程式碼之中普遍存在的,類似“Object obj=new Object ( ) ”這類的引 用 ,只要強引用還存在,垃圾收集器永遠不會回收掉被引用的物件。

軟引用是用來描述一些還有用但並非必需的物件。對於軟引用關聯著的物件,在系統將要發生記憶體溢位異常之前,將會把這些物件列進回收範圍之中進行第二次回收。如果這次回收還沒有足夠的記憶體,才會丟擲記憶體溢位異常。在JDK 1.2之後,提供了SoftReference類來實現軟引用。

弱引用也是用來描述非必需物件的,但是它的強度比軟引用更弱一些,被弱引用關聯的物件只能生存到下一次垃圾收集發生之前。當垃圾收集器工作時,無論當前記憶體是否足夠, 都會回收掉只被弱引用關聯的物件。在JDK 1.2之後 ,提供了WeakReference類來實現弱引用。

虛引用也稱為幽靈引用或者幻影引用,它是最弱的一種引用關係。一個物件是否有虛引用的存在,完全不會對其生存時間構成影響,也無法通過虛引用來取得一個物件例項。為一個物件設定虛引用關聯的唯一目的就是能在這個物件被收集器回收時收到一個系統通知。在JDK1.2之後,提供了PhantomReference類來實現虛引用。

生存還是死亡

即使在可達性分析演算法中不可達的物件,也並非是“非死不可”的 ,這時候它們暫時處於“緩刑” 階段 ,要真正宣告一個物件死亡 ,至少要經歷兩次標記過程 : 如 果物件在進行可達性分析後發現沒有與GC Roots相連線的引用鏈,那它將會被第一次標記並且進行一次篩選, 篩選的條件是此物件是否有必要執行finalize() 方法。當物件沒有覆蓋finalize() 方法,或 者finalize() 方法已經被虛擬機器呼叫過,虛擬機器將這兩種情況都視為“沒有必要執行”。

如果這個物件被判定為有必要執行finalize() 方法,那麼這個物件將會放置在一個叫做 F-Queue的佇列之中,並在稍後由一個由虛擬機器自動建立的、低優先順序的Finalizer執行緒去執行它。這裡所謂的“執行”是指虛擬機器會觸發這個方法,但並不承諾會等待它執行結束,這樣做的原因是,如果一個物件在finalize() 方法中執行緩慢,或者發生了死迴圈(更極端的情況 ),將很可能會導致F-Queue佇列中其他物件永久處於等待,甚至導致整個記憶體回收系統崩潰。finalize() 方法是物件逃脫死亡命運的最後一次機會,稍後GC將對F-Queue中的物件進行第二次小規模的標記,如果物件要在finalize ( ) 中成功拯救自己——只要重新與引用鏈 上的任何一個物件建立關聯即可,譬如把自己(this關鍵字)賦值給某個類變數或者物件的成員變 量 ,那在第二次標記時它將被移除出“即將回收” 的集合 ;如果物件這時候還沒有逃脫,那基本上它就真的被回收了。從程式碼清單3-2中我們可以看到一個物件的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 mehtod executed ! 
yes,i am still alive : ) 
no,i am dead : (

從程式碼清單3-2的執行結果可以看出,SAVE_HOOK物件的finalize ( ) 方法確實被GC收集器觸發過,並且在被收集前成功逃脫了。

另一個值得注意的地方是,程式碼中有兩段完全一樣的程式碼片段,執行結果卻是一次逃脫成功,一次失敗,這是因為任何一個物件的finalize()方法都只會被系統自動呼叫一次, 如果物件面臨下一次回收,它的finalize() 方法不會被再次執行,因此第二段程式碼的自救行動失敗了。

需要特別說明的是,上面關於物件死亡時finalize ( ) 方法的描述可能帶有悲情的藝術色彩 ,筆者並不鼓勵大家使用這種方法來拯救物件。相反 ,筆者建議大家儘量避免使用它,因為它不是C/C++中的解構函式,而是Java剛誕生時為了使C/C++程式設計師更容易接受它所做出的一個妥協。它的執行代價高昂,不確定性大,無法保證各個物件的呼叫順序。有些教材中描述它適合做“關閉外部資源”之類的工作,這完全是對這個方法用途的一種自我安慰。 finalize ( ) 能做的所有工作,使用try-finally或者其他方式都可以做得更好、更及時,所以筆者建議大家完全可以忘掉Java語言中有這個方法的存在。

回收方法區

很多人認為方法區(或者HotSpot虛擬機器中的永久代)%2���沒有垃圾收集的,Java虛擬機器規範中確實說過可以不要求虛擬機器在方法區實現垃圾收集,而且在方法區中進行垃圾收集 的“價效比” 一般比較低:在堆中 ,尤其是在新生代中,常規應用進行一次垃圾收集一般可以回收70%〜95%的空間,而永久代的垃圾收集效率遠低於此。

永久代的垃圾收集主要回收兩部分內容:廢棄常量和無用的類。回收廢棄常量與回收Java堆中的物件非常類似。以常量池中字面量的回收為例,假如一個字串“abc” 已經進入了常量池中,但是當前系統沒有任何一個String物件是叫做“abc”的,換句話說 ,就是沒有任何String物件引用常量池中的“abc”常量 ,也沒有其他地方引用了這個字面量,如果這時發生記憶體回收,而且必要的話,這個“abc”常量就會被系統清理出常量池。常量池中的其他類(介面)、方法、欄位的符號引用也與此類似。

判定一個常量是否是“廢棄常量”比較簡單,而要判定一個類是否是“無用的類”的條件則相對苛刻許多。類需要同時滿足下面3個條件才能算是“無用的類”:

  • 該類所有的例項都已經被回收,也就是Java堆中不存在該類的任何例項。
  • 載入該類的ClassLoader已經被回收。
  • 該類對應的java.lang.Class物件沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法。

虛擬機器可以對滿足上述3個條件的無用類進行回收,這裡說的僅僅是“可以”,而並不是和物件一樣,不使用了就必然會回收。是否對類進行回收,HotSpot虛擬機器提供了-Xnoclassgc 引數進行控 ,還可以使用-verbose : class以及-XX : +TraceClassLoading、 -XX : +TraceClassUnLoading檢視類載入和解除安裝資訊,其中-verbose : class和-XX : +TraceClassLoading可以在Product版的虛擬機器中使用, -XX : +TraceClassUnLoading引數需要FastDebug版的虛擬機器支援。

在大量使用反射、動態代理、CGLib等ByteCode框架、動態生成JSP以及OSGi這類頻繁 自定義ClassLoader的場景都需要虛擬機器具備類解除安裝的功能,以保證永久代不會溢位。

相關文章