JVM垃圾收集算法演化

需要《深入理解JVM虛擬機》第三版 掃描版PDF的可以加QQ  3454779752 ,有償,謝謝

名詞定義

·部分收集(Partial GC):指目標不是完整收集整個Java堆的垃圾收集,其中又分爲:
    ■新生代收集(Minor GC/Young GC):指目標只是新生代的垃圾收集。
    ■老年代收集(Major GC/Old GC):指目標只是老年代的垃圾收集。目前只有CMS收集器會有單獨收集老年代的行爲。另外請注意“Major GC”這個說法現在有點混淆,在不同資料上常有不同所指,讀者需按上下文區分到底是指老年代的收集還是整堆收集。
    ■混合收集(Mixed GC):指目標是收集整個新生代以及部分老年代的垃圾收集。目前只有G1收集器會有這種行爲。
·整堆收集(Full GC):收集整個Java堆和方法區的垃圾收集。
   
 

前言

垃圾收集算法可以劃分爲引用計數式垃圾收集Reference Counting GC)和追蹤式垃圾收集Tracing GC)兩大類,這兩類也常被稱作直接垃圾收集間接垃圾收集”。但引用計數式垃圾收集類算法在主流虛擬機中不涉及,所以這次只看追蹤式垃圾收集。 正文內容主要是  “追蹤式垃圾收集”(Tracing GC) 
 
 

依據假說

      當前商業虛擬機的垃圾收集器,大多數都遵循了分代收集Generational Collection的理論進 行設計,分代收集名爲理論,實質是一套符合大多數程序運行實際情況的經驗法則,它建立在兩個分代假說之上:
1弱分代假說Weak Generational Hypothesis):絕大多數對象都是朝生夕滅的。
2強分代假說Strong Generational Hypothesis):熬過越多次垃圾收集過程的對象就越難以消亡。
這兩個分代假說共同奠定了多款常用的垃圾收集器的一致的設計原則:收集器應該將Java堆劃分出不同的區域,然後將回收對象依據其年齡(年齡即對象熬過垃圾收集過程的次數)分配到不同的區域之中存儲。顯而易見,如果一個區域中大多數對象都是朝生夕滅,難以熬過垃圾收集過程的話,那麼把它們集中放在一起,每次回收時只關注如何保留少量存活而不是去標記那些大量將要被回收的對象,就能以較低代價回收到大量的空間;如果剩下的都是難以消亡的對象,那把它們集中放在一塊,虛擬機便可以使用較低的頻率來回收這個區域,這就同時兼顧了垃圾收集的時間開銷和內存的空間有效利用。
 
  在Java堆劃分出不同的區域之後,垃圾收集器纔可以每次只回收其中某一個或者某些部分的區域——因而纔有了“Minor GC”“Major GC”“Full GC”這樣的回收類型的劃分;也才能夠針對不同的區域安排與裏面存儲對象存亡特徵相匹配的垃圾收集算法——因而發展出了標記-複製算法”“標記-清除算法”“標記-整理算法”等針對性的垃圾收集算法。
 
  把分代收集理論具體放到現在的商用Java虛擬機裏,設計者一般至少會把Java堆劃分爲新生代(Young Generation)和老年代(Old Generation)兩個區域。顧名思義,在新生代中,每次垃圾收集時都發現有大批對象死去,而每次回收後存活的少量對象,將會逐步晉升到老年代中存放。
 
   簡單劃分一下內存區域那麼容易,它至少存在一個明顯的困難:對象不是孤立的,對象之間會存在跨代引用。假如要現在進行一次只侷限於新生代區域內的收集(Minor GC),但新生代中的對象是完全有可能被老年代所引用的,爲了找出該區域中的存活對象,不得不在固定的GC Roots之外,再額外遍歷整個老年代中所有對象來確保可達性分析結果的正確性,反過來也是一樣。遍歷整個老年代所有對象的方案雖然理論上可行,但無疑會爲內存回收帶來很大的性能負擔。爲了解決這個問題,就需要對分
