Java垃圾收集器
概述
說起垃圾收集(Garbage Collection,GC),大部分人都把這項技術當做Java語言的伴生產物。事實上,GC的曆史遠遠比Java久遠,1960年誕生於MIT的Lisp是第一門真正使用記憶體動態分配和垃圾收集技術的語言。當Lisp還在胚胎時期時,人們就在思考:
GC需要完成的三件事情:
哪些記憶體需要回收?
什麼時候回收?
如何回收?
經過半個世紀的發展,記憶體的動態分配與記憶體回收技術已經相當成熟,一切看起來都進入了“自動化”時代,那為什麼我們還要去瞭解GC和記憶體配置呢?答案很簡單:當需要排查各種記憶體溢出、記憶體流失問題時,當垃圾收整合為系統達到更高並發量的瓶頸時,我們就需要對這些“自動化”的技術實施必要的監控和調節。
把時間從半個世紀以前撥回到現在,回到我們熟悉的Java語言。第2章介紹了Java記憶體運行時地區的各個部分,其中程式計數器、虛擬機器棧、本地方法棧三個地區隨線程而生,隨線程而滅;棧中的棧幀隨著方法的進入和退出而有條不紊地執行著出棧和入棧操作。每一個棧幀中分配多少記憶體基本上是在類結構確定下來時就已知的(儘管在運行期會由JIT編譯器進行一些最佳化,但在本章基於概念性模型的討論中,大體上可以認為是編譯期可知的),因此這幾個地區的記憶體配置和回收都具備確定性,在這幾個地區內不需要過多考慮回收的問題,因為方法結束或線程結束時,記憶體自然就跟隨著回收了。而Java堆和方法區則不一樣,一個介面中的多個實作類別需要的記憶體可能不一樣,一個方法中的多個分支需要的記憶體也可能不一樣,我們只有在程式處於運行期間時才能知道會建立哪些對象,這部分記憶體的分配和回收都是動態,垃圾收集器所關注的是這部分記憶體,本書後續討論中的“記憶體”分配與回收也僅指這一部分記憶體。
對象已死?
堆中幾乎存放著Java世界中所有的對象執行個體,垃圾收集器在對堆進行回收前,第一件事情就是要確定這些對象有哪些還“存活”著,哪些已經“死去”(即不可能再被任何途徑使用的對象)。
引用計數演算法
很多教科書判斷對象是否存活的演算法是這樣的:給對象中添加一個引用計數器,每當有一個地方引用它時,計數器值就加1;當引用失效時,計數器值就減1;任何時刻計數器都為0的對象就是不可能再被使用的。筆者面試過很多的應屆生和一些有多年工作經驗的開發人員,他們對於這個問題給予的都是這個答案。
客觀地說,引用計數演算法(Reference Counting)的實現簡單,判定效率也很高,在大部分情況下它都是一個不錯的演算法,也有一些比較著名的應用案例,例如微軟的COM(Component Object Model)技術、使用ActionScript 3的FlashPlayer、Python語言以及在遊戲指令碼領域中被廣泛應用的Squirrel中都使用了引用計數演算法進行記憶體管理。但是,Java語言中沒有選用引用計數演算法來管理記憶體,其中最主要的原因是它很難解決對象之間的相互循環參考的問題。
舉個簡單的例子,請看如下代碼中的testGC()方法:對象objA和objB都有欄位instance,賦值令objA.instance = objB及objB.instance = objA,除此之外,這兩個對象再無任何引用,實際上這兩個對象已經不可能再被訪問,但是它們因為互相引用著對方,導致它們的引用計數都不為0,於是引用計數演算法無法通知GC收集器回收它們。代碼如下所示: 引用計數演算法的缺陷
1 public class ReferenceCountingGC { 2 public static void main(String[] args) { 3 testGC(); 4 } 5 public Object instance = null; 6 private static final int _1MB=1024*1024; 7 /** 8 * 這個成員屬性的唯一意義就是占點記憶體,以便能在GC日誌中看清楚是否被回收過 9 */10 private byte[] bigSize = new byte[2 * _1MB];11 public static void testGC(){12 ReferenceCountingGC objA=new ReferenceCountingGC();13 ReferenceCountingGC objB=new ReferenceCountingGC();14 objA.instance=objB;15 objB.instance=objA;16 objA=null;17 objB=null;18 // 假設在這行發生GC,那麼objA和objB是否能被回收?19 System.gc();20 }21 }
運行結果:
[Full GC (System) [Tenured: 0K->210K(10240K), 0.0149142 secs] 4603K->210K(19456K), [Perm : 2999K->2999K(21248K)], 0.0150007 secs] [Times: user=0.01 sys=0.00, real=0.02 secs] Heap def new generation total 9216K, used 82K [0x00000000055e0000, 0x0000000005fe0000, 0x0000000005fe0000) Eden space 8192K, 1% used [0x00000000055e0000, 0x00000000055f4850,0x0000000005de0000) from space 1024K, 0% used [0x0000000005de0000, 0x0000000005de0000, 0x0000000005ee0000) to space 1024K, 0% used [0x0000000005ee0000, 0x0000000005ee0000, 0x0000000005fe0000) tenured generation total 10240K, used 210K [0x0000000005fe0000, 0x00000000069e0000, 0x00000000069e0000) the space 10240K, 2% used [0x0000000005fe0000, 0x0000000006014a18, 0x0000000006014c00, 0x00000000069e0000) compacting perm gen total 21248K, used 3016K [0x00000000069e0000, 0x0000000007ea0000, 0x000000000bde0000) the space 21248K, 14% used [0x00000000069e0000, 0x0000000006cd2398, 0x0000000006cd2400, 0x0000000007ea0000) No shared spaces configured.
從運行結果中可以清楚地看到GC日誌中包含“4603K->210K”,意味著虛擬機器並沒有因為這兩個對象互相引用就不回收它們,這也從側面說明虛擬機器並不是通過引用計數演算法來判斷對象是否存活的。
根搜尋演算法
在主流的商用程式語言中(Java和C#,甚至包括前面提到的古老的Lisp),都是使用根搜尋演算法(GC Roots Tracing)判定對象是否存活的。這個演算法的基本思路就是通過一系列的名為“GC Roots”的對象作為起始點,從這些節點開始向下搜尋,搜尋所走過的路徑稱為引用鏈(Reference Chain),當一個對象到GC Roots沒有任何引用鏈相連(用圖論的話來說就是從GC Roots到這個對象不可達)時,則證明此對象是停用。3-1所示,對象object 5、object 6、object7雖然互相有關聯,但是它們到GC Roots是不可達的,所以它們將會被判定為是可回收的對象。
在Java語言裡,可作為GC Roots的對象包括下面幾種:
虛擬機器棧(棧幀中的本地變數表)中的引用的對象。
方法區中的類靜態屬性引用的對象。
方法區中的常量引用的對象。
本地方法棧中JNI(即一般說的Native方法)的引用的對象。
再談引用
無論是通過引用計數演算法判斷對象的引用數量,還是通過根搜尋演算法判斷對象的引用鏈是否可達,判定對象是否存活都與“引用”有關。在JDK 1.2之前,Java中的引用的定義很傳統:如果reference類型的資料中儲存的數值代表的是另外一塊記憶體的起始地址,就稱這塊記憶體代表著一個引用。這種定義很純粹,但是太過狹隘,一個對象在這種定義下只有被引用或者沒有被引用兩種狀態,對於如何描述一些“食之無味,棄之可惜”的對象就顯得無能為力。我們希望能描述這樣一類對象:當記憶體空間還足夠時,則能保留在記憶體之中;如果記憶體在進行垃圾收集後還是非常緊張,則可以拋棄這些對象。很多系統的緩衝功能都符合這樣的應用情境。
在JDK 1.2之後,Java對引用的概念進行了擴充,將引用分為強引用(Strong Reference)、軟引用(Soft Reference)、弱引用(WeakReference)、虛引用(Phantom Reference)四種,這四種引用強度依次逐漸減弱。
強引用就是指在程式碼之中普遍存在的,類似“Object obj = new Object()”這類的引用,只要強引用還存在,垃圾收集器永遠不會回收掉被引用的對象。
軟引用用來描述一些還有用,但並非必需的對象。對於軟引用關聯著的對象,在系統將要發生記憶體溢出異常之前,將會把這些對象列進回收範圍之中並進行第二次回收。如果這次回收還是沒有足夠的記憶體,才會拋出記憶體溢出異常。在JDK 1.2之後,提供了SoftReference類來實現軟引用。
弱引用也是用來描述非必需對象的,但是它的強度比軟引用更弱一些,被弱引用關聯的對象只能生存到下一次垃圾收集發生之前。當垃圾收集器工作時,無論當前記憶體是否足夠,都會回收掉只被弱引用關聯的對象。在JDK 1.2之後,提供了WeakReference類來實現弱引用。
虛引用也稱為幽靈引用或者幻影引用,它是最弱的一種參考關聯性。一個對象是否有虛引用的存在,完全不會對其存留時間構成影響,也無法通過虛引用來取得一個對象執行個體。為一個對象設定虛引用關聯的唯一目的就是希望能在這個對象被收集器回收時收到一個系統通知。在JDK 1.2之後,提供了PhantomReference類來實現虛引用。
生存還是死亡?
在根搜尋演算法中不可達的對象,也並非是“非死不可”的,這時候它們暫時處於“緩刑”階段,要真正宣告一個對象死亡,至少要經曆兩次標記過程:如果對象在進行根搜尋後發現沒有與GC Roots相串連的引用鏈,那它將會被第一次標記並且進行一次篩選,篩選的條件是此對象是否有必要執行finalize()方法。當對象沒有覆蓋finalize()方法,或者finalize()方法已經被虛擬機器調用過,虛擬機器將這兩種情況都視為“沒有必要執行”。
如果這個對象被判定為有必要執行finalize()方法,那麼這個對象將會被放置在一個名為F-Queue的隊列之中,並在稍後由一條由虛擬機器自動建立的、低優先順序的Finalizer線程去執行。這裡所謂的“執行”是指虛擬機器會觸發這個方法,但並不承諾會等待它運行結束。這樣做的原因是,如果一個對象在finalize()方法中執行緩慢,或者發生了死迴圈(更極端的情況),將很可能會導致F-Queue隊列中的其他對象永久處於等待狀態,甚至導致整個記憶體回收系統崩潰。finalize()方法是對象逃脫死亡命運的最後一次機會,稍後GC將對F-Queue中的對象進行第二次小規模的標記,如果對象要在finalize()中成功拯救自己—只要重新與引用鏈上的任何一個對象建立關聯即可,譬如把自己(this關鍵字)賦值給某個類變數或對象的成員變數,那在第二次標記時它將被移除出“即將回收”的集合;如果對象這時候還沒有逃脫,那它就真的離死不遠了。從代碼清單3-2中我們可以看到一個對象的finalize()被執行,但是它仍然可以存活。
從代碼清單3-2的運行結果可以看到,SAVE_HOOK對象的finalize()方法確實被GC收集器觸發過,並且在被收集前成功逃脫了。
1 public class FinalizeEscapeGC { 2 public static FinalizeEscapeGC SAVE_HOOK = null; 3 public void isAlive(){ 4 System.out.println("yes,i am still alive!"); 5 } 6 protected void finalize() throws Throwable { 7 SAVE_HOOK = new FinalizeEscapeGC(); 8 //對象第一次成功拯救自己 9 SAVE_HOOK = null;10 System.gc();11 // 因為Finalizer方法優先順序很低,暫停0.5秒,以等待它12 Thread.sleep(500);13 if (SAVE_HOOK != null){14 SAVE_HOOK.isAlive();15 }else{16 System.out.println("no,i am dead!");17 }18 // 下面這段代碼與上面的完全相同,但是這次自救卻失敗了19 SAVE_HOOK = null;20 System.gc();21 // 因為Finalizer方法優先順序很低,暫停0.5秒,以等待它22 Thread.sleep(500);23 if (SAVE_HOOK != null){24 SAVE_HOOK.isAlive();25 }else{26 System.out.println("no,i am dead!");27 }28 }29 }
運行結果:
1 finalize method executed!2 yes, i am still alive!3 no, i am dead!
另外一個值得注意的地方就是,代碼中有兩段完全一樣的程式碼片段,執行結果卻是一次逃脫成功,一次失敗,這是因為任何一個對象的finalize()方法都只會被系統自動調用一次,如果對象面臨下一次回收,它的finalize()方法不會被再次執行,因此第二段代碼的自救行動失敗了。 需要特別說明的是,上面關於對象死亡時finalize()方法的描述可能帶有悲情的藝術色彩,筆者並不鼓勵大家使用這種方法來拯救對象。相反,筆者建議大家盡量避免使用它,因為它不是C/C++中的解構函式,而是Java剛誕生時為了使C/C++程式員更容易接受它所做出的一個妥協。它的運行代價高昂,不確定性大,無法保證各個對象的調用順序。有些教材中提到它適合做“關閉外部資源”之類的工作,這完全是對這種方法的用途的一種自我安慰。finalize()能做的所有工作,使用try-finally或其他方式都可以做得更好、更及時,大家完全可以忘掉Java語言中還有這個方法的存在。
回收方法區
很多人認為方法區(或者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查看類的載入和卸載資訊。
在大量使用反射、動態代理、CGLib等bytecode架構的情境,以及動態產生JSP和OSGi這類頻繁自訂ClassLoader的情境都需要虛擬機器具備類卸載的功能,以保證永久代不會溢出。
垃圾收集演算法
由於垃圾收集演算法的實現涉及大量的程式細節,而且各個平台的虛擬機器操作記憶體的方法又各不相同,因此本節不打算過多地討論演算法的實現,只是介紹幾種演算法的思想及其發展過程。
標記 -清除演算法
最基礎的收集演算法是“標記-清除”(Mark-Sweep)演算法,如它的名字一樣,演算法分為“標記”和“清除”兩個階段:首先標記出所有需要回收的對象,在標記完成後統一回收掉所有被標記的對象,它的標記過程其實在前一節講述對象標記判定時已經基本介紹過了。之所以說它是最基礎的收集演算法,是因為後續的收集演算法都是基於這種思路並對其缺點進行改進而得到的。它的主要缺點有兩個:一個是效率問題,標記和清除過程的效率都不高;另外一個是空間問題,標記清除之後會產生大量不連續的記憶體片段,空間片段太多可能會導致,當程式在以後的運行過程中需要分配較大對象時無法找到足夠的連續記憶體而不得不提前觸發另一次垃圾收集動作。標記-清除演算法的執行過程3-2所示。
複製演算法
為瞭解決效率問題,一種稱為“複製”(Copying)的收集演算法出現了,它將可用記憶體按容量劃分為大小相等的兩塊,每次只使用其中的一塊。當這一塊的內存用完了,就將還存活著的對象複製到另外一塊上面,然後再把已使用過的記憶體空間一次清理掉。這樣使得每次都是對其中的一塊進行記憶體回收,記憶體配置時也就不用考慮記憶體片段等複雜情況,只要移動堆頂指標,按順序分配記憶體即可,實現簡單,運行高效。只是這種演算法的代價是將記憶體縮小為原來的一半,未免太高了一點。複製演算法的執行過程3-3所示。
現在的商業虛擬機器都採用這種收集演算法來回收新生代,IBM的專門研究表明,新生代中的對象98%是朝生夕死的,所以並不需要按照1∶1的比例來劃分記憶體空間,而是將記憶體分為一塊較大的Eden空間和兩塊較小的Survivor空間,每次使用Eden和其中的一塊Survivor。當回收時,將Eden和Survivor中還存活著的對象一次性地拷貝到另外一塊Survivor空間上,最後清理掉Eden和剛才用過的Survivor的空間。HotSpot虛擬機器預設Eden和Survivor的大小比例是8∶1,也就是每次新生代中可用記憶體空間為整個新生代容量的90%(80%+10%),只有10%的記憶體是會被“浪費”的。當然,98%的對象可回收只是一般情境下的資料,我們沒有辦法保證每次回收都只有不多於10%的對象存活,當Survivor空間不夠用時,需要依賴其他記憶體(這裡指老年代)進行分配擔保(Handle Promotion)。
記憶體的分配擔保就好比我們去銀行借款,如果我們信譽很好,在98%的情況下都能按時償還,於是銀行可能會預設我們下一次也能按時按量地償還貸款,只需要有一個擔保人能保證如果我不能還款時,可以從他的賬戶扣錢,那銀行就認為沒有風險了。記憶體的分配擔保也一樣,如果另外一塊Survivor空間沒有足夠的空間存放上一次新生代收集下來的存活對象,這些對象將直接通過分配擔保機制進入老年代。關於對新生代進行分配擔保的內容,本章稍後在講解垃圾收集器執行規則時還會再詳細講解。
標記-整理演算法
複製收集演算法在對象存活率較高時就要執行較多的複製操作,效率將會變低。更關鍵的是,如果不想浪費50%的空間,就需要有額外的空間進行分配擔保,以應對被使用的記憶體中所有對象都100%存活的極端情況,所以在老年代一般不能直接選用這種演算法。
根據老年代的特點,有人提出了另外一種“標記-整理”(Mark-Compact)演算法,標記過程仍然與“標記-清除”演算法一樣,但後續步驟不是直接對可回收對象進行清理,而是讓所有存活的對象都向一端移動,然後直接清理掉端邊界以外的記憶體,“標記-整理”演算法的3-4所示。 3.3.4
分代收集演算法
當前商業虛擬機器的垃圾收集都採用“分代收集”(Generational Collection)演算法,這種演算法並沒有什麼新的思想,只是根據對象的存活周期的不同將記憶體劃分為幾塊。一般是把Java堆分為新生代和老年代,這樣就可以根據各個年代的特點採用最適當的收集演算法。在新生代中,每次垃圾收集時都發現有大批對象死去,只有少量存活,那就選用複製演算法,只需要付出少量存活對象的複製成本就可以完成收集。而老年代中因為對象存活率高、沒有額外空間對它進行分配擔保,就必須使用“標記-清理”或“標記-整理”演算法來進行回收。
垃圾收集器
如果說收集演算法是記憶體回收的方法論,垃圾收集器就是記憶體回收的具體實現。Java虛擬機器規範中對垃圾收集器應該如何?並沒有任何規定,因此不同的廠商、不同版本的虛擬機器所提供的垃圾收集器都可能會有很大的差別,並且一般都會提供參數供使用者根據自己的應用特點和要求組合出各個年代所使用的收集器。這裡討論的收集器基於Sun HotSpot虛擬機器1.6版 Update 22,這個虛擬機器包含的所有收集器3-5所示。