JVM-垃圾收集器與記憶體分配策略
垃圾收集器與記憶體分配策略
一個垃圾收集器除了垃圾收集這個本職工作之外,它還要負責堆的管理與布局、對象的分配、與解釋器的協作、與編譯器的協作、與監控子系統協作等職責,其中至少堆的管理和對象的分配這部分功能是Java虛擬機能夠正常運作的必要支援,是一個最小化功能的垃圾收集器也必須實現的內容。
垃圾收集關注的是堆和方法區的記憶體如何管理。
程式計數器、虛擬機棧、本地方法棧3個區域隨執行緒而生,隨執行緒而滅,棧中的棧幀隨著方法的進入和退出而有條不紊地執行著出棧和入棧操作。每一個棧幀中分配多少記憶體基本上是在類結構確定下來時就已知的(儘管在運行期會由即時編譯器進行一些優化,但在基於概念模型的討論里,大體上可以認為是編譯期可知的),因此這幾個區域的記憶體分配和回收都具備確定性,在這幾個區域內就不需要過多考慮如何回收的問題,當方法結束或者執行緒結束時,記憶體自然就跟隨著回收了。
判斷對象存活狀態
引用計數演算法
在對象中添加一個引用計數器,每當有一個地方引用它時,計數器值就加一;當引用失效時,計數器值就減一;任何時刻計數器為零的對象就是不可能再被使用的。
客觀來說,這種方法只需要佔據一部分額外記憶體即可實現進行計數,微軟COM技術,Python語言等等一些應用中都有引用計數法進行記憶體管理。但是Java的主流虛擬機都沒有選用這種演算法,因為有很多例外情況需要判斷,比如說單純的引用計數很難解決對象之間相互引用的問題。(A->B,B->A這樣的話雙方計數器都為1,除此之外再無引用,也不被訪問)
可達性分析演算法
當前主流的商用程式語言(Java、C#,上溯至前面提到的古老的Lisp)的記憶體管理子系統,都是通過可達性分析(Reachability Analysis)演算法來判定對象是否存活的。這個演算法的基本思路就是通過一系列稱為「GC Roots」的根對象作為起始節點集,從這些節點開始,根據引用關係向下搜索,搜索過程所走過的路徑稱為「引用鏈」(Reference Chain),如果某個對象到GC Roots間沒有任何引用鏈相連,或者用圖論的話來說就是從GC Roots到這個對象不可達時,則證明此對象是不可能再被使用的。
Java中,可固定作GC Roots的對象有:虛擬機棧中引用的對象,方法區中類靜態屬性引用的對象,方法區中常量引用的對象,本地方法棧中JNI引用的對象,所有被同步鎖持有的對象,反應JVM內部情況的JMXBean,JVMTI中註冊的回調,本地程式碼快取等。
根據用戶所選用的垃圾收集器以及當前回收的記憶體區域不同,還可以有其他對象「臨時性」地加入,共同構成完整GC Roots集合。
引用
JDK1.2之前,Java中只有「被引用」和「未被引用」兩種狀態。
JDK1.2之後,引用分為:強引用(Strongly Re-ference)、軟引用(Soft Reference)、弱引用(Weak Reference)和虛引用(Phantom Reference)4種,這4種引用強度依次逐漸減弱。
- 強引用是最傳統的「引用」的定義,是指在程式程式碼之中普遍存在的引用賦值,即類似「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()
方法。注意,執行該方法的時候並不會等待該方法結束,因為如果某個對象的finalize()
執行緩慢,甚至發生死循環,將會導致F-Queue
中的其他對象永久處於等待狀態,甚至導致整個記憶體回收子系統的崩潰。
finalize()
方法是對象逃離死亡命運的最後一次機會,稍後收集器將會對等待隊列中的對象進行第二次小規模的標記,如果對象在finalize()
方法中拯救了自己——重新與引用鏈上任何一個對象建立關聯即可。
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize method executed!");
FinalizeEscapeGC.SAVE_HOOK = this; //該對象被引用
}
可以通過重寫finalize()
方法實現拯救一個對象。(對於一個對象,finalize()
方法只會被系統調用一次)
不建議使用
finalize()
方法進行拯救函數,通過try-finally或者其他工作方式都可以做到。
回收方法區
方法區的垃圾收集主要回收兩部分內容:廢棄的常量和不再使用的類型。回收廢棄常量與回收Java堆中的對象非常類似。
判斷一個廢棄常量和回收堆中對象類似:舉例,常量池中有「java」字元串,如果當前系統中沒有任何一個字元串對象的值為「java」,且虛擬機中也沒有其他地方引用此常量,這時候發生記憶體回收且垃圾收集器判斷確有必要的話,這個「java」常量將會被系統清理出常量池。常量池中其他類也類似。
判斷一個類型是否屬於「不再使用的類」的條件比較苛刻,需要同時滿足下面三個條件:
- 該類所有的實例都已經被回收,也就是Java堆中不存在該類及其任何派生子類的實例。
- 載入該類的類載入器已經被回收,這個條件除非是經過精心設計的可替換類載入器的場景,如OSGi、JSP的重載入等,否則通常是很難達成的。
- 該類對應的java.lang.Class對象沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法。
Java虛擬機被允許對滿足上述三個條件的無用類進行回收,這裡說的僅僅是「被允許」,而並不是和對象一樣,沒有引用了就必然會回收。關於是否要對類型進行回收,HotSpot虛擬機提供了-Xnoclassgc參數進行控制,還可以使用-verbose:class以及-XX:+TraceClass-Loading、-XX:+TraceClassUnLoading查看類載入和卸載資訊,其中-verbose:class和-XX:+TraceClassLoading可以在Product版的虛擬機中使用,-XX+TraceClassUnLoading參數需要FastDebug版的虛擬機支援。
在大量使用反射、動態代理、CGLib等位元組碼框架,動態生成JSP以及OSGi這類頻繁自定義類載入器的場景中,通常都需要Java虛擬機具備類型卸載的能力,以保證不會對方法區造成過大的記憶體壓力。
垃圾收集演算法
從如何判定對象消亡的角度出發,垃圾收集演算法可以劃分為「引用計數式垃圾收集」(Reference Counting GC)和「追蹤式垃圾收集」(Tracing GC)兩大類,這兩類也常被稱作「直接垃圾收集」和「間接垃圾收集」。不過主流虛擬機中均採用的是追蹤式垃圾收集。
分代收集理論
兩個分代假說:
- 弱分代假說:絕大多數對象都是朝生夕滅的。
- 強分代假說:熬過越多次垃圾收集過程的對象就越難以消亡。
這兩個分代假說奠定了多款常用的垃圾收集器的一致的設計原則:將Java堆劃分出不同的區域,然後將回收對象依據其年齡(年齡即對象熬過垃圾收集過程的次數)分配到不同的區域中存儲。
如果一個區域中大多數對象都是朝生夕滅,難以熬過垃圾收集過程的話,那麼把它們集中放在一起,每次回收時只關注如何保留少量存活而不是去標記那些大量將要被回收的對象,就能以較低代價回收到大量的空間;如果剩下的都是難以消亡的對象,那把它們集中放在一塊,虛擬機便可以使用較低的頻率來回收這個區域,這就同時兼顧了垃圾收集的時間開銷和記憶體的空間有效利用。
現在的商用Java虛擬機里,設計者一般至少會把Java堆劃分為新生代(Young Generation)和老年代(Old Generation)兩個區域。在新生代中,每次垃圾收集時都發現有大批對象死去,而每次回收後存活的少量對象,將會逐步晉陞到老年代中存放。
但是分代收集並非只是簡單的劃分記憶體區域,假如說要進行一次新生代區域內的收集,新生代區域內的對象是有可能被老年代引用的,因此除了固定的GC Roots外,還需要對老年代的所有對象進行一次遍歷來確保可達性分析結果的正確性。這樣的話會對記憶體回收帶來很大的性能負擔。因此分代收集理論添加了第三條經驗法則:
跨代引用假說(Intergenerational Reference Hypothesis)
:跨代引用相對於同代引用來說僅占極少數。
存在互相引用關係的兩個對象,是應該傾向於同時生存或者同時消亡的。舉個例子,如果某個新生代對象存在跨代引用,由於老年代對象難以消亡,該引用會使得新生代對象在收集時同樣得以存活,進而在年齡增長之後晉陞到老年代中,這時跨代引用也隨即被消除了。
依據這條假說,我們就不應再為了少量的跨代引用去掃描整個老年代,也不必浪費空間專門記錄每一個對象是否存在及存在哪些跨代引用,只需在新生代上建立一個全局的數據結構(該結構被稱為「記憶集」,Remembered Set),這個結構把老年代劃分成若干小塊,標識出老年代的哪一塊記憶體會存在跨代引用。此後當發生Minor GC(只收集新生代)時,只有包含了跨代引用的小塊記憶體里的對象才會被加入到GC Roots進行掃描。雖然這種方法需要在對象改變引用關係(如將自己或者某個屬性賦值)時維護記錄數據的正確性,會增加一些運行時的開銷,但比起收集時掃描整個老年代來說仍然是划算的。
一些專有名詞:
部分收集(Partial GC):指目標不是完整收集整個Java堆的垃圾收集,其中又分為:
- 新生代收集(Minor GC/Young GC):指目標只是新生代的垃圾收集。
- 老年代收集(Major GC/Old GC):指目標只是老年代的垃圾收集。目前只有CMS收集器會有單獨收集老年代的行為。另外請注意「Major GC」這個說法現在有點混淆,在不同資料上常有不同所指,讀者需按上下文區分到底是指老年代的收集還是整堆收集。
- 混合收集(Mixed GC):指目標是收集整個新生代以及部分老年代的垃圾收集。目前只有G1收集器會有這種行為
整堆收集(Full GC):收集整個Java堆和方法區的垃圾收集。
值得注意的是,分代收集理論也有其缺陷,最新出現(或在實驗中)的幾款垃圾收集器都展現出了面向全區域收集設計的思想,或者可以支援全區域不分代的收集的工作模式。
標記-清除演算法
演算法分為「標記」和「清除」兩個階段:首先標記出所有需要回收的對象,在標記完成後,統一回收掉所有被標記的對象,也可以反過來,標記存活的對象,統一回收所有未被標記的對象。(標記即判斷對象是否屬於垃圾)
它的主要缺點有兩個:第一個是執行效率不穩定,如果Java堆中包含大量對象,而且其中大部分是需要被回收的,這時必須進行大量標記和清除的動作,導致標記和清除兩個過程的執行效率都隨對象數量增長而降低;第二個是記憶體空間的碎片化問題,標記、清除之後會產生大量不連續的記憶體碎片,空間碎片太多可能會導致當以後在程式運行過程中需要分配較大對象時無法找到足夠的連續記憶體而不得不提前觸發另一次垃圾收集動作。
演算法執行過程:
標記-複製演算法
將可用記憶體按容量劃分為大小相等的兩塊,每次只使用其中的一塊。當這一塊的記憶體用完了,就將還存活著的對象複製到另外一塊上面,然後再把已使用過的記憶體空間一次清理掉。
演算法執行過程:
如果記憶體中多數對象都是存活的,這種演算法將會產生大量的記憶體間複製的開銷,但對於多數對象都是可回收的情況,演算法需要複製的就是佔少數的存活對象,而且每次都是針對整個半區進行記憶體回收,分配記憶體時也就不用考慮有空間碎片的複雜情況,只要移動堆頂指針,按順序分配即可。這樣實現簡單,運行高效,不過其缺陷也顯而易見,這種複製回收演算法的代價是將可用記憶體縮小為了原來的一半,空間浪費未免太多了一點。
現在的商用Java虛擬機大多都優先採用了這種收集演算法去回收新生代,IBM公司曾有一項專門研究對新生代「朝生夕滅」的特點做了更量化的詮釋——新生代中的對象有98%熬不過第一輪收集。因此並不需要按照1∶1的比例來劃分新生代的記憶體空間。
HotSpot虛擬機的Serial、ParNew等新生代收集器均採用了Appel式回收策略來設計新生代的記憶體布局[1]。Appel式回收的具體做法是把新生代分為一塊較大的Eden空間和兩塊較小的Survivor空間,每次分配記憶體只使用Eden和其中一塊Survivor。發生垃圾搜集時,將Eden和Survivor中仍然存活的對象一次性複製到另外一塊Survivor空間上,然後直接清理掉Eden和已用過的那塊Survivor空間。HotSpot虛擬機默認Eden和Survivor的大小比例是8∶1。
Appel式回收還有一個充當罕見情況的「逃生門」的安全設計,當Survivor空間不足以容納一次Minor GC之後存活的對象時,就需要依賴其他記憶體區域(實際上大多就是老年代)進行分配擔保(Handle Promotion)。
標記-整理演算法
標記-複製演算法在面對存活率較高的情況時需要進行大量複製操作,效率將會降低。老年代一般不能直接選用這種演算法。
標記-整理演算法的標記過程仍然與「標記-清除」演算法一樣,但後續步驟不是直接對可回收對象進行清理,而是讓所有存活的對象都向記憶體空間一端移動,然後直接清理掉邊界以外的記憶體。
回收過程:
至於什麼時候採取哪種演算法,則需要根據情況討論:
介紹一下吞吐量
吞吐量 = CPU在用戶應用程式運行的時間 / (CPU在用戶應用程式運行的時間 + CPU垃圾回收的時間)
程式運行時間可以理解為記憶體分配和訪問的時間(還有其他的操作)。
標記-清除演算法,即使不移動對象會使得收集器的效率提升一些,但因記憶體分配和訪問相比垃圾收集頻率要高得多,這部分的耗時增加,總吞吐量仍然是下降的。
HotSpot虛擬機裡面關注吞吐量的Parallel Scavenge收集器是基於標記-整理演算法的,而關注延遲的CMS收集器則是基於標記-清除演算法的。
還有一種「和稀泥式」解決方案可以不在記憶體分配和訪問上增加太大額外負擔,做法是讓虛擬機平時多數時間都採用標記-清除演算法,暫時容忍記憶體碎片的存在,直到記憶體空間的碎片化程度已經大到影響對象分配時,再採用標記-整理演算法收集一次,以獲得規整的記憶體空間。前面提到的基於標記-清除演算法的CMS收集器面臨空間碎片過多時採用的就是這種處理辦法。
HotSpot的演算法實現
固定可作為GC Roots的節點主要在全局性的引用(例如常量或類靜態屬性)與執行上下文(例如棧幀中的本地變數表)中,目前所有收集器在根節點枚舉這一步驟時都是需要暫停用戶執行緒的。
根節點枚舉必須在一個能保障一致性的快照中才得以進行——這裡「一致性」的意思是整個枚舉期間執行子系統看起來就像被凍結在某個時間點上,不會出現分析過程中,根節點集合的對象引用關係還在不斷變化的情況,若這點不能滿足的話,分析結果準確性也就無法保證。
目前主流Java虛擬機使用的都是準確式垃圾收集,當用戶執行緒停頓下來之後,並不需要一個不漏地檢查完所有執行上下文和全局的引用位置,虛擬機應當是有辦法直接得到哪些地方存放著對象引用的。在HotSpot的解決方案里,是使用一組稱為OopMap的數據結構來達到這個目的。一旦類載入動作完成的時候,HotSpot就會把對象內什麼偏移量上是什麼類型的數據計算出來,在即時編譯過程中,也會在特定的位置記錄下棧里和暫存器里哪些位置是引用。這樣收集器在掃描時就可以直接得知這些資訊了,並不需要真正一個不漏地從方法區等GC Roots開始查找。
[Verified Entry Point]
0x026eb730: mov %eax,-0x8000(%esp)
…………
;; ImplicitNullCheckStub slow case
0x026eb7a9: call 0x026e83e0 ; OopMap{ebx=Oop [16]=Oop off=142}
; *caload
; - java.lang.String::hashCode@48 (line 1489)
; {runtime_call}
0x026eb7ae: push $0x83c5c18 ; {external_word}
0x026eb7b3: call 0x026eb7b8
0x026eb7b8: pusha
0x026eb7b9: call 0x0822bec0 ; {runtime_call}
0x026eb7be: hlt
String::hashCode()方法的本地程式碼,可以看到在0x026eb7a9處的call指令有OopMap記錄,它指明了EBX暫存器和棧中偏移量為16的記憶體區域中各有一個普通對象指針(Ordinary Object Pointer,OOP)的引用,有效範圍為從call指令開始直到0x026eb730(指令流的起始位置)+142(OopMap記錄的偏移量)=0x026eb7be,即hlt指令為止。
安全點
對象引用的變化會導致OopMap的變化,但是我們不可能每條指令都進行更新,這樣的話太過影響性能,因此通過設置安全點,避免頻繁更新OopMap,只在達到安全點的位置才更新OopMap。用戶執行緒在安全點停頓,GC在安全點進行,採取主動式中斷,執行緒輪詢中斷標誌位,當標誌位為真時,在最近的安全點主動中斷掛起。
安全點位置的選取基本上是以「是否具有讓程式長時間執行的特徵」為標準進行選定的,因為每條指令執行的時間都非常短暫,程式不太可能因為指令流長度太長這樣的原因而長時間執行,「長時間執行」的最明顯特徵就是指令序列的復用,例如方法調用、循環跳轉、異常跳轉等都屬於指令序列復用,所以只有具有這些功能的指令才會產生安全點。
安全區域
設置安全點之後,在用戶執行緒執行過程中就會遇到安全點,進入到垃圾收集。但是當執行緒處於Sleep或Blocked狀態時,CPU沒有分配處理時間,執行緒無法響應虛擬機的中斷請求,不能走到安全的地方中斷掛起。這時就引入了安全區域。
安全區域是指能夠確保在某一段程式碼片段之中,引用關係不會發生變化,因此,在這個區域中任意地方開始垃圾收集都是安全的。我們也可以把安全區域看作被擴展拉伸了的安全點。
當用戶執行緒執行到安全區域裡面的程式碼時,首先會標識自己已經進入了安全區域,那樣當這段時間裡虛擬機要發起垃圾收集時就不必去管這些已聲明自己在安全區域內的執行緒了。當執行緒要離開安全區域時,它要檢查虛擬機是否已經完成了根節點枚舉(或者垃圾收集過程中其他需要暫停用戶執行緒的階段),如果完成了,那執行緒就當作沒事發生過,繼續執行;否則它就必須一直等待,直到收到可以離開安全區域的訊號為止。
記憶集與卡表
記憶集是一種用於記錄從非收集區域指向收集區域的指針集合的抽象數據結構。
在垃圾收集的場景中,收集器只需要通過記憶集判斷出某一塊非收集區域是否存在有指向了收集區域的指針就可以了,並不需要了解這些跨代指針的全部細節。三種記錄精度:
- 字長精度:每個記錄精確到一個機器字長(就是處理器的定址位數,如常見的32位或64位,這個精度決定了機器訪問物理記憶體地址的指針長度),該字包含跨代指針。
- 對象精度:每個記錄精確到一個對象,該對象里有欄位含有跨代指針。
- 卡精度:每個記錄精確到一塊記憶體區域,該區域內有對象含有跨代指針。
第三種「卡精度」所指的是用一種稱為「卡表」(Card Table)的方式去實現記憶集。
卡表最簡單的形式可以只是一個位元組數組,而HotSpot虛擬機確實也是這樣做的。以下這行程式碼是HotSpot默認的卡表標記邏輯:
CARD_TABLE [this address >> 9] = 0;
位元組數組CARD_TABLE的每一個元素都對應著其標識的記憶體區域中一塊特定大小的記憶體塊,這個記憶體塊被稱作「卡頁」(Card Page)。一般來說,卡頁大小都是以2的N次冪的位元組數,通過上面程式碼可以看出HotSpot中使用的卡頁是2的9次冪,即512位元組(地址右移9位,相當於用地址除以512)。那如果卡表標識記憶體區域的起始地址是0x0000的話,數組CARD_TABLE的第0、1、2號元素,分別對應了地址範圍為0x0000~0x01FF、0x0200~0x03FF、0x0400~0x05FF的卡頁記憶體塊。建議用C中指針的概念進行理解。
一個卡頁的記憶體中通常包含不止一個對象,只要卡頁內有一個(或更多)對象的欄位存在著跨代指針,那就將對應卡表的數組元素的值標識為1,稱為這個元素變髒(Dirty),沒有則標識為0。在垃圾收集發生時,只要篩選出卡表中變髒的元素,就能輕易得出哪些卡頁記憶體塊中包含跨代指針,把它們加入GC Roots中一併掃描。
寫屏障
卡表元素變髒的時刻,即該時刻有其他分代區域的對象引用了當前區域對象,該如何更新卡表元素呢?
HotSpot虛擬機里是通過寫屏障(Write Barrier)技術維護卡表狀態。
寫屏障可以看作在虛擬機層面對「引用類型欄位賦值」這個動作的AOP切面,在引用對象賦值時會產生一個環形(Around)通知,供程式執行額外的動作,也就是說賦值的前後都在寫屏障的覆蓋範疇內。在賦值前的部分的寫屏障叫作寫前屏障(Pre-Write Barrier),在賦值後的則叫作寫後屏障(Post-Write Barrier)。
應用寫屏障後,虛擬機就會為所有賦值操作生成相應的指令,一旦收集器在寫屏障中增加了更新卡表操作,無論更新的是不是老年代對新生代對象的引用,每次只要對引用進行更新,就會產生額外的開銷,不過這個開銷與Minor GC時掃描整個老年代的代價相比還是低得多的。
假設處理器的快取行大小為64位元組,由於一個卡表元素佔1個位元組,64個卡表元素將共享同一個快取行。這64個卡表元素對應的卡頁總的記憶體為32KB(64×512位元組),也就是說如果不同執行緒更新的對象正好處於這32KB的記憶體區域內,就會導致更新卡表時正好寫入同一個快取行而影響性能。為了避免偽共享問題,一種簡單的解決方案是不採用無條件的寫屏障,而是先檢查卡表標記,只有當該卡表元素未被標記過時才將其標記為變髒,程式碼如下:
if (CARD_TABLE [this address >> 9] != 0)
CARD_TABLE [this address >> 9] = 0;
在JDK 7之後,HotSpot虛擬機增加了一個新的參數-XX:+UseCondCardMark,用來決定是否開啟卡表更新的條件判斷。開啟會增加一次額外判斷的開銷,但能夠避免偽共享問題,兩者各有性能損耗,是否打開要根據應用實際運行情況來進行測試權衡。
並發的可達性分析(增量更新,原始快照)
可達性分析演算法理論上要求全過程都基於一個能保障一致性的快照中才能夠進行分析,這意味著必須全程凍結用戶執行緒的運行。在根節點枚舉這個步驟中,由於GC Roots相比起整個Java堆中全部的對象畢竟還算是極少數,且在各種優化技巧(如OopMap)的加持下,它帶來的停頓已經是非常短暫且相對固定(不隨堆容量而增長)的了。可從GC Roots再繼續往下遍歷對象圖,這一步驟的停頓時間就必定會與Java堆容量直接成正比例關係了:堆越大,存儲的對象越多,對象圖結構越複雜,要標記更多對象而產生的停頓時間自然就更長,這聽起來是理所當然的事情。
引入三色標記:
- 白色:表示對象尚未被垃圾收集器訪問過。顯然在可達性分析剛剛開始的階段,所有的對象都是白色的,若在分析結束的階段,仍然是白色的對象,即代表不可達。
- 黑色:表示對象已經被垃圾收集器訪問過,且這個對象的所有引用都已經掃描過。黑色的對象代表已經掃描過,它是安全存活的,如果有其他對象引用指向了黑色對象,無須重新掃描一遍。黑色對象不可能直接(不經過灰色對象)指向某個白色對象。
- 灰色:表示對象已經被垃圾收集器訪問過,但這個對象上至少存在一個引用還沒有被掃描過。
並發情況下,可達性分析是可能出現問題的,造成「對象消失」,這個問題沒有贅述,可以自己查資料。
造成該問題需要滿足以下兩個條件:
- 賦值器插入了一條或多條從黑色對象到白色對象的新引用;
- 賦值器刪除了全部從灰色對象到該白色對象的直接或間接引用。
解決並發掃描時對象消失的問題,有兩種解決方案:增量更新和原始快照。
增量更新要破壞的是第一個條件,當黑色對象插入新的指向白色對象的引用關係時,就將這個新插入的引用記錄下來,等並發掃描結束之後,再將這些記錄過的引用關係中的黑色對象為根,重新掃描一次。這可以簡化理解為,黑色對象一旦新插入了指向白色對象的引用之後,它就變回灰色對象了。
原始快照要破壞的是第二個條件,當灰色對象要刪除指向白色對象的引用關係時,就將這個要刪除的引用記錄下來,在並發掃描結束之後,再將這些記錄過的引用關係中的灰色對象為根,重新掃描一次。這也可以簡化理解為,無論引用關係刪除與否,都會按照剛剛開始掃描那一刻的對象圖快照來進行搜索。
以上無論是對引用關係記錄的插入還是刪除,虛擬機的記錄操作都是通過寫屏障實現的。在HotSpot虛擬機中,增量更新和原始快照這兩種解決方案都有實際應用,譬如,CMS是基於增量更新來做並發標記的,G1、Shenandoah則是用原始快照來實現。
經典垃圾收集器
Serial收集器
:單執行緒工作的收集器,在它進行垃圾收集時,必須暫停其他工作執行緒,直至收集結束。
迄今為止,它依然是HotSpot虛擬機運行在客戶端模式下的默認新生代收集器
優點:簡單高效,目前在用戶桌面的應用場景以及近年流行的部分微服務應用中使用,因為這些應用分配給虛擬機管理的記憶體一般來說不會很大,幾十兆甚至一兩百兆的新生代,垃圾收集的停頓時間可控在十幾,幾十毫秒,不影響體驗。
ParNew收集器
實質上是Serial收集器的多執行緒並行版本。
除了同時使用多條執行緒進行垃圾收集之外,其餘的行為包括Serial收集器可用的所有控制參數(例如:-XX:SurvivorRatio、-XX:PretenureSizeThreshold、-XX:HandlePromotionFailure等)、收集演算法、Stop The World、對象分配規則、回收策略等都與Serial收集器完全一致,
它是不少運行在服務端模式下的HotSpot虛擬機,尤其是JDK 7之前的遺留系統中首選的新生代收集器,其中有一個與功能、性能無關但其實很重要的原因是:除了Serial收集器外,目前只有它能與CMS收集器配合工作。
而G1是一個面向全堆的收集器,不再需要其他新生代收集器的配合工作。自JDK 9開始,ParNew加CMS收集器的組合就不再是官方推薦的服務端模式下的收集器解決方案了。
Parallel Scavenge
收集器
新生代收集器,基於標記-複製演算法實現,關注吞吐量。
垃圾收集停頓時間越短就越適合需要與用戶交互或需要保證服務響應品質的程式,良好的響應速度能提升用戶體驗;而高吞吐量則可以最高效率地利用處理器資源,儘快完成程式的運算任務,主要適合在後台運算而不需要太多交互的分析任務。
Parallel Scavenge收集器提供了兩個參數用於精確控制吞吐量,分別是控制最大垃圾收集停頓時間的-XX:MaxGCPauseMillis參數以及直接設置吞吐量大小的-XX:GCTimeRatio參數。
具有自適應調節策略:-XX:+UseAdaptiveSizePolicy(開關)
Serial Old
收集器
Serial Old是Serial收集器的老年代版本,它同樣是一個單執行緒收集器,使用標記-整理演算法。這個收集器的主要意義也是供客戶端模式下的HotSpot虛擬機使用。如果在服務端模式下,它也可能有兩種用途:一種是在JDK 5以及之前的版本中與Parallel Scavenge收集器搭配使用,另外一種就是作為CMS收集器發生失敗時的後備預案,在並發收集發生Concurrent Mode Failure時使用。
Parallel Old
收集器
Parallel Old是Parallel Scavenge收集器的老年代版本,支援多執行緒並發收集,基於標記-整理演算法實現。這個收集器是直到JDK 6時才開始提供的。直到Parallel Old收集器出現後,「吞吐量優先」收集器終於有了比較名副其實的搭配組合,在注重吞吐量或者處理器資源較為稀缺的場合,都可以優先考慮Parallel Scavenge加Parallel Old收集器這個組合。
CMS
收集器(Concurrent Mark Sweep)
一種以獲取最短回收停頓時間為目標的收集器。目前很大一部分的Java應用集中在互聯網網站或者基於瀏覽器的B/S系統的服務端上,這類應用通常都會較為關注服務的響應速度,希望系統停頓時間儘可能短,以給用戶帶來良好的交互體驗。CMS收集器就非常符合這類應用的需求。
它的運作過程相對於前面幾種收集器來說要更複雜一些,整個過程分為四個步驟,包括:
1)初始標記(CMS initial mark)
2)並發標記(CMS concurrent mark)
3)重新標記(CMS remark)
4)並發清除(CMS concurrent sweep)
其中初始標記、重新標記這兩個步驟仍然需要「Stop The World」。初始標記僅僅只是標記一下GC Roots能直接關聯到的對象,速度很快;並發標記階段就是從GC Roots的直接關聯對象開始遍歷整個對象圖的過程,這個過程耗時較長但是不需要停頓用戶執行緒,可以與垃圾收集執行緒一起並發運行;而重新標記階段則是為了修正並發標記期間,因用戶程式繼續運作而導致標記產生變動的那一部分對象的標記記錄(關於增量更新的講解),這個階段的停頓時間通常會比初始標記階段稍長一些,但也遠比並發標記階段的時間短;最後是並發清除階段,清理刪除掉標記階段判斷的已經死亡的對象,由於不需要移動存活對象,所以這個階段也是可以與用戶執行緒同時並發的。
由於在整個過程中耗時最長的並發標記和並發清除階段中,垃圾收集器執行緒都可以與用戶執行緒一起工作,所以從總體上來說,CMS收集器的記憶體回收過程是與用戶執行緒一起並發執行的。
優點:並發收集、低停頓。
缺點:
- 對處理器資源非常敏感,CMS默認啟動的回收執行緒數是(處理器核心數量+3)/4,在處理器核心數不足4個時,CMS對用戶程式的影響就可能變得很大。
- 無法處理「浮動垃圾」,在CMS的並發標記和並發清理階段,用戶執行緒是還在繼續運行的,程式在運行自然就還會伴隨有新的垃圾對象不斷產生,但這一部分垃圾對象是出現在標記過程結束以後,CMS無法在當次收集中處理掉它們,只好留待下一次垃圾收集時再清理掉。這一部分垃圾就稱為「浮動垃圾」。此外,CMS收集老年代時無法等待老年代填滿才收集,因為並發過程需要給用戶執行緒預留足夠的記憶體空間。JDK5設置的默認老年代空間閾值為68%,JDK6時提高到了92%。如果CMS運行期間預留的記憶體無法滿足程式分配新對象的需要,則會出現「Concurrent Mode Failure」並發失敗。這時候的後備選擇是臨時啟用Serial Old收集器重新進行老年代的垃圾收集。因此閾值設置還是比較重要的。
- 標記-清除演算法會造成大量的空間碎片,可能會出現老年代還有很多剩餘空間,但是不得不提前觸發Full GC。解決這個問題出現了兩種方案:一種是當CMS不得不進行Full GC時開啟記憶體碎片的合併整理,這時候移動存活對象無法並發(在Shenandoah和ZGC出現前),另一種是在進行若干次不整理空間的Full GC後,下一次進入Full GC前整理空間。這兩種方案都是通過兩個開關參數決定,JDK9之後廢棄。
G1
收集器
G1收集器是垃圾收集器技術發展歷史上的里程碑式的成果。G1是一款主要面向服務端應用的垃圾收集器。JDK 9發布之日,G1宣告取代Parallel Scavenge加Parallel Old組合,成為服務端模式下的默認垃圾收集器,而CMS則淪落至被聲明為不推薦使用(Deprecate)的收集器。從整體來看是基於「標記-整理」演算法實現的,從局部來看是基於「標記-複製」演算法實現的。
G1面向堆記憶體任何部分來組成回收集(Collection Set,一般簡稱CSet)進行回收,衡量標準不再是它屬於哪個分代,而是哪塊記憶體中存放的垃圾數量最多,回收收益最大,這就是G1收集器的Mixed GC模式。可指定停頓時間。G1關注於吞吐量和延遲之間的平衡。
G1不再堅持固定大小以及固定數量的分代區域劃分,而是把連續的Java堆劃分為多個大小相等的獨立區域(Region),每一個Region都可以根據需要,扮演新生代的Eden空間、Survivor空間,或者老年代空間。收集器能夠對扮演不同角色的Region採用不同的策略去處理,這樣無論是新創建的對象還是已經存活了一段時間、熬過多次收集的舊對象都能獲取很好的收集效果。
Region中還有一類特殊的Humongous區域,專門用來存儲大對象。G1認為只要大小超過了一個Region容量一半的對象即可判定為大對象。每個Region的大小可以通過參數-XX:G1HeapRegionSize設定,取值範圍為1MB~32MB,且應為2的N次冪。而對於那些超過了整個Region容量的超級大對象,將會被存放在N個連續的Humongous Region之中,G1的大多數行為都把Humongous Region作為老年代的一部分來進行看待。
G1仍保留新生代和老年代的概念,但是新生代和老年代不再是固定的了,它們是一系列區域的動態集合。
G1收集器之所以能建立可預測的停頓時間模型,是因為它將Region作為單次回收的最小單元,即每次收集到的記憶體空間都是Region大小的整數倍,這樣可以有計劃地避免在整個Java堆中進行全區域的垃圾收集。更具體的處理思路是讓G1收集器去跟蹤各個Region裡面的垃圾堆積的「價值」大小,
價值即回收所獲得的空間大小以及回收所需時間的經驗值
,然後在後台維護一個優先順序列表,每次根據用戶設定允許的收集停頓時間(使用參數-XX:MaxGCPauseMillis指定,默認值是200毫秒),優先處理回收價值收益最大的那些Region,這也就是「Garbage First」名字的由來。
將Java堆分成多個獨立Region後,Region裡面存在的跨Region引用對象如何解決?解決的思路我們已經知道:使用記憶集避免全堆作為GC Roots掃描,但在G1收集器上記憶集的應用其實要複雜很多,它的每個Region都維護有自己的記憶集,這些記憶集會記錄下別的Region指向自己的指針,並標記這些指針分別在哪些卡頁的範圍之內。G1的記憶集在存儲結構的本質上是一種哈希表,Key是別的Region的起始地址,Value是一個集合,裡面存儲的元素是卡表的索引號。這種「雙向」的卡表結構(卡表是「我指向誰」,這種結構還記錄了「誰指向我」)比原來的卡表實現起來更複雜,同時由於Region數量比傳統收集器的分代數量明顯要多得多,因此G1收集器要比其他的傳統垃圾收集器有著更高的記憶體佔用負擔。根據經驗,G1至少要耗費大約相當於Java堆容量10%至20%的額外記憶體來維持收集器工作。
另外,面對垃圾收集過程中,用戶改變對象引用關係時,G1採用原始快照演算法實現。
此外,垃圾收集對用戶執行緒的影響還體現在回收過程中新創建對象的記憶體分配上,程式要繼續運行就肯定會持續有新對象被創建,G1為每一個Region設計了兩個名為TAMS(Top at Mark Start)的指針,把Region中的一部分空間劃分出來用於並發回收過程中的新對象分配,並發回收時新分配的對象地址都必須要在這兩個指針位置以上。G1收集器默認在這個地址以上的對象是被隱式標記過的,即默認它們是存活的,不納入回收範圍。與CMS中的「Concurrent Mode Failure」失敗會導致Full GC類似,如果記憶體回收的速度趕不上記憶體分配的速度,G1收集器也要被迫凍結用戶執行緒執行,導致Full GC而產生長時間「Stop The World」。
收集過程:
- 初始標記(Initial Marking):僅僅只是標記一下GC Roots能直接關聯到的對象,並且修改TAMS指針的值,讓下一階段用戶執行緒並發運行時,能正確地在可用的Region中分配新對象。這個階段需要停頓執行緒,但耗時很短,而且是借用進行Minor GC的時候同步完成的,所以G1收集器在這個階段實際並沒有額外的停頓。
- 並發標記(Concurrent Marking):從GC Root開始對堆中對象進行可達性分析,遞歸掃描整個堆里的對象圖,找出要回收的對象,這階段耗時較長,但可與用戶程式並發執行。當對象圖掃描完成以後,還要重新處理SATB(原始快照)記錄下的在並發時有引用變動的對象。
- 最終標記(Final Marking):對用戶執行緒做另一個短暫的暫停,用於處理並發階段結束後仍遺留下來的最後那少量的SATB記錄。
- 篩選回收(Live Data Counting and Evacuation):負責更新Region的統計數據,對各個Region的回收價值和成本進行排序,根據用戶所期望的停頓時間來制定回收計劃,可以自由選擇任意多個Region構成回收集,然後把決定回收的那一部分Region的存活對象複製到空的Region中,再清理掉整箇舊Region的全部空間。這裡的操作涉及存活對象的移動,是必須暫停用戶執行緒,由多條收集器執行緒並行完成的。
G1收集器除了並發標記外,其餘階段也是要完全暫停用戶執行緒的,換言之,它並非純粹地追求低延遲,官方給它設定的目標是在延遲可控的情況下獲得儘可能高的吞吐量,所以才能擔當起「全功能收集器」的重任與期望。而增加停頓時間能最大程度地提高垃圾收集的效果。
從G1開始,最先進的垃圾收集器的設計導向都不約而同地變為追求能夠應付應用的記憶體分配速率(Allocation Rate),而不追求一次把整個Java堆全部清理乾淨。這樣,應用在分配,同時收集器在收集,只要收集的速度能跟得上對象分配的速度,那一切就能運作得很完美。這種新的收集器設計思路從工程實現上看是從G1開始興起的,所以說G1是收集器技術發展的一個里程碑。
相比CMS,G1的優點有很多,暫且不論可以指定最大停頓時間、分Region的記憶體布局、按收益動態確定回收集這些創新性設計帶來的紅利,單從最傳統的演算法理論上看,G1也更有發展潛力。與CMS的「標記-清除」演算法不同,G1從整體來看是基於「標記-整理」演算法實現的收集器,但從局部(兩個Region之間)上看又是基於「標記-複製」演算法實現,無論如何,這兩種演算法都意味著G1運作期間不會產生記憶體空間碎片,垃圾收集完成之後能提供規整的可用記憶體。這種特性有利於程式長時間運行,在程式為大對象分配記憶體時不容易因無法找到連續記憶體空間而提前觸發下一次收集。
比起CMS,G1的弱項也可以列舉出不少,如在用戶程式運行過程中,G1無論是為了垃圾收集產生的記憶體佔用(Footprint)還是程式運行時的額外執行負載(Overload)都要比CMS要高。
CMS和G1的對比
就記憶體佔用來說,雖然G1和CMS都使用卡表來處理跨代指針,但G1的卡表實現更為複雜,而且堆中每個Region,無論扮演的是新生代還是老年代角色,都必須有一份卡表,這導致G1的記憶集(和其他記憶體消耗)可能會佔整個堆容量的20%乃至更多的記憶體空間;相比起來CMS的卡表就相當簡單,只有唯一一份,而且只需要處理老年代到新生代的引用,反過來則不需要,由於新生代的對象具有朝生夕滅的不穩定性,引用變化頻繁,能省下這個區域的維護開銷是很划算的。
在執行負載的角度上,同樣由於兩個收集器各自的細節實現特點導致了用戶程式運行時的負載會有不同,譬如它們都使用到寫屏障,CMS用寫後屏障來更新維護卡表;而G1除了使用寫後屏障來進行同樣的(由於G1的卡表結構複雜,其實是更煩瑣的)卡表維護操作外,為了實現原始快照搜索(SATB)演算法,還需要使用寫前屏障來跟蹤並發時的指針變化情況。相比起增量更新演算法,原始快照搜索能夠減少並發標記和重新標記階段的消耗,避免CMS那樣在最終標記階段停頓時間過長的缺點,但是在用戶程式運行過程中確實會產生由跟蹤引用變化帶來的額外負擔。由於G1對寫屏障的複雜操作要比CMS消耗更多的運算資源,所以CMS的寫屏障實現是直接的同步操作,而G1就不得不將其實現為類似於消息隊列的結構,把寫前屏障和寫後屏障中要做的事情都放到隊列里,然後再非同步處理。
淺色表示用戶執行緒掛起,深色為並發。
經典垃圾收集器中還有ZGC,Shenandoah等,JDK11中默認使用的就是ZGC。
在本篇中,很多涉及虛擬機的具體參數並未提及,可參考其他部落格。
記憶體分配與回收策略
Java技術體系的自動記憶體管理,最根本的目標是自動化地解決兩個問題:自動給對象分配記憶體以及自動回收分配給對象的記憶體。驗證的實際是使用Serial加Serial Old客戶端默認收集器組合下的記憶體分配和回收的策略。
對象優先在Eden分配
大多數情況下,對象在新生代Eden區中分配。當Eden區沒有足夠空間進行分配時,虛擬機將發起一次Minor GC。HotSpot虛擬機提供了-XX:+PrintGCDetails這個收集器日誌參數,告訴虛擬機在發生垃圾收集行為時列印記憶體回收日誌,並且在進程退出的時候輸出當前的記憶體各區域分配情況。
private static final int _1MB = 1024 * 1024;
/**
* VM參數:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
分配 20M 虛擬機運行記憶體,10M新生代,10M老年代,運行日誌,Eden:Survivor=8:1
*/
public static void testAllocation() {
byte[] allocation1, allocation2, allocation3, allocation4;
allocation1 = new byte[2 * _1MB];
allocation2 = new byte[2 * _1MB];
allocation3 = new byte[2 * _1MB];
allocation4 = new byte[4 * _1MB]; // 出現一次Minor GC,這時6MB的1,2,3轉移到老年代,4M的進入Eden
}
[GC [DefNew: 6651K->148K(9216K), 0.0070106 secs] 6651K->6292K(19456K), 0.0070426 secs][Times:user=0.00 sys=0.00....
Heap
def new generation total 9216K, used 4326K [0x029d0000, 0x033d0000, 0x033d0000)
eden space 8192K, 51% used [0x029d0000, 0x02de4828, 0x031d0000)
from space 1024K, 14% used [0x032d0000, 0x032f5370, 0x033d0000)
to space 1024K, 0% used [0x031d0000, 0x031d0000, 0x032d0000)
tenured generation total 10240K, used 6144K [0x033d0000, 0x03dd0000, 0x03dd0000)
the space 10240K, 60% used [0x033d0000, 0x039d0030, 0x039d0200, 0x03dd0000)
compacting perm gen total 12288K, used 2114K [0x03dd0000, 0x049d0000, 0x07dd0000)
the space 12288K, 17% used [0x03dd0000, 0x03fe0998, 0x03fe0a00, 0x049d0000)
No shared spaces configured.
日誌如上。
執行testAllocation()中分配allocation4對象的語句時會發生一次Minor GC,這次回收的結果是新生代6651KB變為148KB,而總記憶體佔用量則幾乎沒有減少(因為allocation1、2、3三個對象都是存活的,虛擬機幾乎沒有找到可回收的對象)。產生這次垃圾收集的原因是為allocation4分配記憶體時,發現Eden已經被佔用了6MB,剩餘空間已不足以分配allocation4所需的4MB記憶體,因此發生Minor GC。垃圾收集期間虛擬機又發現已有的三個2MB大小的對象全部無法放入Survivor空間(Survivor空間只有1MB大小),所以只好通過分配擔保機制提前轉移到老年代去。
大對象直接進入老年代
大對象就是指需要大量連續記憶體空間的Java對象,最典型的大對象便是那種很長的字元串,或者元素數量很龐大的數組,上面例子中的byte[]數組就是典型的大對象
在Java虛擬機中要避免大對象的原因是,在分配空間時,它容易導致記憶體明明還有不少空間時就提前觸發垃圾收集,以獲取足夠的連續空間才能安置好它們,而當複製對象時,大對象就意味著高額的記憶體複製開銷。
HotSpot虛擬機提供了-XX:PretenureSizeThreshold參數,指定大於該設置值的對象直接在老年代分配,這樣做的目的就是避免在Eden區及兩個Survivor區之間來回複製,產生大量的記憶體複製操作。
長期存活的對象將進入老年代
HotSpot虛擬機中多數收集器都採用了分代收集來管理堆記憶體,那記憶體回收時就必須能決策哪些存活對象應當放在新生代,哪些存活對象放在老年代中。為做到這點,虛擬機給每個對象定義了一個對象年齡(Age)計數器,存儲在對象頭中(詳見對象的記憶體布局)。對象通常在Eden區里誕生,如果經過第一次Minor GC後仍然存活,並且能被Survivor容納的話,該對象會被移動到Survivor空間中,並且將其對象年齡設為1歲。對象在Survivor區中每熬過一次Minor GC,年齡就增加1歲,當它的年齡增加到一定程度(默認為15),就會被晉陞到老年代中。對象晉陞老年代的年齡閾值,可以通過參數-XX:MaxTenuringThreshold設置。
動態對象年齡判定
為了能更好地適應不同程式的記憶體狀況,HotSpot虛擬機並不是永遠要求對象的年齡必須達到-XX:MaxTenuringThreshold才能晉陞老年代,如果在Survivor空間中相同年齡所有對象大小的總和大於Survivor空間的一半,年齡大於或等於該年齡的對象就可以直接進入老年代,無須等到-XX:MaxTenuringThreshold中要求的年齡。
private static final int _1MB = 1024 * 1024;
/**
* VM參數:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
-XX:MaxTenuringThreshold=15
* -XX:+PrintTenuringDistribution
*/
@SuppressWarnings("unused")
public static void testTenuringThreshold2() {
byte[] allocation1, allocation2, allocation3, allocation4;
allocation1 = new byte[_1MB / 4]; // allocation1+allocation2大於survivo空間一半
allocation2 = new byte[_1MB / 4];
allocation3 = new byte[4 * _1MB];
allocation4 = new byte[4 * _1MB];
allocation4 = null;
allocation4 = new byte[4 * _1MB];
}
all1和all2加起來佔用512KB,滿足同年齡對象打到Suvivor空間一半的規則,因此將兩個對象放入老年代。
空間分配擔保
在發生Minor GC之前,虛擬機必須先檢查老年代最大可用的連續空間是否大於新生代所有對象總空間,如果這個條件成立,那這一次Minor GC可以確保是安全的。如果不成立,則虛擬機會先查看-XX:HandlePromotionFailure參數的設置值是否允許擔保失敗(Handle Promotion Failure);如果允許,那會繼續檢查老年代最大可用的連續空間是否大於歷次晉陞到老年代對象的平均大小,如果大於,將嘗試進行一次Minor GC,儘管這次Minor GC是有風險的;如果小於,或者-XX:HandlePromotionFailure設置不允許冒險,那這時就要改為進行一次Full GC。
解釋一下「冒險」是冒了什麼風險:前面提到過,新生代使用複製收集演算法,但為了記憶體利用率,只使用其中一個Survivor空間來作為輪換備份,因此當出現大量對象在Minor GC後仍然存活的情況——最極端的情況就是記憶體回收後新生代中所有對象都存活,需要老年代進行分配擔保,把Survivor無法容納的對象直接送入老年代,這與生活中貸款擔保類似。老年代要進行這樣的擔保,前提是老年代本身還有容納這些對象的剩餘空間,但一共有多少對象會在這次回收中活下來在實際完成記憶體回收之前是無法明確知道的,所以只能取之前每一次回收晉陞到老年代對象容量的平均大小作為經驗值,與老年代的剩餘空間進行比較,決定是否進行Full GC來讓老年代騰出更多空間。
取歷史平均值來比較其實仍然是一種賭概率的解決辦法,也就是說假如某次Minor GC存活後的對象突增,遠遠高於歷史平均值的話,依然會導致擔保失敗。如果出現了擔保失敗,那就只好老老實實地重新發起一次Full GC,這樣停頓時間就很長了。雖然擔保失敗時繞的圈子是最大的,但通常情況下都還是會將-XX:HandlePromotionFailure開關打開,避免Full GC過於頻繁。
在JDK 6 Update 24之後,雖然源碼中還定義了-XX:HandlePromotionFailure參數,但是在實際虛擬機中已經不會再使用它。JDK 6 Update 24之後的規則變為只要老年代的連續空間大於新生代對象總大小或者歷次晉陞的平均大小,就會進行Minor GC,否則將進行Full GC。