深入理解Jvm虛擬機第三章
- 二、對象已死?
- 3.2.1 引用計數算法
- 3.2.2 可達性分析算法
- 3.2.3 再談引用
- 3.2.4 生存還是死亡
- 3.2.5 回收方法區
- 三、垃圾收集算法
- 3.3.1 分代收集理論
- 3.3.2 標記-清除算法
- 3.3.3 標記-復制算法
- 3.3.4 標記-整理算法
- 四、HotSpot的算法細節實現
- 3.4.1 根節點枚舉
- 3.4.2 安全點
- 3.4.3 安全區域
- 3.4.4 記憶集與卡表
- 3.4.5 寫屏障
- 3.4.6 并發的可達性分析
- 五、經典垃圾收集器
- 3.5.1 Serial收集器
- 3.5.2 ParNew收集器
- 3.5.3 Parallel Scavenge 收集器
- 3.5.4 Serial Old收集器
- 3.5.5 Parallel Old收集器
- 3.5.6 CMS收集器
- 3.5.7 Garbage First收集器
- 六、低延遲垃圾收集器
- 3.6.1 Shenandoah收集器
- 3.6.2 ZGC收集器
- 七、選擇合適的垃圾收集器
- 3.7.1 Epsilon收集器
- 3.7.2 收集器的權衡
- 3.7.3 虛擬機及垃圾收集器日志
- 3.7.4 垃圾收集器參數總結
- 八、實戰:內存分配與回收策略
- 3.8.1 對象優先在Eden分配
- 3.8.2 大對象直接進入老年代
- 3.8.3 長期存活的對象進入老年代
- 3.8.4 動態對象年齡判定
- 3.8.5 空間分配擔保
二、對象已死?
在堆中存放著幾乎所有的對象實例,垃圾收集器在對堆進行回收前第一件事就是要確定這些對象之中哪些還“活著”,哪些已經“死去”(不再被任何途徑使用的對象)
3.2.1 引用計數算法
在對象中添加一個引用計數器,每當有一個地方引用它時,計數器值就加一;當引用失效時,計數器值就減一;任何時刻計數器為零的對象就是不可能再被引用的。
雖然這種方法是簡單高效的,但是還有一種例外情況:例如A對象引用B對象,B對象引用A對象,并且兩個對象都已經不可能再被訪問,這時雖然兩個對象應該被回收但是由于計數器值不為0所以回收不了。
3.2.2 可達性分析算法
該算法的基本思路為通過一系列稱為“GC Roots”的根對象作為起始節點集,從這些結點開始根據引用關系向下搜索,搜索過程中走過的路徑稱為“引用鏈”,如果某個對象到“GC Roots”間沒有任何引用鏈相連,則證明此對象是不可能再被使用的。
在Java技術體系里,固定可作為GC Roots的對象包括以下幾種
- 在虛擬機棧(棧幀中的本地變量表)中引用的對象,譬如當前正在運行的方法所使用到的參數、局部變量、臨時變量等
- 在方法區中靜態屬性引用的對象,譬如Java類的引用類型靜態變量
- 在方法區中常量引用的對象,譬如字符串常量池里的引用
- 在本地方法棧中JNI(即通常所說的Native方法)引用的對象
- Java虛擬機內部的引用,如基本數據類型對應的Class對象,一些常駐的異常對象,還有系統類加載器
- 所有被同步鎖持有的對象
- 反應Java虛擬機內部情況的JMXBean、JVMTI中注冊的回調、本地代碼緩存等
如果只針對 Java 堆中的某一塊區域進行垃圾回收(比如:典型的只針對新生代),必須考慮到內存區域是虛擬機自己的實現細節,更不是孤立封閉的,這個區域的對象完全有可能被其他區域的對象所引用,這時候就需要一并將關聯的區域對象也加入 GCRoots 集合中去考慮,才能保證可達性分析的準確性。
也就是說,進行
局部回收的時候,也要考慮到該內存區域里的對象是否也被其他內存區域引用到
3.2.3 再談引用
在JDK1.2之前,Java里面的引用是很傳統的定義:如果reference數據是代表某塊內存、某個對象的引用。這種定義并沒有什么不對,但是對于描述一些“食之無味,棄之可惜”的對象就顯得無能為力。譬如我們希望能描述一類對象:當內存空間足夠時,能保留在內存中,如果內存空間在進行垃圾收集后仍然非常緊張,那就可以拋棄這些對象。
在JDK1.2之后,Java堆引用的概念進行了擴充,將引用分為強引用、軟引用、弱引用和虛引用。這四種引用強度以此逐漸減弱
- 強引用類似“Object obj = new Object()”,無論任何情況下,只要強引用關系還存在,垃圾收集器就永遠不會回收掉被引用的對象
- 軟引用是用來描述一些還有用,但非必須的對象。只要被軟引用關聯著的對象,在系統將要發生內存溢出異常前,會把這些對象列進回收范圍之中進行二次回收,如果這次回收還沒有足夠的內存,在會拋出內存溢出異常,SoftReference
- 弱引用的強度比軟引用更弱一些,被弱引用關聯的對象只能生存到下一次垃圾收集發生為止。當垃圾收集器開始工作,無論當前的內存是否足夠,都會回收掉只被弱引用關聯的對象,WeakReference
- 虛引用也成為“幽靈引用”或者“幻影引用”,是最弱的一種引用關系。為一個對象設置虛引用關聯的唯一目的只是為了能在這個對象被收集器回收時收到一個系統通知,PhantomReference
3.2.4 生存還是死亡
一個對象真正死亡,最多會經歷兩次標記過程:如果對象不可達,那么會被第一次標記,然后進行一次篩選,篩選的條件是此對象是否有必要執行finalize()方法。假如對象沒有覆蓋finalize()方法或者finalize()方法已經被虛擬機調用過,那么虛擬機將這兩種情況都視為“沒有必要執行”。
如果對象在finalize()中成功拯救自己,只需要重新與引用鏈上任何一個對象建立關聯即可(重新被引用引用到)。
3.2.5 回收方法區
方法區的垃圾收集主要回收兩部分內容:廢棄的常量和不再使用的類型。回收廢棄常量與回收Java堆中的對象非常類似。
判斷一個類型是否屬于“不再被使用的類”的條件就比較苛刻了。需要同時滿足下面三個條件:
- 該類所有的實例都已經被回收,也就是Java堆中不存在該類及其任何派生子類實例
- 加載該類的類加載器已經被回收,這個條件除非是經過精心設計的可替換類加載器的場景,如OSGi、JSP的重加載等,否則通常是很難達成的
- 該類對應的java.lang.Class對象沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法
在大量使用反射、動態代理、CGLib等字節碼框架,動態生成JSP以及OSGi這類頻繁自定義類加載器的場景中,通常都需要Java虛擬機具備類型卸載的能力,以保證不會對方法區造成過大的內存壓力
三、垃圾收集算法
垃圾收集算法主要分為‘引用計數式垃圾收集“和”追蹤式垃圾收集“兩大類,這兩類也常被稱作”直接垃圾收集“和”間接垃圾收集“,由于主流Java虛擬機中均未涉及引用計數式垃圾收集算法,所以本節介紹的所有算法均屬于追蹤式垃圾收集范疇
3.3.1 分代收集理論
當前商業虛擬機的垃圾收集器,大多遵循了”分代收集“理論進行設計,建立在兩個分代假說之上:
- 弱分代假說(Weak Generational Hypothesis):絕大多數對象都是朝生夕滅的
- 強分代假說(Strong Generational Hypothesis):熬過越多次垃圾收集過程的對象就越難以消亡
顯而易見,收集器應該將Java堆劃分出不同的區域,然后將回收對象依據其年齡(對象熬過垃圾收集過程的次數)分配到不同的區域中存儲。
在堆劃分出不同的區域之后,GC才可以每次只回收其中一個或者某部分區域,也才能夠針對不同的區域安排與里面存儲對象存亡特征相匹配的垃圾收集算法。并且發展出了“標記-復制算法”“標記-清除算法”“標記-整理算法”
設計者一般至少會把Java堆劃分成新生代和老年代兩個區域。但是分代收集并非只是劃分一下內存區域那么容易,至少存在一個明顯的困難:對象不是孤立的,對象之間會存在跨代引用
跨代引用假說:跨代引用相對于同代引用僅占極少數
根據這條假說,我們不必為了少量的引用去掃描整個老年代,也不必浪費空間專門記錄每一個對象是否存在及存在哪些跨代引用,只需要在新生代上建立一個全局的數據結構(記憶集)這個結構把老年代劃分成若干小塊,標識出老年代的哪一塊內存會存在跨代引用。之后當發生MinorGC時,只有包含了跨代引用的小塊內存里的對象才會被加入到GC Roots進行掃描
3.3.2 標記-清除算法
算法分為“標記”和“清除”兩個階段:首先標記出所有要回收的對象,在標記完成后,統一回收掉所有被標記的對象,或者反過來,首先標記所有需要回收的對象,在標記完成后,統一回收掉所有被標記的對象。
它的主要缺點有兩個:
- 執行效率不穩定:如果Java堆中包含大量對象,而且其中大部分是需要被回收的,這時必須進行大量標記和清除的動作,導致標記和清除兩個過程的執行效率都隨對象數量的增長而降低
- 內存空間碎片化:標記、清除后會產生大量不連續的內存碎片,空間碎片太多可能會導致之后存儲較大對象時無法找到足夠的連續內存而不得不提前觸發另一次垃圾收集動作
3.3.3 標記-復制算法
這種算法將可用內存按容量劃分為大小相等的兩塊,每次只使用其中的一塊。當這一塊內存用完了,就將還活著的對象復制到另一塊上面,然后再把已使用過的內存空間一次清理掉。如果內存中多數對象都是存活的,那么這種算法將會產生大量的內存復制的開銷,但對于多數對象都是可回收的情況,算法需要復制的就是占少數的存活對象,這樣實現簡單,運行高效。但是缺點顯而易見:復制回收算法的代價是將可用內存縮小為了原來的一半
IBM公司曾經研究出:新生代中的對象有98%熬不過第一輪收集。因此并不需要按照1:1的比例來劃分新生代的內存空間。
Appel式回收的具體做法是把新生代分為一塊較大的Eden空間和兩塊較小的Survivor空間,每次分配內存只使用Eden和其中一塊Survivor,發生垃圾收集時,將Eden和Survivor中仍然存活的對象一次性復制到另外一塊Survivor空間上,然后直接清理掉Eden和已用過的那塊Survivor空間。
HotSpot虛擬機默認Eden和Survivor的大小比例是8:1,即每次新生代中可用內存空間為整個新生代容量的90%。但是無法保證每次回收都只有不多于10%的對象存活因此Appel式回收還有一個“逃生門”設計,當Survivor空間不足以容納一次存活對象時,就需要依賴其他區域(大多數情況下為老年代)進行分配擔保
3.3.4 標記-整理算法
標記復制算法在對象存活率較高的老年代中并不適用,因為很大概率會遇見所有對象都存活的極端情況,所以老年代中一般不能直接選用這種算法
針對老年代對象的死亡特征,提出了一種“標記-整理”算法,標記過程與“標記-清除”算法一樣,但后續步驟不是直接對可回收對象進行清理,而是讓所有存活對象都向內存空間一端移動,然后直接清理掉邊界以外的內存
是否移動回收后的存活對象是一項優缺點并存的風險決策:
- 如果要移動存活對象:尤其是老年代這種每次回收都有大量對象存活的區域,移動對象并更新所有這些對象的引用是一項艱巨的操作,并且這項操作需要暫停用戶應用程序才能進行。
- 如果不移動和整理存活對象,彌散于堆中的存活對象導致的空間碎片化問題就只能依賴更為復雜的內存分配器和內存訪問器解決(例如分區空閑分配鏈表),內存訪問是用戶程序最頻繁的操作,如果這個環節上增加了額外的負擔,必然會直接影響應用程序吞吐量
所以是否移動對象都存在弊端,移動對象則回收對象更復雜,不移動對象則內存分配更復雜。吞吐量的本質是復制器于收集器的效率總和,即使不移動對象會使收集器的效率提升一些,但因內存分配和訪問相比垃圾收集頻率更高,這部分的耗時增加,總吞吐量仍然是下降的
還有一種”和稀泥式“解決方案:讓虛擬機平時使用標記-清除算法,直到內存空間的碎片化成都已經大到影響對象分配時,再采用標記-整理算法收集一次,以獲得規整的內存空間
四、HotSpot的算法細節實現
3.4.1 根節點枚舉
現在Java應用越做越大,逐個檢查GC Roots下的引用肯定要消耗不少時間。迄今為止,所有收集器在根節點枚舉這一步驟時都是必須暫停用戶線程的,如果分析過程中,根節點集合的對象引用關系還在不斷變化,那么分析結果準確性也就無法保證。這是導致垃圾收集過程必須停頓所有用戶線程的一個重要原因
HotSpot中,使用一組稱為OopMap的數據結構來得到哪些地方存放著對象引用,這樣就不需要一個不漏的檢查完所有執行上下文和全局的引用位置
3.4.2 安全點
由于引用關系的變化,如果為每一條指令都生成對應的OopMap,那么將會需要大量的時間和空間,這樣垃圾收集伴隨而來的空間成本就會變得很高昂
實際上HotSpot只是在”特定的位置“記錄了這些信息,這些位置被稱為安全點,這就要求了用戶程序必須執行到達安全點后才能夠暫停。因此,安全點的選定即不能太少也不能太多,太少會讓收集器等待時間過長,太多會過分增大運行時的內存負荷。安全點的位置選取基本上是以是否具有讓程序長時間執行的特征為標準進行選定的,因為每條指令執行的時間都非常短暫,程序不太可能因為指令流長度太長的原因長時間執行,”長時間執行“的最明顯特征就是指令序列的復用,例如方法調用、循環跳轉、異常跳轉等。
另一個需要考慮的問題是,如何在垃圾收集發生時讓所有線程都跑到最近的安全點,這里有兩種方案:
- 搶先式中斷:搶先式中斷不需要線程的執行代碼主動去配合,垃圾收集時,系統先把所有用戶線程全部中斷,如果發現有用戶線程中斷的地方不在安全點上,就恢復這條線程的執行,直到跑到安全點上。幾乎沒有虛擬機實現采用這種方案。
- 主動式中斷:簡單的設計一個標志位,各個線程不斷的輪詢這個標志,一旦發現終端標志為真時就自己在最近的安全點上主動終端掛起。輪詢標志的地方和安全點是重合的,還要加上所有創建對象和其他需要在Java堆上分配內存的地方,這是為了檢查是否即將發生垃圾收集,避免沒有足夠內存分配新對象
3.4.3 安全區域
安全點看似已經完美解決了如何停頓用戶線程,但程序”不執行的時候“(即沒有分配處理器時間,程序處于Sleep狀態或者Blocked狀態),這時程序無法走到安全的地方中斷掛起自己,虛擬機也不可能等待線程重新被激活分配處理器時間。對于這種情況,就必須要引入安全區域來解決。
安全區域可以看作被擴展拉伸了的安全點,在這個區域中,引用關系不會發生變化。
當用戶線程執行到安全區域里面的代碼時,首先會標識自己已經進入了安全區域,這樣當虛擬機要發起垃圾收集時就不必去管這些已聲明自己在安全區域內的線程了。當線程要離開安全區域時,它要檢查虛擬機是否已經完成了根節點枚舉,如果完成了,那線程就當沒事發生過,繼續執行,否則它就必須一直等待,知道收到可以離開安全區域的信號為止。
3.4.4 記憶集與卡表
為了解決對象跨代引用所帶來的問題,垃圾收集器在新生代中建立了名為”記憶集“的數據結構。記憶集是一種用于記錄從非收集區域指向收集區域的指針集合的抽象數據結構。如果不考慮效率成本,最簡單的實現可以用非收集區域中所有含跨代引用的對象數組來實現這個數據結構:
Class RememberedSet{Object[] set[OBJECT_INTERGENERATIONAL_REFERENCE_SIZE];
}
這種記錄全部含跨代引用對象的實現方案,空間占用和維護成本都相當高昂,但是收集器只需要通過記憶集判斷某一塊非收集區域是否存在了指向收集區域的指針就可以了。所以設計者在設計之真的時候可以選擇更為粗獷的記錄粒度來節省記憶集的存儲和維護成本。
- 字長精度:每個記錄精確到一個字長,也就是一個跨代指針的物理內存地址的指針長度
- 對象精度:每個記錄精確到一個對象,該對象里含有跨代指針
- 卡精度:每個記錄精確到一塊內存區域,該區域有對象含有跨代指針
第三種“卡精度”指的是用一種稱為卡表的方式去實現記憶集,記憶集是一種抽象的數據結構,只定義了它的行為意圖,沒有定義行為的具體實現。卡表就是記憶集的一種具體實現,它定義了記憶集的記錄精度、與堆內存的映射關系等。
卡表最簡單的形式可以是一個字節數組,HotSpot虛擬機也是這樣做的:
CARD_TABLE[this address >> 9] = 1;
字節數組的每一個元素都對應著其標識的內存區域中一塊特定大小的內存塊,這個內存塊被稱作“卡頁”。一般來說,卡頁大小都是以2的N次冪的字節數,通過上面的代碼可以看出HotSpot使用的卡頁為2的9次冪。如果卡表內存起始地址是0x0000,數組CARD_TABLE的0、1、2號元素分別對應了地址范圍為0x0000-0x01FF,0x0200-0x03FF,0x0400-0x05FF的卡頁內存塊。
一個卡頁的內存通常包含不止一個對象,如果卡頁內存在跨代指針,那么對應卡表的數組元素值標識為1,稱這個元素變臟。在垃圾收集時只需要把臟元素篩選出來,就能輕易地出哪些卡頁內存塊中包含跨代指針,并加入GC Roots。
3.4.5 寫屏障
經過即時編譯的代碼已經是純粹的機器指令流了,這時該如何在對象賦值的那一刻更新維護卡表?這就必須找到一個機器碼層面的手段,把維護卡表的動作放到每一個賦值操作中。
寫屏障可以看作虛擬機層面對“引用類型字段賦值”這個動作的AOP切面,在寫之前的寫屏障叫做寫前屏障,在寫之后的叫做寫后屏障
為了避免偽共享問題,一種簡單的解決方案是不采用無條件的寫屏障,先檢查卡表標記,當該卡表元素未被標記過的時候才將其標記為變臟:
if(CARD_TABLE[this address >> 9] != 1){CARD_TABLE[this address >> 9] = 1;
}
3.4.6 并發的可達性分析
要解決或者降低用戶線程的停頓,就要先搞清楚為什么必須在一個能保障一致性的快照上才能進行對象圖的遍歷,為了弄清楚這個問題,我們引入三色標記作為工具輔助推導:
- 白色:表示對象尚未被垃圾收集器訪問過 ,如果在分析結束的階段,對象仍然是白色的,標識不可達
- 黑色:表示對象已經被垃圾收集器訪問過,且這個對象的所有引用都已經掃描過。黑色的對象代表已經掃描過,并且是安全存活的
- 灰色:表示對象已經被垃圾收集器訪問過,但這個對象上至少存在一個引用還沒有掃描過
如果掃描時線程是凍結的,那么不會有任何問題。如果掃描時用戶線程和掃描器是并發進行的,那么可能會產生兩種后果:
- 把原本消亡的對象錯誤標記為存活,這會產生一些浮動垃圾,下次收集處理掉即可
- 把原本存活的對象標記為已消亡,程序肯定會因此發生錯誤
譬如用戶線程將引用鏈上的一個灰色節點所有引用切斷,并且又被黑色節點引用。這時即使白色節點還在引用鏈上,也不會被掃描到了。
當且僅當下面兩個條件同時滿足時,會產生“對象消失”的問題,即原本應該是黑色的對象被誤標為白色:
- 賦值器插入了一條或多條從黑色對象到白色對象的新引用
- 賦值器刪除了全部從灰色對象到該白色對象的直接或者間接引用
我們要解決并發掃描時的對象消失問題,只需要破壞這兩個條件的任意一個即可。由此分別產生了兩種解決方案:增量更新和原始快照
增量更新要破壞的是第一個條件:黑色對象插入新的白色引用時,將這個新插入的引用記錄下來,并發掃描結束后再將這些記錄過的黑色對象為根,重新掃描一次。也就是說黑色對象一旦插入新的只想白色對象的引用,就變回灰色對象了。
原始快照要破壞的是第二個條件:當灰色對象要刪除指向白色對象的引用時,將要刪除的引用記錄下來,等并發掃描結束后,再將這些記錄過的引用關系中的灰色對象為根,重新掃描一次。可以簡化理解為:無論引用關系刪除與否,都會按照剛開始掃描的那一刻對象圖快照進行搜索。
在HotSpot中,增量更新和原始快照這兩種解決方案都有實際應用
五、經典垃圾收集器
3.5.1 Serial收集器
Serial收集器是一個單線程收集器,這里的“單線程”不僅僅指的是它只會用一個處理器或者一條收集線程去完成垃圾收集工作,更重要的是它進行垃圾收集時,必須暫停其他所有工作線程,直到它收集結束。
事實上,Serial收集器仍然有著優于其他收集器的地方,那就是簡單并且高效。對于內存資源受限的環境,它是所有收集器里額外內存消耗最小的。Serial收集器由于沒有線程交互的開銷,專心做垃圾收集自然可以獲得最高的單線程收集效率。收集幾十兆甚至一兩百兆的新生代,垃圾收集的停頓時間完全可以控制在十幾、幾十毫秒、最多一百多毫秒以內。
3.5.2 ParNew收集器
ParNew收集器實質上是Serial收集器的多線程并行版本。
ParNew是不少運行在服務端模式下的HotSpot首選的新生代收集器,有一個很重要的原因就是除了Serial收集器外,只有它能與CMS收集器配合工作。
3.5.3 Parallel Scavenge 收集器
Parallel Scavenge也是一款基于標記-復制算法實現的新生代收集器,Parallel Scavenge的特點是它的目標是達到一個可控制的吞吐量。吞吐量就是處理器用于運行用戶代碼的時間與處理器總消耗時間的比值,即:
吞吐量 = 運行用戶代碼時間 / (運行用戶代碼時間+運行垃圾收集時間)
Parallel Scavenge收集器提供了兩個參數用于精確控制吞吐量,分別是控制最大垃圾收集停頓時間的-XX:MaxGCPauseMillis參數以及直接設置吞吐量大小的-XX:GCTimeRatio參數
- -XX:MaxGCPauseMillis參允許的值是一個大于0的毫秒數,收集器將盡力保證內存回收花費時間不超過用戶設定值。但是垃圾收集停頓時間縮短是以犧牲吞吐量和新生代空間為代價換取的:系統把新生代調的小一點,但也直接導致垃圾收集發生的更頻繁,停頓時間的確在下降,但吞吐量也降下來了
- -XX:GCTimeRatio參數應設置為一個正整數,表示用戶期望虛擬機消耗在GC上的時間不超過程序運行時間的1/(N+1)
Parallel Scavenge收集器還有一個參數:-XX:+UseAdaptiveSizePolicy。這是一個開關參數,當這個參數被激活后,虛擬機會根據當前系統的運行情況收集性能監控信息,動態調整這些參數以提供最合適的停頓時間或者最大的吞吐量。這種調節方式稱為垃圾收集的自適應調節策略。
3.5.4 Serial Old收集器
Serial Old是Serial收集器的老年代版本,同樣是一個單線程收集器,使用標記-整理算法。這個收集器的主要意義是供客戶端模式下的HotSpot虛擬機使用。如果在服務端,也有兩種用途:
- 在JDK5以及之前的版本中與Parallel Scavenge收集器搭配使用
- 作為CMS收集器發生失敗時的后備預案,在并發收集發生Concurrent Mode Failure時使用
3.5.5 Parallel Old收集器
ParallelOld收集器時Parallel Scavenge收集器的老年代版本,支持多線程并行收集,基于標記-整理算法實現。
這個收集器是在JDK6才開始提供的,在此之前,Parallel Scavenge收集器一直處于相當尷尬的狀態,原因是如果新生代選擇了Parallel Scavenge收集器,老年代除了Serial Old收集器以外別無選擇,其他表現良好的老年收集器,如CMS無法與他配合工作。
由于老年代SerialOld收集器在服務端應用性能上的拖累,使用ParallelScavenge收集器也未必能在整體上獲得吞吐量最大化的效果。
同樣,由于單線程的老年代收集中無法充分利用服務器多處理器的并行處理能力,在老年代內存空間很大而且硬件規格比較高級的運行環境中,這種組合的總吞吐量甚至不一定比ParNew加CMS組合來得優秀
Parallel Old收集器出現后,“吞吐量優先”收集器終于有了比較名副其實的搭配組合,在注重吞吐量或者處理器資源較為稀缺的場合,都可以優先考慮Parallel Scavenge加Parallel Old收集器組合
3.5.6 CMS收集器
CMS收集器是一種以最短回收停頓時間為目標的收集器,一些Java應用的服務端上通常會較為關注服務的響應速度,希望系統停頓時間盡可能的短,以給用戶帶來良好的交互體驗
從名字上就可以看出CMS收集器是基于標記-清除算法實現的,它的運作過程相對于前面幾種收集器來說要更復雜一些,整個過程分為四個步驟:
- 初始標記
- 并發標記
- 重新標記
- 并發清除
初始標記、重新標記這兩個步驟仍然需要暫停所有用戶線程
初始標記僅僅是標記一下GC Roots能直接關聯到的對象,速度很快;并發標記階段就是從GC Roots的直接關聯對象開始遍歷整個對象圖的過程,這個過程耗時較長但是不需要停頓用戶線程,可以與垃圾收集線程一起并發運行。重新標記階段則是為了修正并發標記期間,因用戶程序繼續運作而導致標記變動的那一部分對象的標記記錄(增量更新和原始快照)。最后是并發清除,清除掉判斷的已死亡的對象,由于不需要移動存活對象,所以這個階段也是可以與用戶線程同時并發的。
CMS是一款優秀的收集器:并發收集、低停頓。CMS是HotSpot虛擬機追求低停頓的第一次成功嘗試,但是它還遠遠達不到完美的程度,至少有以下三個明顯的缺點:
- CMS對處理器資源非常敏感
面向并發設計的程序都對處理器資源比較敏感。在并發階段,它雖然不會導致用戶線程變慢,但卻會因為占用了一部分線程導致總吞吐量降低。CMS默認啟動的回收線程數是(處理器核心數量+3)/4,如果處理器核心數在四個或者以上,并發回收時垃圾收集器線程只占用不少于25%的處理器運算資源,并且會隨著處理器核心數量的增加而下降。但是當處理器核心數量不足4個時,應用本來的處理器負載就很高,還要分出一半運算能力執行收集器線程,就可能導致用戶線程的執行速度忽然大幅降低。為了緩解這種情況,虛擬機提供了一種稱為“增量式并發收集器”的CMS收集器變種,在并發標記、清理的時候讓收集器線程、用戶線程交替運行,盡量減少垃圾收集線程的獨占資源的時間,這樣做整個垃圾收集過程會很長,但是對用戶程序的影響就會顯得較少,直觀感受就是速度變慢的時間更多了,但是速度下降幅度沒有那么明顯。
- CMS收集器無法處理“浮動垃圾”有可能出現“Concurrent Mode Failure”失敗進而導致另一次完全“Stop The World”的Full GC產生。
在并發標記和并發清理階段,用戶線程還是在繼續運行的,會有新的垃圾對象不斷產生,但是這一部分垃圾對象CMS無法在檔次收集中處理掉他們,只好留到下一次垃圾收集時再清理掉。這一部分垃圾就稱為“浮動垃圾”。同樣由于垃圾收集階段用戶線程需要持續運行,就需要預留足夠的內存空間提供給用戶線程使用,因此CMS不能等到老年代幾乎被填滿了再進行收集,必須預留一部分空間供并發收集時的程序運作使用。JDK5的默認設置下,老年代的觸發百分比是68%,可以適當調高-XX:CMSInitiatingOccu-pancyFraction的值提高CMS的觸發百分比。但是如果CMS運行期間預留的內存無法滿足程序分配新對象的需要,就會出現一次“并發失敗”,這是虛擬機不得不凍結用戶線程的執行,臨時調用Serial Old收集器來重新進行老年代的垃圾收集,但這樣停頓的時間就長了。
- CMS基于“標記-清除”算法實現
為了解決內存碎片化問題,CMS提供了一個-XX:UseCMSCompactAtFullCollection開關參數,用于在CMS收集器不得不進行Full GC時開啟內存碎片的合并整理過程,由于過程無法并發,會導致停頓時間變長,所以虛擬機還提供了另外一個參數-XX:CMSFullGCsBeforeCompaction,用來要求CMS收集器在執行若干次不整理空間的Full GC后,下一次進入Full GC前會先進行碎片整理。
3.5.7 Garbage First收集器
Garbage First(簡稱G1)開創了收集器面向局部收集的設計思路和基于Region的內存布局形式。
設計者們希望做出一款能夠建立起”停頓預測模型“的收集器,停頓預測模型的意思是能夠支持指定在一個長度為M毫秒的時間片段內,消耗在垃圾手機上的時間大概率不超過N毫秒這樣的目標,這幾乎已經是實時Java(RTSJ)的中軟實時垃圾收集器特征了。
如何實現這個目標?首先要有思想上的轉變,在G1收集器出現之前的所有其他收集器,包括CMS,垃圾收集的目標范圍要么是整個新生代,要么是整個老年代,要么是整個Java堆。G1可以面向堆內存任何部分來組成回收集進行回收,衡量標準不再是它屬于哪個分代,而是哪塊內存中存放的垃圾數量最多,回收收益最大,這就是G1收集器的Mixed GC模式。
G1開創的基于Region的堆內存布局是它能夠實現這個目標的關鍵。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收集器之所以能夠建立可預測的停頓時間模型,是因為它會跟蹤各個Region里面的垃圾堆積的”價值“大小,價值即回收所獲得的空間大小以及回收所需時間的經驗值,然后在后臺維護一個優先級列表,每次根據用戶設定允許的收集停頓時間(使用參數-XX:MaxGCPauseMillis指定,默認值是200毫秒)優先處理回收價值收益最大的那些Region,這也是”Garbage First“名字的由來。
G1收集器至少還有以下這些關鍵的細節問題需要妥善解決:
- Region內的跨Region引用如何解決
每個Region都維護自己的記憶集,這些記憶集本質上是一種哈希表,Key是別的Region的起始地址,Value是一個集合,里面存儲的元素是卡表的索引號。這種雙向的卡表結構更加復雜,因此G1至少要耗費大約Java堆容量的10%至20%的額外內存來維持收集器工作。
- 并發標記階段如何保證收集線程和用戶線程互不干擾的運行?
CMS是通過增量更新實現的,G1是通過原始快照實現的。G1為每一個Region設計了兩個名為TAMS的指針,把Region中的一部分空間劃分出來用于并發回收過程中的新對象分配,這兩個指針上的對象默認是被隱式標記過的,即默認是存活的,不納入回收范圍。如果內存回收的速度趕不上內存分配的速度,G1收集器也要被迫凍結用戶線程執行,導致FullGC并產生長時間線程停頓。
- 怎樣建立起可靠的停頓預測模型
G1會記錄每個Region的回收耗時、每個Region記憶集里的臟卡數量等各個可測量的步驟花費的成本,并分析得出平均值、標準偏差、置信度等統計信息。這里強調的”衰減平均值“是指它會比普通的平均值更容易受到新數據影響,平均值代表整體平均狀態,但衰減平均值更準確地代表”最近的“平均狀態。換句話說,Region的統計狀態越新越能決定其回收價值,然后通過這些信息預測現在開始回收的話,由哪些Region組成的回收集才可以在不超過期望停頓時間的約束下獲得最高收益。
G1收集器的運作過程大致可劃分為以下四個步驟:
- 初始標記:標記一下GC Roots能直接關聯到的對象,并且修改TAMS的值,讓下一個階段用戶線程并發運行時,能正確地在可用的Region中分配新對象。這個階段需要停頓線程,但耗時很短,而且是借用進行Minor GC的時候同步完成的,所以G1收集器在這個階段實際并沒有額外的停頓。
- 并發標記:進行可達性分析,重新處理SATB(原始快照)記錄下的在并發時由引用變動的對象。
- 最終標記:對用戶線程做另一個短暫的暫停,用于處理并發階段結束后仍遺留下來的最后那少量的SATB記錄。
- 篩選回收:更新Region的統計數據,根據用戶期望的停頓時間制定回收計劃,可以自由選擇任意多個Region構成回收集,必須暫停用戶線程,由多條用戶線程并行完成。
G1與CMS:
G1優點:可以指定最大停頓時間、分Region的內存布局、按收益動態確定會收集這些創新型設計帶來的紅利,運作期間不會產生內存空間碎片,有利于程序長時間運行。
G1缺點:卡表實現更復雜,記憶集可能會占整個堆容量的20%甚至更多的內存空間;相比起來CMS的卡表相對簡單,而且只需要處理老年代到新生代的應用,反過來則不需要。由于G1的寫屏障操作要比CMS占用更多的運算資源,所以G1不得不將其時限為類似消息隊列的結構,將寫前屏障和寫后屏障中要做的事放到隊列里,然后再異步處理。
目前在小內存應用上CMS的表現大概率仍然會優于G1,而大內存應用上G1大多能發揮其優勢。
六、低延遲垃圾收集器
衡量垃圾收集器的三項重要指標是:內存占用、吞吐量、延遲,三者共同構成了一個“不可能三角”。
硬件規格提升,準確來說是內存的擴大,對延遲反而會帶來負面的效果:虛擬機要回收完整的1TB的堆內存,毫無疑問要比回收1GB的堆內存耗費更多時間。
Shenandoah和XGC,幾乎整個工作過程全部都是并發的,只有初始標記、最終標記這些階段有短暫的停頓,這部分停頓的時間基本上都是固定的,與對的容量、隊中對象的數量沒有正比例關系。
3.6.1 Shenandoah收集器
Shenandoah相較于G1的改進:
- 支持并發的整理算法
- 默認不使用分代收集,
- 摒棄了記憶集,改用名為“連接矩陣”的全局數據結構來記錄跨Region的引用關系,也降低了偽共享問題的發生概率。(鄰接矩陣)
Shenandoah收集器的工作大致可以劃分為以下幾個階段:
- 初始標記:標記與GC Roots直接關聯的對象,仍然需要停頓線程,但停頓時間只與GC Roots的數量有關
- 并發標記:標記出全部可達的對象,這個階段可并發執行,時間長短取決于堆中存活對象的數量以及對象圖的結構復雜程度
- 最終標記:處理剩余的SATB(原始快照)掃描,統計出回收價值最高的Region,將這些Region構成一組回收集,這個階段也會有一小段短暫的停頓
- 并發清理:清理那些整個區域連一個存活對象都沒有的Region(Immediate Garbage Region)
- 并發回收:這個階段Shenandoah要把回收集里面的存活對象先復制一份到其他未被使用的Region之中。對于并發回收階段遇到的指針并發訪問問題等,Shenandoah會通過讀屏障和被稱為“Brooks Pointers”的轉發指針來解決。時間長短取決于回收集大小。
- 初始引用更新:需要把堆中所有指向舊對象的引用修正到復制后的新地址,這個操作稱為引用更新。這個階段只是為了建立一個線程集合點,確保所有并發回收階段中進行收集線程都已完成分配給他們的對象移動任務而已,會產生一個非常短暫的停頓。
- 并發引用更新:真正開始進行引用更新操作,這個階段是并發的,時間長短取決于內存中涉及的引用數量的多少。它只需要按照內存物理地址的順序,線性的搜索出引用類型,把舊值改為新值即可。
- 最終引用更新:修正存在于GC Roots中的引用,這個階段是Shenandoah的最后一次停頓,停頓時間與GC Roots的數量相關。
- 并發清理:整個回收集中的Region已再無存活對象,最后再調用一次并發清理來回收這些Region的內存空間,供新對象使用。
支持并發整理的核心概念:轉發指針
轉發指針是在原有對象布局結構的最前面統一增加一個新的引用字段,在正常不處于并發移動的情況下,該引用指向對象自己,當對象有了一個新的副本,便只需要更改轉發指針的值指向新的副本即可。Shenandoah收集器使用CAS操作來保證并發時對象的訪問正確性。
3.6.2 ZGC收集器
ZGC收集器是一款基于Region內存布局的,不設分代的,使用了讀屏障、染色指針和內存多重映射等技術實現的可并發的標記-整理算法的,以低延遲為首要目標的一款垃圾收集器。
- ZGC的內存布局:
ZGC可以有大、中、小型Region
- 小型Region:容量固定為2MB,用于放置小于256KB的小對象
- 中型Region:容量固定為32MB,用于放置大于等于268KB但小于4MB的對象
- 大型Region:容量可以動態變化,但必須為2MB的整數倍,用于放置4MB以上的對象。每個大型Region中只會存放一個大對象,最小容量可以低至4MB。
- ZGC工作四個階段:
- 并發標記:遍歷對象圖做可達性分析,需要經過短暫停頓,標記階段會更新染色指針中的Marked 0、Marked 1標志位。
- 并發預備重分配:根據特定的查詢條件統計出來哪些Region需要清理,將這些Region組成重分配集。ZGC每次回收都會掃描所有的Region,用范圍更大的掃描成本換取記憶集的維護成本。ZGC的重分配集只是決定了里面的存活對象會被重新復制到其他的Region中,里面的Region會被釋放。
- 并發重分配:這個過程中要把重分配集中的存活對象復制到新的Region上,并為重分配集中的每個Region維護一個轉發表,記錄從舊對象到新對象的轉向關系。得益于染色指針的支持,ZGC收集器能僅從引用上就明確得知一個對象是否處于重分配集之中,如果用戶線程此時并發訪問了位于重分配集中的對象,這次訪問將會被頂置的內存屏障截獲,然后根據Region上的轉發表記錄將訪問轉發到新復制的對象上,并同時修正更新該引用的值,使其直接指向新對象,ZGC將這種行為稱為指針的“自愈”能力。
- 并發重映射:重映射就是修正整個堆中指向重分配集中舊對象的所有引用。ZGC很巧妙的把重映射要做的工作合并到了下一次并發標記階段里去完成,反正都是要遍歷所有對象的,這樣就節省了因此遍歷對象圖的開銷。所有指針都被修正后,原來記錄新舊對象關系的轉發表就可以被釋放掉了。
七、選擇合適的垃圾收集器
3.7.1 Epsilon收集器
如果應用只需要運行數分鐘甚至數秒,只要Java虛擬機能正確分配內存,在堆耗盡之前就會退出,那顯然運行負載極小、沒有任何回收行為的Epsilon便是很恰當的選擇
3.7.2 收集器的權衡
我們應該如何選擇一款適合自己應用的收集器,主要受以下三個因素影響:
- 應用程序的主要關注點是什么?吞吐量、低延遲、內存占用
- 運行應用的基礎設施?硬件規格、系統架構、處理器的數量、分配內存大小、操作系統
- JDK的發行商和版本號,對應的《Java虛擬機規范》的版本
3.7.3 虛擬機及垃圾收集器日志
日志級別從低到高共有六種級別:Trace,Debug,Info,Warning,Error,Off。日志級別決定了輸出信息的詳細程度,默認級別為Info,HotSpot的日志規則與Log4j、SLF4j類日志框架大體上是一樣的
還可以使用修飾器(Decorator)來要求每行日志輸出都附加上額外的內容。
3.7.4 垃圾收集器參數總結
八、實戰:內存分配與回收策略
之前幾個小節已經探討了如何回收對象的問題,接下來幾個小節主要探討如何分配對象的問題
3.8.1 對象優先在Eden分配
大多數情況下,對象在新生代Eden區中分配。當Eden區沒有足夠空間進行分配時,虛擬機將發起一次Minor GC
vm參數:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:+UseSerialGC
private static final int _1MB = 1024 * 1024;
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
}
分配allocation4對象時會發生一次Minor GC,原因是Eden已經被占用了6MB,剩余空間已不足以分配allocation4所需的4MB內存,因此發生Minor GC。垃圾收集器期間1虛擬機又發現已有的三個2MB大小的對象全部無法放入Survivor空間,所以只好通過分配擔保機制提前轉移到老年代去。
3.8.2 大對象直接進入老年代
分配空間時,大對象容易導致內存明明還有不少空間時就提前觸發垃圾收集,以獲取足夠的連續空間才能安置好他們,當復制對象時,大對象意味著高額的內存復制開銷。
HotSpot虛擬機提供了-XX:PretenureSizeThreshold參數,指定大于該設置值的對象直接在老年代進行分配,這樣做的目的是避免在Eden區及兩個Survivor區之間來回復制,產生大量的內存復制操作。
-XX:PretenureSizeThreshold參數只對Serial和ParNew兩款新生代收集器有效,HotSpot的其他新生代收集器,如Parallel Scavenge并不支持這個參數。如果必須使用這個參數進行調優,可考慮ParNew加CMS的收集器組合
VM參數:-verbose:gc -Xmx20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:PretenureSizeThreshold=3145728 -XX:UseConcMarkSweepGC
private static final int _1MB = 1024 * 1024;
public static void testAllocation(){byte[] allocation;allocation = new byte[4 * _1MB];
}
4MB對象直接進入了老年代
3.8.3 長期存活的對象進入老年代
虛擬機給每個對象定義了一個對象年齡計數器,存儲在對象頭中。對象通常在Eden區誕生,如果經過第一次Minor GC后仍然存活,并且能被Survivor容納,就將其年齡設為1歲,對象在Survivor中每熬過一次Minor GC,年兩就增加1歲,當年齡增加到一定程度(默認為15),就會被晉升到老年代中。
對象晉升老年代的年齡閾值,可以通過參數-XX:MaxTenuringThreshold設置
vm參數:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=1 -XX:+PrintTenuringDistribution
private static final int _1MB = 1024 * 1024;public static void main(String[] args){byte[] allocation1 , allocation2 , allocation3;allocation2 = new byte[4 * _1MB];allocation3 = new byte[4 * _1MB];allocation3 = null;allocation3 = new byte[4 * _1MB];}
MaxTenuringThreshold=15:
3.8.4 動態對象年齡判定
為了能更好地適應不同程序的內存狀況,如果在Survivor空間中低于或等于某年齡的所有對象大小的綜合大于Survivor空間的一半,年齡大于或等于該年齡的對象就可以直接進入老年代
private static final int _1MB = 1024 * 1024;public static void main(String[] args){byte[] allocation1 , allocation2 , allocation3 , allocation4;allocation1 = new byte[_1MB / 4];allocation2 = new byte[_1MB / 4];allocation3 = new byte[4 * _1MB];allocation4 = new byte[4 * _1MB];allocation4 = null;allocation4 = new byte[4 * _1MB];}
3.8.5 空間分配擔保
進行Minor GC之前,虛擬機必須先檢查老年代是否有足夠空間進行分配擔保和-XX:HandlePromotionFailure參數,如果老年代沒有足夠空間并且參數設置不允許冒險,那么這時就要改為一次FullGC