垃圾收集原理依據及要點


分代收集理論
理論支撐:
  • 弱分代假說(Weak Generational Hypothesis):絕大多數對象都是朝生夕滅的。
  • 強分代假說(Strong Generational Hypothesis):熬過越多次垃圾收集過程的對象就越難以消亡。
  • 跨代引用假說(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虛擬機大多都優先采用了這種收集算法去回收新生代。因為新生代中的對象有98%熬不過第一輪收集,所以,當然實際實現並不是1:1的比例來划分新生代內存空間。而是使用“Appel式回收”,具體做法是把新生代分為一塊較大的Eden空間和兩塊較小的 Survivor空間,每次分配內存只使用Eden和其中一塊Survivor。發生垃圾搜集時,將Eden和Survivor中仍然存活的對象一次性復制到另外一塊Survivor空間上,然后直接清理掉Eden和已用過的那塊Survivor空間。HotSpot虛擬機默認Eden和Survivor的大小比例是8∶1,也即每次新生代中可用內存空間為整個新生代容量的90%(Eden的80%加上一個Survivor的10%),只有一個Survivor空間,即10%的新生代是會被“浪費”的。當Survivor空間不足以容納一次Minor GC之后存活的對象時,就需要依賴其他內存區域(實際上大多就是老年代)進行分配擔保(Handle Promotion),這些對象便將通過分配擔保機制直接進入老年代。
 
標記-整理算法
在對象存活率較高時就要進行較多的復制操作,效率將會降低。更關鍵的是,如果不想浪費50%的空間,就需要有額外的空間進行分配擔保,以應對被使用的內存中所有對象都100%存活的極端情況,所以在老年代一般不能直接選用標記-復制算法。
針對老年代對象的存亡特征有另外一種有針對性的“標記-整 理”(Mark-Compact)算法,其中的標記過程仍然與“標記-清除”算法一樣,但后續步驟不是直接對可回收對象進行清理,而是讓所有存活的對象都向內存空間一端移動,然后直接清理掉邊界以外的內存。
如果移動存活對象,尤其是在老年代這種每次回收都有大量對象存活區域,移動存活對象並更新所有引用這些對象的地方將會是一種極為負重的操作,而且這種對象移動操作必須全程暫停用戶應用程序才能進行,這就更加讓使用者不得不小心翼翼地權衡其弊端了,像這樣的停頓被最初的虛擬機設計者形象地描述為“Stop The World”。
是否移動對象都存在弊端,移動則內存回收時會更復雜,不移動則內存分配時會更復雜。從垃圾收集的停頓時間來看,不移動對象停頓時間會更短,甚至可以不需要停頓,但是從整個程序的吞吐量來看,移動對象會更划算。HotSpot虛擬機里面關注吞吐量的Parallel Scavenge收集器是基於標記-整理算法的,而關注延遲的CMS收集器則是基於標記-清除算法的。
CMS收集器也會在內存空間的碎片化程度已經大到影響對象分配時,采用標記-整理算法收集一次,以獲得規整的內存空間。
 
 
根節點枚舉
固定可作為GC Roots的節點主要在全局性的引用(例如常量或類靜態屬性)與執行上下文(例如棧幀中的本地變量表)中,盡管目標明確,但現在Java應用越做越龐大,光是方法區的大小就常有數百上千兆,里面的類、常量等更是恆河沙數,查找過程要做到高效並非一件容易的事情。迄今為止,所有收集器在根節點枚舉這一步驟時都是必須暫停用戶線程的。必須在一個能保障一致性的快照中才得以進行——這里“一致性”的意思是整個枚舉期間執行子系統看起來就像被凍結在某個時間點上,不會出現分析過程中,根節點集合的對象引用關系還在不斷變化的情況,若這點不能滿足的話,分析結果准確性也就無法保證。
主流Java虛擬機都是准確式垃圾收集,所以並不需要一個不漏地檢查完所有執行上下文和全局的引用位置。在HotSpot 的解決方案里,是使用一組稱為OopMap的數據結構來達到這個目的。OopMap 記錄了棧上本地變量和寄存器到堆上對象的引用關系。其作用是:收集器只要掃描這些OopMap就可以直接得知這些GC Roots,並不需要真正一個不漏地從執行上下文等GC Roots開始查找。
 
 
安全點
在OopMap的協助下,HotSpot可以快速准確地完成GC Roots枚舉,但是,可能導致引用關系變化,或者說導致OopMap內容變化的指令非常多,如果為每一條指令都生成對應的OopMap,那將會需要大量的額外存儲空間。HotSpot只是在“特定的位置”記錄 了這些信息,這些位置被稱為安全點(Safep oint)。有了安全點的設定,也就決定了用戶程序執行時 並非在代碼指令流的任意位置都能夠停頓下來開始垃圾收集,而是強制要求必須執行到達安全點后才能夠暫停。
因此,安全點的選定既不能太少以至於讓收集器等待時間過長,也不能太過頻繁以至於過分增大運行時的內存負荷。安全點位置的選取基本上是以“是否具有讓程序長時間執行的特征”為標准進行選定的,又因為每條指令執行的時間都非常短暫,程序不太可能因為指令流長度太長這樣的原因而長時間執行,所以“長時間執行”的最明顯特征就是指令序列的復用,例如 方法調用、循環跳轉、異常跳轉等都屬於指令序列復用,所以只有具有這些功能的指令才會產生安全點。( 實際上還要加上所有創建對象和其他需要在Java堆上分配內存的地方,這是為了檢查是否即將要發生垃圾收集,避免沒有足夠內存分配新對象
那如何在垃圾收集發生時,讓所有線程都跑到最近的安全點,然后停頓下來呢。有兩種方案可供選擇:搶先式中斷 (Preemptive Suspension)和主動式中斷(Voluntary Suspension)。
搶先式中斷不需要線程的執行代碼主動去配合,在垃圾收集發生時,系統首先把所有用戶線程全部中斷,如果發現有用戶線程中斷的地方不在安全點上,就恢復這條線程執行,讓它一會再重新中斷,直到跑到安全點上。現在幾乎沒有虛擬機實現采用搶先式中斷來暫停線程響應GC事件。
主動式中斷的思想是當垃圾收集需要中斷線程的時候,不直接對線程操作,僅僅簡單地設置一個標志位,各個線程執行過程時會不停地主動去輪詢這個標志,一旦發現中斷標志為真時就自己在最近的安全點上主動中斷掛起。輪詢標志的地方和安全點是重合的。
 
 
安全區域
安全點機制保證了程序執行時,在不太長的時間內就會遇到可進入垃圾收集過程的安全點。但是如果程序不執行,比如沒有分配處理器時間的情況,典型的場景便是用戶線程處於Sleep 狀態或者Blocked狀態,這時候線程無法響應虛擬機的中斷請求,不能再走到安全點去中斷掛起自己,虛擬機也顯然不可能等待線程重新被激活分配處理器時間。對於這種情況,就必須引入安全區域(Safe Region)來解決。
安全區域是指能夠確保在某一段代碼片段之中,引用關系不會發生變化,因此,在這個區域中任意地方開始垃圾收集都是安全的。我們也可以把安全區域看作被擴展拉伸了的安全點。
當用戶線程執行到安全區域里面的代碼時,首先會標識自己已經進入了安全區域,那樣當這段時間里虛擬機要發起垃圾收集時就不必去管這些已聲明自己在安全區域內的線程了。當線程要離開安全區域時,它要檢查虛擬機是否已經完成了根節點枚舉(或者垃圾收集過程中其他需要暫停用戶線程的階段),如果完成了,那線程就當作沒事發生過,繼續執行;否則它就必須一直等待,直到收到可以離開安全區域的信號為止。
 
記憶集與卡表(卡表是記憶集的一種實現方式)
記憶集是一種用於記錄從非收集區域指向收集區域的指針集合的抽象數據結構。
解決對象跨代引用所帶來的問題,垃圾收集器在新生代中建立了名為記憶集(Remembered Set)的數據結構,用以避免把整個老年代加進GC Roots掃描范圍。而在垃圾 收集的場景中,收集器只需要通過記憶集判斷出某一塊非收集區域是否存在有指向了收集區域的指針 就可以了,並不需要了解這些跨代指針的全部細節。那設計者在實現記憶集的時候,便可以選擇更為粗獷的記錄粒度來節省記憶集的存儲和維護成本。
一種稱為“卡表”(Card Table)的方式去實現記憶集,這也是目前最常用的一種記憶集實現形式,HotSpot虛擬機的卡表只是一個字節數組,字節數組CARD_TABLE的每一個元素都對應着其標識的內存區域中一塊特定大小的內存塊,這個內存塊被稱作“卡頁”(Card Page)。一般來說,卡頁大小都是以2的N次冪的字節數,HotSpot中使用的卡頁是2的9次冪,即512字節。一個卡頁的內存中通常包含不止一個對象,只要卡頁內有一個(或更多)對象的字段存在着跨代指針,那就將對應卡表的數組元素的值標識為1,稱為這個元素變臟(Dirty),沒有則標識為0。在垃圾收集發生時,只要篩選出卡表中變臟的元素,就能輕易得出哪些卡頁內存塊中包含跨代指針,把它們加入GC Roots中一並掃描。
 
寫屏障
卡表如何維護呢?如果是解釋執行,虛擬機用充分的介入空間,但如果是編譯執行呢?經過即時編譯后的代碼已經是純粹機器指令流了,所以必須在機器碼層面把卡表的維護動作放到每一次賦值操作中。
HotSpot通過寫屏障技術維護卡表狀態。寫屏障可以看作在虛擬機層面對“引用類型字段賦值”這個動作的AOP切面,在引用對象賦值時會產生一個環形(Around)通知,供程序執行額外的動作,也就是說賦值的前后都在寫屏障的覆蓋范疇內。在賦值前的部分的寫屏障叫作寫前屏障(Pre-Write Barrier),在賦值后的則叫作寫后屏障(Post-Write Barrier)。
當然額外的環形增強來維護卡表會有性能開銷,但相比掃描整個非收集代的代價相比還是低很多。
此外卡表維護還會面臨多線程並發的偽共享,為了減少偽共享帶來的性能損失,虛擬機會判斷只有卡表元素未臟的情況下才去更新此卡表元素。可以通過UseCondCardMark參數開啟這一判斷,默認是關閉的。
 
並發的可達性分析
當前主流編程語言的垃圾收集器基本上都是依靠可達性分析算法來判定對象是否存活的,可達性分析算法理論上要求全過程都基於一個能保障一致性的快照中才能夠進行分析, 這意味着必須全程凍結用戶線程的運行。
由於GC Roots相比起整個Java堆中全部的對象畢竟還算是極少數,且在各種優化技巧(如OopMap)的加持下,它帶來的停頓已經是非常短暫且相對固定(不隨堆容量而增長)的了。可從GC Roots再繼續往下遍歷對象圖,這一步驟的停頓時間就必定會與Java堆容量直接成正比例關系了:堆越大,存儲的對象越多,對象圖結構越復雜,要標記更多對象而產生的停頓時間自然就更長。要知道包含“標記”階段是所有追蹤式垃圾收集算法的共同特征,如果這個階段會隨着堆變大而等比例增加停頓時間,其影響就會波及幾乎所有的垃圾收集器。
那么為什么必須在一個能保障一致性的快照上才能進行對象圖的遍歷?
利用三色標記(Tri-color Marking)作為工具來輔助推導,把遍歷對象圖過程中遇到的對象,按照“是否訪問過”這個條件標記成以下三種顏色:
  • 白色:表示對象尚未被垃圾收集器訪問過。顯然在可達性分析剛剛開始的階段,所有的對象都是白色的,若在分析結束的階段,仍然是白色的對象,即代表不可達。
  • 黑色:表示對象已經被垃圾收集器訪問過,且這個對象的所有引用都已經掃描過。黑色的對象代表已經掃描過,它是安全存活的,如果有其他對象引用指向了黑色對象,無須重新掃描一遍。黑色對象不可能直接(不經過灰色對象)指向某個白色對象。
  • 灰色:表示對象已經被垃圾收集器訪問過,但這個對象上至少存在一個引用還沒有被掃描過。
如果用戶線程與收集器是並發工作呢?收集器在對象圖上標記顏色,同時用戶線程在修改引用關系——即修改對象圖的結構,這樣可能出現兩種后果。一種是把原本消亡的對象錯誤標記為存活。另一種是把原本存活的對象錯誤標記為已消亡,這就是非常致命的后果了,程序肯定會因此 發生錯誤。
理論上,當且僅當以下兩個條件同時滿足時,會產生“對象消失”的問 題,即原本應該是黑色的對象被誤標為白色:
  • 賦值器插入了一條或多條從黑色對象到白色對象的新引用; 因為黑色對象的指向不會再次掃描,白色的就不會變黑。
  • 賦值器刪除了全部從灰色對象到該白色對象的直接或間接引用。白色對象有可能又被黑色對象指向了,又變成前一種情況了。
所以只需破壞這兩個條件的任意一個即可。
由此分別產生了兩種解決方案:增量更新(Incremental Update)和原始快照(Snapshot At The Beginning, SAT B ) 。
  • 增量更新要破壞的是第一個條件,當黑色對象插入新的指向白色對象的引用關系時,就將這個新插入的引用記錄下來,等並發掃描結束之后,再將這些記錄過的引用關系中的黑色對象為根,重新掃描一次。
  • 原始快照要破壞的是第二個條件,當灰色對象要刪除指向白色對象的引用關系時,就將這個要刪除的引用記錄下來,在並發掃描結束之后,再將這些記錄過的引用關系中的灰色對象為根,重新掃描一次。
以上無論是對引用關系記錄的插入還是刪除,虛擬機的記錄操作都是通過 寫屏障實現的。
在 HotSpot虛擬機中,增量更新和原始快照這兩種解決方案都有實際應用,譬如,CMS是基於增量更新 來做並發標記的,G1、Shenandoah則是用原始快照來實現。
 
 
小結
垃圾收集為什么只能暫停在安全點上呢?
垃圾收集中判斷是否為垃圾對象,依據的是GC Roots可達性分析,而可達性分析的第一步就是要進行GC Roots的枚舉,HotSpot利用OopMaps來高效實現GC Roots的枚舉(不需要掃描所有的虛擬機棧,只需要掃描OopMap就能得到GC Roots)。又因為運行中觸發變動OopMap指令非常多,如果每一條指令生成對應的OopMap,就需要大量的額外空間,所以HotSpot只在“特定位置”記錄OopMap信息,而這些位置就是安全點。這也是為什么垃圾收集只能暫停在安全點上的原因,主要是為了保證OopMap記錄完全,以便進行GC Roots的枚舉,才能繼續進行后續的垃圾收集操作。
 
 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM