垃圾收集原理依據及要點

aoeiuv發表於2021-04-19
分代收集理論
理論支撐:
  • 弱分代假說(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的列舉,才能繼續進行後續的垃圾收集操作。
 
 

相關文章