1. 程式人生 > >JVM學習(2) 物件已死?

JVM學習(2) 物件已死?

概述

說起垃圾收集(Garbage Collection,GC),大部分人都把這項技術當做Java語言的伴生產物。事實上,GC的歷史遠遠比Java久遠,1960年誕生於MIT的Lisp是第一門真正使用記憶體動態分配和垃圾收集技術的語言。當Lisp還在胚胎時期時,人們就在思考GC需要完成的三件事:

  • 那些記憶體需要回收?
  • 什麼時候回收?
  • 如何回收?

經過半個世紀的發展,記憶體的動態分配與記憶體回收技術已經相當成熟,一切看起來都進入了“自動化”時代,那為什麼我們還要去了解GC和記憶體分配呢?答案很簡單:當需要排查各種記憶體溢位、記憶體洩漏問題時,當垃圾收整合為系統達到更高併發量的瓶頸時,我們就需要堆這些”自動化“的技術實施必要的監控和調節。

自動記憶體管理機制中介紹了記憶體執行時區域的各個部分,其中程式計數器、虛擬機器棧、本地方法棧三個區域隨執行緒而生,歲執行緒而滅:棧中的棧幀隨著方法的進入和退出有條不紊地執行著出棧和入棧操作。每一個棧幀中分配多少記憶體基本上是在類結構確定下來時就已知地(儘管在執行期會由JIT編譯器進行一些優化,但在本章基於概念模型地討論中,大體上可以認為是編譯器可知地),因此這幾個區域地記憶體分配和回收都具備確定性,在這幾個區域內不需要過多考慮回收的問題,因為方法結束或執行緒結束時,記憶體自然就跟隨著回收了。而Java堆和方法區則不一樣,一個介面中的多個實現類需要的記憶體可能不一樣,一個方法中多個分支需要的記憶體也可能不一樣,我們只有在程式處於執行期間時才能指定會建立那些物件,這部分記憶體的分配和回收時動態地,垃圾收集器所關注的是這部分記憶體。

物件已死?

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

引用計數演算法

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

但是,Java語言中沒有選用引用計數器來管理記憶體,其中最主要的原因是它很難解決物件之間的相互迴圈引用的問題。

舉個栗子:

public class ReferenceCountingGC{
    public Object instance = null;
    private static final int _1MB = 1024 * 1024;
    private byte[] bigSize = new byte[2 * _1MB];
    public static void testGC(){
        ReferenceCountingGC objA = new ReferenceCountingGC();
        RferenceCountingGC objB = new ReferenceCountingGC();
        objA.instance = objB;
        ObjB.instance = objA;
        objA = null;
        objB = null;
        System.gc();
    }
}

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

根搜尋演算法

在主流的商用程式語言中(Java和C#,甚至包括前面提到的古老的Lisp),都是使用根搜尋演算法(GC Roots Tracing)判定物件是否存活的。這個演算法的基本思路就是通過一系列的名為“GC Roots”的物件作為起始點,從這些節點開始向下搜尋,搜尋所走過的路徑稱為引用鏈(Reference Chain),當一個物件到GC Roots沒有任何引用鏈相連(用圖論的話來說就是從GC Roots到這個物件不可達)時,則證明此物件是不可用的。

如圖所示,物件object 5、object 6、object 7雖然互相有關聯,但是它們到GC Roots是不可達的,所以它們將會被判定為是否可回收的物件。

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

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

再談引用

在JDK1.2之前,Java中的引用的定義很傳統:如果reference型別的資料中儲存的數值代表的是另外一塊記憶體的起始地址,就稱這塊記憶體代表著一個引用。

一個物件在這種定義下只有被引用或者沒有被引用兩種狀態,對於如何描述一些“食之無味,棄之可惜”的物件就顯得無能為力。我們希望能描述這樣一類物件:當記憶體空間還沒足夠時,則能保留在記憶體之中;如果記憶體在進行垃圾收集後還是非常緊張,則可以拋棄這些物件。很多系統的快取功能都符合這樣的應用場景。

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

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

生成還是死亡?

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

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

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 method 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 :(");
        }
    }
}

從上面程式碼中我們可以看到一個物件的finalize()被執行,但是它仍然可以存活。

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

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

回收方法區

很多人認為方法區(或者HotSpot虛擬機器中的永久代)是沒有垃圾收集的,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的場景都需要虛擬機器具備類解除安裝的功能,以保證永久代不會溢位。