JVM-垃圾收集器與記憶體分配策略

GaoYuan206發表於2021-11-12

垃圾收集器與記憶體分配策略

一個垃圾收集器除了垃圾收集這個本職工作之外,它還要負責堆的管理與佈局、物件的分配、與直譯器的協作、與編譯器的協作、與監控子系統協作等職責,其中至少堆的管理和物件的分配這部分功能是Java虛擬機器能夠正常運作的必要支援,是一個最小化功能的垃圾收集器也必須實現的內容。

垃圾收集關注的是堆和方法區的記憶體如何管理。

程式計數器、虛擬機器棧、本地方法棧3個區域隨執行緒而生,隨執行緒而滅,棧中的棧幀隨著方法的進入和退出而有條不紊地執行著出棧和入棧操作。每一個棧幀中分配多少記憶體基本上是在類結構確定下來時就已知的(儘管在執行期會由即時編譯器進行一些優化,但在基於概念模型的討論裡,大體上可以認為是編譯期可知的),因此這幾個區域的記憶體分配和回收都具備確定性,在這幾個區域內就不需要過多考慮如何回收的問題,當方法結束或者執行緒結束時,記憶體自然就跟隨著回收了。

判斷物件存活狀態

引用計數演算法

在物件中新增一個引用計數器,每當有一個地方引用它時,計數器值就加一;當引用失效時,計數器值就減一;任何時刻計數器為零的物件就是不可能再被使用的。

客觀來說,這種方法只需要佔據一部分額外記憶體即可實現進行計數,微軟COM技術,Python語言等等一些應用中都有引用計數法進行記憶體管理。但是Java的主流虛擬機器都沒有選用這種演算法,因為有很多例外情況需要判斷,比如說單純的引用計數很難解決物件之間相互引用的問題。(A->B,B->A這樣的話雙方計數器都為1,除此之外再無引用,也不被訪問)

可達性分析演算法

當前主流的商用程式語言(Java、C#,上溯至前面提到的古老的Lisp)的記憶體管理子系統,都是通過可達性分析(Reachability Analysis)演算法來判定物件是否存活的。這個演算法的基本思路就是通過一系列稱為“GC Roots”的根物件作為起始節點集,從這些節點開始,根據引用關係向下搜尋,搜尋過程所走過的路徑稱為“引用鏈”(Reference Chain),如果某個物件到GC Roots間沒有任何引用鏈相連,或者用圖論的話來說就是從GC Roots到這個物件不可達時,則證明此物件是不可能再被使用的。

image-20211106144914392

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堆中包含大量物件,而且其中大部分是需要被回收的,這時必須進行大量標記和清除的動作,導致標記和清除兩個過程的執行效率都隨物件數量增長而降低;第二個是記憶體空間的碎片化問題,標記、清除之後會產生大量不連續的記憶體碎片,空間碎片太多可能會導致當以後在程式執行過程中需要分配較大物件時無法找到足夠的連續記憶體而不得不提前觸發另一次垃圾收集動作。

演算法執行過程:

image-20211109220704459

標記-複製演算法

將可用記憶體按容量劃分為大小相等的兩塊,每次只使用其中的一塊。當這一塊的記憶體用完了,就將還存活著的物件複製到另外一塊上面,然後再把已使用過的記憶體空間一次清理掉。

演算法執行過程:

image-20211109221500313

如果記憶體中多數物件都是存活的,這種演算法將會產生大量的記憶體間複製的開銷,但對於多數物件都是可回收的情況,演算法需要複製的就是佔少數的存活物件,而且每次都是針對整個半區進行記憶體回收,分配記憶體時也就不用考慮有空間碎片的複雜情況,只要移動堆頂指標,按順序分配即可。這樣實現簡單,執行高效,不過其缺陷也顯而易見,這種複製回收演算法的代價是將可用記憶體縮小為了原來的一半,空間浪費未免太多了一點。

現在的商用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)。

標記-整理演算法

標記-複製演算法在面對存活率較高的情況時需要進行大量複製操作,效率將會降低。老年代一般不能直接選用這種演算法。

標記-整理演算法的標記過程仍然與“標記-清除”演算法一樣,但後續步驟不是直接對可回收物件進行清理,而是讓所有存活的物件都向記憶體空間一端移動,然後直接清理掉邊界以外的記憶體。

回收過程:

image-20211109230107984

至於什麼時候採取哪種演算法,則需要根據情況討論:

介紹一下吞吐量

吞吐量 = 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收集器:單執行緒工作的收集器,在它進行垃圾收集時,必須暫停其他工作執行緒,直至收集結束。

image-20211110212026654

迄今為止,它依然是HotSpot虛擬機器執行在客戶端模式下的預設新生代收集器

優點:簡單高效,目前在使用者桌面的應用場景以及近年流行的部分微服務應用中使用,因為這些應用分配給虛擬機器管理的記憶體一般來說不會很大,幾十兆甚至一兩百兆的新生代,垃圾收集的停頓時間可控在十幾,幾十毫秒,不影響體驗。

ParNew收集器實質上是Serial收集器的多執行緒並行版本。

除了同時使用多條執行緒進行垃圾收集之外,其餘的行為包括Serial收集器可用的所有控制引數(例如:-XX:SurvivorRatio、-XX:PretenureSizeThreshold、-XX:HandlePromotionFailure等)、收集演算法、Stop The World、物件分配規則、回收策略等都與Serial收集器完全一致,

image-20211110212303992

它是不少執行在服務端模式下的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收集器這個組合。

image-20211111141044646

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收集器的記憶體回收過程是與使用者執行緒一起併發執行的。

image-20211111142348832

優點:併發收集、低停頓。

缺點:

  • 對處理器資源非常敏感,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收集器除了併發標記外,其餘階段也是要完全暫停使用者執行緒的,換言之,它並非純粹地追求低延遲,官方給它設定的目標是在延遲可控的情況下獲得儘可能高的吞吐量,所以才能擔當起“全功能收集器”的重任與期望。而增加停頓時間能最大程度地提高垃圾收集的效果。

image-20211111165759431

從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就不得不將其實現為類似於訊息佇列的結構,把寫前屏障和寫後屏障中要做的事情都放到佇列裡,然後再非同步處理。

淺色表示使用者執行緒掛起,深色為併發。

image-20211112152412720

經典垃圾收集器中還有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。

相關文章