代收集理論添加第三條經驗法則:
3跨代引用假說Intergenerational Reference Hypothesis):跨代引用相對於同代引用來說僅佔極少數這其實是可根據前兩條假說邏輯推理得出的隱含推論:存在互相引用關係的兩個對象,是應該傾向於同時生存或者同時消亡的。舉個例子,如果某個新生代對象存在跨代引用,由於老年代對象難以消亡,該引用會使得新生代對象在收集時同樣得以存活,進而在年齡增長之後晉升到老年代中,這時跨代引用也隨即被消除了。
  依據這條假說,我們就不應再爲了少量的跨代引用去掃描整個老年代,也不必浪費空間專門記錄每一個對象是否存在及存在哪些跨代引用,只需在新生代上建立一個全局的數據結構(該結構被稱爲“記憶集Remembered Set),這個結構把老年代劃分成若干小塊,標識出老年代的哪一塊內存會存在跨代引用。此後當發生Minor GC時,只有包含了跨代引用的小塊內存裏的對象纔會被加入到GCRoots進行掃描。雖然這種方法需要在對象改變引用關係(如將自己或者某個屬性賦值)時維護記錄數據的正確性,會增加一些運行時的開銷,但比起收集時掃描整個老年代來說仍然是划算的。
 

理論發展

1. 標記-清除算法

    最早出現也是最基礎的垃圾收集算法是標記-清除Mark-Sweep)算法,在1960年由Lisp之父John McCarthy所提出。如它的名字一樣,算法分爲標記清除兩個階段:首先標記出所有需要回收的對象,在標記完成後,統一回收掉所有被標記的對象,也可以反過來,標記存活的對象,統一回收所有未被標記的對象。標記過程就是對象是否屬於垃圾的判定過程。
 
    之所以說它是最基礎的收集算法,是因爲後續的收集算法大多都是以標記-清除算法爲基礎,對其缺點進行改進而得到的。它的主要缺點有兩個:
    第一個是執行效率不穩定,如果Java堆中包含大量對象,而且其中大部分是需要被回收的,這時必須進行大量標記和清除的動作,導致標記和清除兩個過程的執行效率都隨對象數量增長而降低;
    第二個是內存空間的碎片化問題,標記、清除之後會產生大量不連續的內存碎片,空間碎片太多可能會導致當以後在程序運行過程中需要分配較大對象時無法找到足夠的連續內存而不得不提前觸發另一次垃圾收集動作。標記-清除算法的執行過程如圖所示。
                                                                            圖1  “標記-清除算法示意圖
 
 

2.標記-複製算法

    標記-複製算法常被簡稱爲複製算法。爲了解決標記-清除算法面對大量可回收對象時執行效率低的問題,1969Fenichel提出了一種稱爲半區複製Semispace Copying)的垃圾收集算法,它將可用內存按容量劃分爲大小相等的兩塊,每次只使用其中的一塊。當這一塊的內存用完了,就將還存活着的對象複製到另外一塊上面,然後再把已使用過的內存空間一次清理掉。如果內存中多數對象都是存活的,這種算法將會產生大量的內存間複製的開銷,但對於多數對象都是可回收的情況,算法需要複製的就是佔少數的存活對象,而且每次都是針對整個半區進行內存回收,分配內存時也就不用考慮有空間碎片的複雜情況,只要移動堆頂指針,按順序分配即可。這樣實現簡單,運行高效,不過其缺陷也顯而易見,這種複製回收算法的代價是將可用內存縮小爲了原來的一半,空間浪費未免太多了一點。標記-複製算法的執行過程如圖所示。
                                                                        圖2 標記-複製算法示意圖
 
    現在的商用Java虛擬機大多都優先採用了這種收集算法去回收新生代,IBM公司曾有一項專門研究對新生代“朝生夕滅的特點做了更量化的詮釋——新生代中的對象有98%熬不過第一輪收集。因此並不需要按照11的比例來劃分新生代的內存空間。在1989年,Andrew Appel針對具備朝生夕滅特點的對象,提出了一種更優化的半區複製分代策略,現在稱爲“Appel式回收HotSpot虛擬機的SerialParNew等新生代收集器均採用了這種策略來設計新生代的內存佈局Appel式回收的具體做法是把新生代分爲一塊較大的Eden空間和兩塊較小的Survivor空間,每次分配內存只使用Eden和其中一塊Survivor。發生垃圾蒐集時,將EdenSurvivor中仍然存活的對象一次性複製到另外一塊Survivor空間上,然後直接清理掉Eden和已用過的那塊Survivor間。
 
    HotSpot虛擬機默認EdenSurvivor的大小比例是81,也即每次新生代中可用內存空間爲整個新生代容量的90%Eden80%加上一個Survivor10%),只有一個Survivor空間,即10%的新生代是會被“浪費的。當然,98%的對象可被回收僅僅是普通場景下測得的數據,任何人都沒有辦法百分百保證每次回收都只有不多於10%的對象存活,因此Appel式回收還有一個充當罕見情況的逃生門的安全設計,當Survivor空間不足以容納一次Minor GC之後存活的對象時,就需要依賴其他內存區域(實際上大多就是老年代)進行分配擔保(Handle Promotion)。內存的分配擔保好比我們去銀行借款,如果我們信譽很好,在98%的情況下都能按時償還,於是銀行可能會默認我們下一次也能按時按量地償還貸款,只需要有一個擔保人能保證如果我不能還款時,可以從他的賬戶扣錢,那銀行就認爲沒有什麼風險了。內存的分配擔保也一樣,如果另外一塊Survivor空間沒有足夠空間存放上一次新生代收集下來的存活對象,這些對象便將通過分配擔保機制直接進入老年代,這對虛擬機來說就是安全的。
 

