1. 分代收集理論
當前商業虛擬機的垃圾收集器,大多數都遵循了“分代收集”(Generational Collection)的理論進行設計。它建立在兩個分代假說之上:
- 弱分代假說(Weak Generational Hypothesis):絕大多數對象都是朝生夕滅的。
- 強分代假說(Strong Generational Hypothesis):熬過越多次垃圾收集過程的對象就越難以消亡。
對堆劃分區域,將回收對象依據其年齡分配到不同的區域
這兩個分代假說共同奠定了多款常用的垃圾收集器的一致的設計原則:收集器應該將Java堆劃分出不同的區域,然后將回收對象依據其年齡(年齡即對象熬過垃圾收集過程的次數)分配到不同的區域之中存儲。
如果一個區域中大多數對象都是朝生夕滅,難以熬過垃圾收集過程的話,那么把它們集中放在一起,每次回收時只關注如何保留少量存活而不是去標記那些大量將要被回收的對象,就能以較低代價回收到大量的空間。
如果剩下的都是難以消亡的對象,那把它們集中放在一塊,虛擬機便可以使用較低的頻率來回收這個區域,這就同時兼顧了垃圾收集的時間開銷和內存的空間有效利用。
堆劃分
所以堆分為為新生代(Young Generation)和老年代(Old Generation)兩個區域。
在新生代中,每次垃圾收集時都發現有大批對象死去,剩下的逐步放到老年代。
跨代引用
新生代中的對象是完全有可能被老年代所引用的,為了找出該區域中的存活對象,不得不在固定的GC Roots之外,再額外遍歷整個老年代中所有對象來確保可達性分析結果的正確性,反過來也是一樣。這樣無疑會為內存回收帶來很大的性能負擔。
解決理論
- 跨代引用假說(Intergenerational Reference Hypothesis):跨代引用相對于同代引用來說僅占極少數。
存在互相引用關系的兩個對象,是應該傾向于同時生存或者同時消亡的。
存在互相引用的兩個對象應該同時生存或者同時消亡。所以存在跨代引用的新生代對象會因為老年代對象難以消亡,也 得意存活 , 最后也晉升到老年代, 跨代引用被消除。
記憶集
在新生代建立一個記憶集, 將老年代標識為一小塊的區域,標識哪些區域存在跨代引用。當發生MinorGC時,掃描GC Roots + 記憶集中 存在 跨代引用的老年代區域對象作為GC 也作為GC Roots進行掃描。
雖然這種方法需要在對象改變引用關系(如將自己或者某個屬性賦值)時維護記錄數據的正確性,會增加一些運行時的開銷,但比起收集時掃描整個老年代來說仍然是劃算的。
垃圾回收分類
1. 部分收集(Partial GC)
1.1 新生代收集(Minor GC/Young GC):
指目標只是新生代的垃圾收集。
1.2 老年代收集(Major GC/Old GC):
指目標只是老年代的垃圾收集。目前只有CMS收集器會有單獨收集老年代的行為。
1.3 混合收集(Mixed GC)
指目標是收集整個新生代以及部分老年代的垃圾收集。目前只有G1收集器會有這種行為。
2. 整堆收集(Full GC)
收集整個Java堆和方法區的垃圾收集。
2. 垃圾收集算法
2.1 標記-清除算法
如它的名字一樣,算法分為“標記”和“清除”兩個階段:首先標記出所有需要回收的對象,在標記完成后,統一回收掉所有被標記的對象,也可以反過來,標記存活的對象,統一回收所有未被標記的對象。
標記過程就是判定對象是否屬于垃圾的過程。
缺點
- 效率不穩定,當需要回收的對象過多時,大量標記和清除的動作會導致導致標記和清除兩個過程的執行效率都隨對象數量增長而降低
- 內存空間的碎片化問題,標記、清除之后會產生大量不連續的內存碎片,內存碎片太多可能會導致 大對象無法找到足夠的連續內存 而不得不 提前觸發GC。
2.2 標記-復制算法
標記-復制算法常被簡稱為復制算法。為了解決標記-清除算法面對大量可回收對象時執行效率低的問題,該算法將可用內存按容量劃分為大小相等的兩塊,每次只使用其中的一塊。當這一塊的內存用完了,就將還存活著的對象復制到另外一塊上面,然后再把已使用過的內存空間一次清理掉。
優點
實現簡單,運行高效。如果多數對象都是可回收的情況,算法需要復制的就是占少數的存活對象,而且每次都是針對整個半區進行內存回收,分配內存時也就不用考慮有空間碎片的復雜情況,只要移動堆頂指針,按順序分配即可。
缺點
- 如果內存中多數對象都是存活的,這種算法將會產生大量的內存間復制的開銷。
- 內存利用率只有 一半。
2.2.1. 應用場景 : 新生代
新生代中的對象有98%熬不過第一輪收集。因此并不需要按照1∶1的比例來劃分新生代的內存空間。
針對具備“朝生夕滅”特點的對象,產生了一種更優化的半區復制分代策略,現在稱為“Appel式回收”。
From:To:Eden=1:1:8
HotSpot虛擬機的Serial、ParNew等新生代收集器均采用了這種策略來設計新生代的內存布局。
Appel式回收的具體做法是把新生代分為一塊較大的Eden空間和兩塊較小的Survivor空間(From區和To區),每次分配內存只使用Eden和其中一塊Survivor。發生垃圾搜集時,將Eden和Survivor中仍然存活的對象一次性復制到另外一塊Survivor空間上,然后直接清理掉Eden和已用過的那塊Survivor空間。
HotSpot虛擬機默認Eden和Survivor的大小比例是8∶1,也即每次新生代中可用內存空間為整個新生代容量的90%(Eden的80%加上一個Survivor的10%),只有一個Survivor空間,即10%的新生代是會被“浪費”的。
分配擔保
然,98%的對象可被回收僅僅是“普通場景”下測得的數據,任何人都沒有辦法百分百保證每次回收都只有不多于10%的對象存活。當Survivor空間不足以容納一次Minor GC之后存活的對象時,就需要依賴其他內存區域(實際上大多就是老年代)進行分配擔保(Handle Promotion)。
2.3 標記-整理算法
標記-復制算法在對象存活率較高時就要進行較多的復制操作,效率將會降低。更關鍵的是,如果不想浪費50%的空間(Apple式回收改進的標記復制算法, 1:1:8 ),就需要有額外的空間進行分配擔保,以應對被使用的內存中所有對象都100%存活的極端情況,所以在老年代一般不能直接選用這種算法。
針對老年代對象的存亡特征,標記-整理”(Mark-Compact)算法,其中的標記過程仍然與“標記-清除”算法一樣,但后續步驟不是直接對可回收對象進行清理,而是讓所有存活的對象都向內存空間一端移動,然后直接清理掉邊界以外的內存。
吞吐量和GC延遲的考量
吞吐量的實質是賦值器(Mutator,可以理解為使用垃圾收集的用戶程序,本書為便于理解,多數地方用“用戶程序”或“用戶線程”代替)與收集器的效率總和。
Stop The World(會產生GC延遲)
如果移動存活對象,尤其是在老年代這種每次回收都有大量對象存活區域,移動存活對象并更新所有引用這些對象的地方將會是一種極為負重的操作,而且這種對象移動操作必須全程暫停用戶應用程序才能進行,像這樣的停頓被描述為“Stop The World”。
但如果跟標記-清除算法那樣完全不考慮移動和整理存活對象的話。空間碎片化問題只能就只能依賴更為復雜的內存分配器和內存訪問器來解決。內存的訪問是用戶程序最頻繁的操作,假如在這個環節上增加了額外的負擔,勢必會直接影響應用程序的吞吐量。
從垃圾收集的停頓時間來看,不移動對象停頓時間會更短,甚至可以不需要停頓,但是從整個程序的吞吐量來看,移動對象會更劃算。
吞吐量的實質是賦值器(Mutator,可以理解為使用垃圾收集的用戶程序,本書為便于理解,多數地方用“用戶程序”或“用戶線程”代替)與收集器的效率總和。即使不移動對象會使得收集器的效率提升一些,但因內存分配和訪問相比垃圾收集頻率要高得多,這部分的耗時增加,總吞吐量仍然是下降的。
HotSpot虛擬機里面關注吞吐量的Parallel Scavenge收集器是基于標記-整理算法的。
而關注延遲的CMS收集器則是基于標記-清除算法的:為了不在內存分配和訪問上增加太大額外負擔,做法是讓虛擬機平時多數時間都采用標記-清除算法,暫時容忍內存碎片的存在,直到內存空間的碎片化程度已經大到影響對象分配時,再采用標記-整理算法收集一次,以獲得規整的內存空間。
2.4 HotSpot的算法細節實現
2.4.1. 根節點枚舉
從全局性的引用(例如常量或類靜態屬性)與執行上下文(例如棧幀中的本地變量表)中 固定可作為GC Roots的節點。
迄今為止,所有收集器在根節點枚舉這一步驟時都是必須暫停用戶線程的,就是“Stop The World”。可達性分析算法耗時最長的查找引用鏈的過程已經可以做到與用戶線程一起并發,但根節點枚舉始終還是必須在一個能保障一致性的快照中才得以進行。這里“一致性”的意思是整個枚舉期間執行子系統看起來就像被凍結在某個時間點上,不會出現分析過程中,根節點集合的對象引用關系還在不斷變化的情況,
OopMap
當用戶線程停頓下來之后,為了避免需要一個不漏地檢查完所有執行上下文和全局的引用位,虛擬機應當是有辦法直接得到哪些地方存放著對象引用的。
在HotSpot的解決方案里,是使用一組稱為OopMap的數據結構來達到這個目的。一旦類加載動作完成的時候,HotSpot就會把對象內什么偏移量上是什么類型的數據計算出來,這樣收集器在掃描時就可以直接得知這些信息了,并不需要真正一個不漏地從方法區等GC Roots開始查找。
2.4.2. 安全點
HotSpot沒有為每條指令都生成OopMap,因為導致OopMap內容變化的指令非常多,如果為每一條指令都生成對應的OopMap,那將會需要大量的額外存儲空間,這樣垃圾收集的成本太高。
只是在“特定的位置”記錄了這些信息,這些位置被稱為安全點(Safepoint)。決定了用戶程序執行時并非在代碼指令流的任意位置都能夠停頓下來開始垃圾收集,而是強制要求必須執行到達安全點后才能夠暫停。
2.4.2.1. 安全點的選定
安全點位置的選取基本上是以“是否具有讓程序長時間執行的特征”為標準進行選定的。
“長時間執行”的最明顯特征就是指令序列的復用,例如方法調用、循環跳轉、異常跳轉等都屬于指令序列復用,所以只有具有這些功能的指令才會產生安全點。
2.4.2.2. GC時,如何讓線程跑到安全點停頓
搶先式中斷(Preemptive Suspension)
搶先式中斷不需要線程的執行代碼主動去配合,在垃圾收集發生時,系統首先把所有用戶線程全部中斷,如果發現有用戶線程中斷的地方不在安全點上,就恢復這條線程執行,讓它一會再重新中斷,直到跑到安全點上。
現在幾乎沒有虛擬機實現采用搶先式中斷來暫停線程響應GC事件。
主動式中斷(Voluntary Suspension)
主動式中斷的思想是當垃圾收集需要中斷線程的時候,不直接對線程操作,僅僅簡單地設置一個標志位,各個線程執行過程時會不停地主動去輪詢這個標志,一旦發現中斷標志為真時就自己在最近的安全點上主動中斷掛起。
由于輪詢操作在代碼中會頻繁出現,這要求它必須足夠高效。HotSpot使用內存保護陷阱的方式,把輪詢操作精簡至只有一條匯編指令的程度。當需要暫停用戶線程時,虛擬機把0x160100的內存頁設置為不可讀,那線程執行到test指令時就會產生一個自陷異常信號,然后在預先注冊的異常處理器中掛起線程實現等待,這樣僅通過一條匯編指令便完成安全點輪詢和觸發線程中斷了。
2.4.3. 安全區域
如果GC的時候,線程并沒有分到cpu時間片,也就是沒有在執行,就無法響應jvm的中斷請 求 (比如線程處于 sleep,blocked狀態),不能跑到安全點掛起自己。
虛擬機也顯然不可能持續等待線程重新被激活分配處理器時間。對于這種情況,就必須引入安全區域(Safe Region)來解決。
安全區域是指能夠確保在某一段代碼片段之中,引用關系不會發生變化,因此,在這個區域中任意地方開始垃圾收集都是安全的。我們也可以把安全區域看作被擴展拉伸了的安全點。
當用戶線程執行到安全區域里面的代碼時,首先會標識自己已經進入了安全區域,那樣當這段時間里虛擬機要發起垃圾收集時就不必去管這些已聲明自己在安全區域內的線程了。當線程要離開安全區域時,它要檢查虛擬機是否已經完成了根節點枚舉(或者垃圾收集過程中其他需要暫停用戶線程的階段),如果完成了,那線程就當作沒事發生過,繼續執行;否則它就必須一直等待,直到收到可以離開安全區域的信號為止。
2.5 記憶集與卡表
為解決對象跨代引用所帶來的問題,垃圾收集器在新生代中建立了名為記憶集(Remembered Set)的數據結構,用以避免把整個老年代加進GC Roots掃描范圍。
不只新生代,老年代才有跨代引用的問題,所有 部分區域行為(Partial GC)的垃圾收集器(如G1、ZGC和Shenandoah),都有跨代引用的問題。
記憶集是一種用于記錄從非收集區域指向收集區域的指針集合的抽象數據結構。
收集器只需要通過記憶集判斷出某一塊非收集區域是否存在有指向了收集區域的指針就可以了,并不需要了解這些跨代指針的全部細節。
2.5.1 可供選擇(當然也可以選擇這個范圍以外的)的記錄精度:
1. 字長精度
每個記錄精確到一個機器字長(就是處理器的尋址位數,如常見的32位或64位,這個精度決定了機器訪問物理內存地址的指針長度),該字包含跨代指針。
2. 對象精度
每個記錄精確到一個對象,該對象里有字段含有跨代指針。
3. 卡精度
每個記錄精確到一塊內存區域,該區域內有對象含有跨代指針。
卡表
“卡表”(Card Table),是目前最常用的一種記憶集實現形式,是記憶集的一種實現。定義了記憶集的記錄精度、與堆內存的映射關系等。
hotspot卡表實現
CARD_TABLE [this address >> 9] = 0;
字節數組CARD_TABLE的每一個元素都對應著其標識的內存區域中一塊特定大小的內存塊,這個內存塊被稱作“卡頁”,卡頁大小為 2的9次方。即512字節(地址右移9位,相當于用地址除以512)。
一個卡頁的內存中通常包含不止一個對象,只要卡頁內有一個(或更多)對象的字段存在著跨代指針,那就將對應卡表的數組元素的值標識為1,稱為這個元素變臟(Dirty),沒有則標識為0。在垃圾收集發生時,只要篩選出卡表中變臟的元素,就能輕易得出哪些卡頁內存塊中包含跨代指針,把它們加入GC Roots中一并掃描。
2.5.2 寫屏障
在HotSpot虛擬機里是通過寫屏障(Write Barrier)技術維護卡表狀態的。寫屏障可以看作在虛擬機層面對“引用類型字段賦值”這個動作的AOP切面[[2]](javascript:void(0);),在引用對象賦值時會產生一個環形(Around)通知,供程序執行額外的動作,也就是說賦值的前后都在寫屏障的覆蓋范疇內。在賦值前的部分的寫屏障叫作寫前屏障(Pre-Write Barrier),在賦值后的則叫作寫后屏障(Post-Write Barrier)。
void oop_field_store(oop* field, oop new_value) {
// 引用字段賦值操作
*field = new_value;
// 寫后屏障,在這里完成卡表狀態更新
post_write_barrier(field, new_value);
}
應用寫屏障后,虛擬機就會為所有賦值操作生成相應的指令,一旦收集器在寫屏障中增加了更新卡表操作,無論更新的是不是老年代對新生代對象的引用,每次只要對引用進行更新,就會產生額外的開銷,不過這個開銷與Minor GC時掃描整個老年代的代價相比還是低得多的。
偽共享問題
除了寫屏障的開銷外,卡表在高并發場景下還面臨著“偽共享”(False Sharing)問題。現代中央處理器的緩存系統中是以緩存行(Cache Line)為單位存儲的,當多線程修改互相獨立的變量時,如果這些變量恰好共享同一個緩存行,就會彼此影響(寫回、無效化或者同步)而導致性能降低,這就是偽共享問題。
假設處理器的緩存行大小為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,用來決定是否開啟卡表更新的條件判斷。
3. 并發的可達性分析
三色標記法
白色:表示對象尚未被垃圾收集器訪問過。顯然在可達性分析剛剛開始的階段,所有的對象都是白色的,若在分析結束的階段,仍然是白色的對象,即代表不可達。
黑色:表示對象已經被垃圾收集器訪問過,且這個對象的所有引用都已經掃描過。黑色的對象代表已經掃描過,它是安全存活的,如果有其他對象引用指向了黑色對象,無須重新掃描一遍。黑色對象不可能直接(不經過灰色對象)指向某個白色對象。
灰色:表示對象已經被垃圾收集器訪問過,但這個對象上至少存在一個引用還沒有被掃描過。
當且僅當以下兩個條件同時滿足時,會產生“對象消失”的問題,即原本應該是黑色的對象被誤標為白色:
賦值器插入了一條或多條從黑色對象到白色對象的新引用;
賦值器刪除了全部從灰色對象到該白色對象的直接或間接引用。
因此,我們要解決并發掃描時的對象消失問題,只需破壞這兩個條件的任意一個即可。
1. 增量更新(Incremental Update)
當黑色對象插入新的指向白色對象的引用關系時,就將這個新插入的引用記錄下來,等并發掃描結束之后,再將這些記錄過的引用關系中的黑色對象為根,重新掃描一次。這可以簡化理解為,黑色對象一旦新插入了指向白色對象的引用之后,它就變回灰色對象了。
2. 原始快照
當灰色對象要刪除指向白色對象的引用關系時,就將這個要刪除的引用記錄下來,在并發掃描結束之后,再將這些記錄過的引用關系中的灰色對象為根,重新掃描一次。這也可以簡化理解為,無論引用關系刪除與否,都會按照剛剛開始掃描那一刻的對象圖快照來進行搜索。
以上無論是對引用關系記錄的插入還是刪除,虛擬機的記錄操作都是通過寫屏障實現的。在HotSpot虛擬機中,增量更新和原始快照這兩種解決方案都有實際應用,譬如,CMS是基于增量更新來做并發標記的,G1、Shenandoah則是用原始快照來實現。