3 標記-整理算法

   標記-複製算法在對象存活率較高時就要進行較多的複製操作,效率將會降低。更關鍵的是,如果不想浪費50%的空間,就需要有額外的空間進行分配擔保,以應對被使用的內存中所有對象都100%存活的極端情況,所以在老年代一般不能直接選用這種算法。針對老年代對象的存亡特徵,1974Edward Lueders提出了另外一種有針對性的標記-整理”Mark-Compact)算法,其中的標記過程仍然與標記-清除算法一樣,但後續步驟不是直接對可回收對象進行清理,而是讓所有存活的對象都向內存空間一端移動,然後直接清理掉邊界以外的內存,“標記-整理算法的示意圖如圖3所示。標記-清除算法與標記-整理算法的本質差異在於前者是一種非移動式的回收算法,而後者是移動式的。是否移動回收後的存活對象是一項優缺點並存的風險決策:
                                                                                    圖3 “標記-整理算法示意圖
 
    如果移動存活對象,尤其是在老年代這種每次回收都有大量對象存活區域,移動存活對象並更新所有引用這些對象的地方將會是一種極爲負重的操作,而且這種對象移動操作必須全程暫停用戶應用程序才能進行(最新的ZGC和Shenandoah收集器使用讀屏障(Read Barrier)技術實現了整理過程與用戶線程的併發執行),這就更加讓使用者不得不小心翼翼地權衡其弊端了,像這樣的停頓被最初的虛擬機設計者形象地描述爲“Stop The World”(通常標記-清除算法也是需要停頓用戶線程來標記、清理可回收對象的,只是停頓時間相對而言要來的短而已)。但如果跟標記-清除算法那樣完全不考慮移動和整理存活對象的話,彌散於堆中的存活對象導致的空間碎片化問題就只能依賴更爲複雜的內存分配器和內存訪問器來解決。譬如通過“分區空閒分配鏈表”來解決內存分配問題(計算機硬盤存儲大文件就不要求物理連續的磁盤空間,能夠在碎片化的硬盤上存儲和訪問就是通過硬盤分區表實現的)。內存的訪問是用戶程序最頻繁的操作,甚至都沒有之一,假如在這個環節上增加了額外的負擔,勢必會直接影響應用程序的吞吐量。
 
    基於以上兩點,是否移動對象都存在弊端,移動則內存回收時會更復雜,不移動則內存分配時會更復雜。從垃圾收集的停頓時間來看,不移動對象停頓時間會更短,甚至可以不需要停頓,但是從整個程序的吞吐量來看,移動對象會更划算。此語境中,吞吐量的實質是賦值器(Mutator,可以理解爲使用垃圾收集的用戶程序,本書爲便於理解,多數地方用“用戶程序用戶線程代替)與收集器的效率總和。即使不移動對象會使得收集器的效率提升一些,但因內存分配和訪問相比垃圾收集頻率要高得多,這部分的耗時增加,總吞吐量仍然是下降的。HotSpot虛擬機裏面關注吞吐量的ParallelScavenge收集器是基於標記-整理算法的,而關注延遲的CMS收集器則是基於標記-清除算法的,這也從側面印證這點。
 
     另外,還有一種和稀泥式解決方案可以不在內存分配和訪問上增加太大額外負擔,做法是讓虛擬機平時多數時間都採用標記-清除算法,暫時容忍內存碎片的存在,直到內存空間的碎片化程度已經大到影響對象分配時,再採用標記-整理算法收集一次,以獲得規整的內存空間。前面提到的基於標記-清除算法的CMS收集器面臨空間碎片過多時採用的就是這種處理辦法。
 
 
參考:
深入理解JVM第三版 第三章  第三節
 
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章