文章目錄
- 概述
- 對象已死嗎
- 引用計數算法
- 可達性分析算法
- 再談引用
- finalize():生存還是死亡
- 回收方法區
- 垃圾收集算法
- 標記-清除算法
- 復制算法
- 標記-整理算法
- 分代收集算法
- HotSpot的算法實現
- 枚舉根結點
- 安全點
- 安全區域
- 垃圾收集器
- Serial
- ParNew
- Parallel Scavenge
- Serial Old
- Parallel Old
- CMS
- CMS運作步驟
- CMS優缺點
- G1
- G1實現原理
- G1實現難點
- G1運作步驟
- G1性能評測
- 理解GC日志
- GC小結
- 垃圾收集器參數總結
- 內存分配與回收策略
- 對象優先在Eden分配
- 程序運行結果解釋1
- 新生代GC與老年代GC區別
- 大對象直接進入老年代
- 程序運行結果解分析2
- 長期存活的對象將進入老年代
- 程序運行結果解分析3
- 動態對象年齡判定
- 程序運行結果解分析4
- 空間分配擔保
- 本章小結
概述
1960年誕生于MIT的Lisp是第一門真正使用內存動態分配和垃圾收集技術的語言。
當Lisp還在胚胎期,人們就在思考GC需要完成3件事情:
- 哪些內存需要回收?
- 什么時候回收?
- 如何回收?
經過多年發展,垃圾收集器與內存分配技術日臻完善,為什么還有去了解GC和內存分配?
因為當需要排查各種內存溢出、內存泄露問題時,當垃圾收集稱為系統達到更高并發量的瓶頸時,就需要對這些“自動化”的技術實施必要的監控和調節。
在Java內存運行時區域中,PCR、 VM Stack、 Native Method Stack 3個區域與線程同生共死。
棧中的棧幀隨著方法的進入和退出而有條不紊地執行者出棧和入棧的操作。每一棧幀中分配多少內存基本上是在類結構確定下倆就已知的。
因此這幾個區域內存分配和回收都具備確定性,所以無需多慮,內存隨著方法結束或者線程結束而回收。
Java堆 和 方法區與這3區域的不同。
一個接口中的多個實現類需要的內存可能不一樣,一個方法中的多個分支需要的內存也可能不一樣,這能在程序運行期間才能知道會創建哪些對象,這部分內存分配和回收都是動態。
GC所關注的是這部分內存。
對象已死嗎
GC在堆進行回收前,第一件事就是要哪些對象是“活”的,哪些是“死”的。
引用計數算法
引用計數算法 Reference Counting思路:給對象中添加一個引用計數器,每當有一個地方引用它時,計數器+1;引用失效時,計數器-1;任何時刻計數器為0的對象就是不可能再被使用。
它的優點:簡單高效
但是,主流JVM未選用該算法管理內存,其中最主要的原因是它很難解決對象間相互循環引用的問題。
兩對象相互引用的例子
package com.lun.c03;
/*** testGC()方法執行后,objA和objB會不會被GC呢? * @author zzm*/
public class ReferenceCountingGC {public Object instance = null;private static final int _1MB = 1024 * 1024;/*** 這個成員屬性的唯一意義就是占點內存,以便在能在GC日志中看清楚是否有回收過*/private byte[] bigSize = new byte[2 * _1MB];public static void testGC() {ReferenceCountingGC objA = new ReferenceCountingGC();ReferenceCountingGC objB = new ReferenceCountingGC();objA.instance = objB;objB.instance = objA;objA = null;objB = null;// 假設在這行發生GC,objA和objB是否能被回收?System.gc();}
}
上例側面說明VM并不是通過引用計數算法來判斷對象是否存活的。
PS. 上例沒有main方法,另外程序例子運行后如何生成GC日志暫未說明,GC日志內容也未說明。
PS. 理解GC日志
可達性分析算法
Reachability Analysis/??n?l?s?s/
Java、C#、古老Lisp都是通過這種算法來判斷對象是否存活。
算法思路:通過一系列稱為“GC Roots”的對象作為起始點,從這些節點開始向下搜索,搜索所走過的路徑稱為引用鏈Reference Chain,當一對象到GC Roots沒有任何引用鏈相連(用圖論的話來說,就是從GC Root到這個對象不可達)時,則證明此對象是不可用的。
在Java語言中,可作為GC Roots的對象包括下面幾種:
- VM Stack(棧幀的本地變量表)中引用的對象
- 方法區中類靜態屬性引用的對象
- 方法區中常量引用的對象
- 本地方法棧中JNI(即一般說的Native方法)引用的對象
再談引用
無論是通過引用計數算法判斷對象的引用對象,還是通過可達性分析算法判斷對象的引用鏈是否可達,判定對象是否存活都與“引用”有關。
JDK1.2以前,Java中的引用的定義很傳統:若reference類型的數據中存儲的數字代表的是另一塊內存的起始地址,就稱這塊內存代表著一個引用。這定義純粹,但狹隘,一個對象在這種頂一下只有被引用或者沒有被引用兩種狀態。
愿景:有一類對象,當內存空間還足夠時,則保留在內存中;若內存空間在進行垃圾收集后還是非常緊張,則可拋棄這些對象。很多系統的緩存功能都符合這樣的應用場景。
在JDK1.2后,Java對引用的概念進行了擴充。
- 強引用StrongReference指在程序代碼中普遍存在的,類似“Object obj = new Object()”這類的引用,只要強引用還存在,GC永不回收掉被引用的對象。
- 軟引用SoftReference用來描述一些還有用但并非必需的對象。 對于軟引用關聯著的對象,在系統將要發生OOME之前,將會把這些對象列入回收范圍之中進行第二次回收。若這次回收還沒有足夠內存,才會拋出OOME。在JDK1.2后,SoftReference類實現軟引用
- 弱引用WeakReference用來描述非必需對象的,但是它的強度比軟引用更弱一些,被弱引用關聯的對象只能生存到下一次垃圾收集發生之前。當GC工作時,無論當前內存是否足夠,都會回收掉只被弱引用關聯的對象。在JDK1.2后,WeakReference類實現弱引用
- 虛引用PhantomReference最弱的引用。一個對象是否有虛引用的存在,完全不會對其生存時間構成影響,也無法通過虛引用用來取得一個對象實例。為一個對象設置虛引用關聯的唯一目的就是能在這個對象被GC回收時收到一個系統通知。在JDK1.2后,PhantomReference類實現虛引用
這4種引用強度依次逐漸減弱。
finalize():生存還是死亡
要真正宣告一個對象死亡,至少要經歷兩次標記過程:
若對象在進行可達性分析后發現沒有與GC Roots相連接引用鏈,那它會被第一次標記。第一次標記后進行一次篩選,條件是此對象是否有必要執行finalize()方法。
VM將兩種情況視為“沒必要執行”
- 對象沒有覆蓋finalize()方法
- finalize()方法已經被VM調用過
若這對象被判定為有必要執行finalize()方法,這對象就將會防止在一個叫做F-Queue的隊列之中,并在稍后由一個由VM自動建立的、低優先級的Finalizer線程去執行它。
這里所謂的“執行”是指VM會觸碰這個方法,但并不承諾會等待它運行結束,這樣做的原因是,若一個對象在finalize()方法中執行緩慢,或者發生了死循環(更極端的情況),將可能會導致F-ueue隊列中其他對象永久處于等待,甚至導致整個內存回收系統崩潰。
finalize()方法是對象逃脫死亡命運的最后一次機會,稍后GC將對F-Queue中的對象進行第二次小規模標記,
自贖的方法:
若對象要在finalize()成功拯救自己——只要重新與引用鏈上的任何一個對象建立關聯即可,譬如把自己(this關鍵字)賦值給某個類變量或者對象的成員變量,那在第二次標記時它將被移除出“即將回收”的集合:若對象這時候還沒有逃脫,那基本上它就真的被回收了。如下例
package com.lun.c03;/*** 此代碼演示了兩點: * 1.對象可以在被GC時自我拯救。 * 2.這種自救的機會只有一次,因為一個對象的finalize()方法最多只會被系統自動調用一次* @author zzm*/
public class FinalizeEscapeGC {public static FinalizeEscapeGC SAVE_HOOK = null;public void isAlive() {System.out.println("yes, i am still alive :)");}@Overrideprotected void finalize() throws Throwable {super.finalize();System.out.println("finalize mehtod executed!");FinalizeEscapeGC.SAVE_HOOK = this;}public static void main(String[] args) throws Throwable {SAVE_HOOK = new FinalizeEscapeGC();//對象第一次成功拯救自己SAVE_HOOK = null;System.gc();// 因為Finalizer方法優先級很低,暫停0.5秒,以等待它Thread.sleep(500);if (SAVE_HOOK != null) {SAVE_HOOK.isAlive();} else {System.out.println("no, i am dead :(");}// 下面這段代碼與上面的**完全相同**,但是這次自救卻失敗了SAVE_HOOK = null;System.gc();// 因為Finalizer方法優先級很低,暫停0.5秒,以等待它Thread.sleep(500);if (SAVE_HOOK != null) {SAVE_HOOK.isAlive();} else {System.out.println("no, i am dead :(");}}
}/* result:finalize mehtod executed!
yes, i am still alive :)
no, i am dead :(
*/
上例子中,代碼有兩段完全一樣的代碼片段,執行結果卻是一次逃脫成功,一次失敗,這是因為任何一個對象的finalize()方法都只會被系統自動調用一次,若對象面臨下一次回收,它的finalize()方法不會被再次執行,因此第二段代碼自救行動失敗了。
建議finalize()不要被調用。
它不是C/C++中的析構函數,而是Java誕生初期為使C/C++程序員更容易接受它所作出的一個妥協。
它的運行代價高昂,不確定性大,無法保證各個對象的調用順序。
finalize()能做的所有工作,使用try-finally或者其他方式都可以做得更好、更及時。
所以,finalize()方法了解一下就行了。
回收方法區
方法區(HotSpotVM的永生代)被認為沒有垃圾收集,JVM規范確實說過可以不要求VM在方法區實現垃圾收集,而且在方法區中進行垃圾收集的“性價比”一般較低:在堆中,尤其是在新生代中,常規應用進行一次垃圾收集一般可以回收70%~95%的空間,而永久代的垃圾收集效率遠低于此。
永久代的垃圾收集主要回收兩部分內容
- 廢棄常量
- 無用類
回收廢棄常量與回收Java堆中的對象非常類似。
以常量池中字面量的回收為例,假如一個字符串“abc”已經進入常量池中,當前系統沒有任何一個String對象叫做“abc”的,換句話說,就是沒有任何String對象引用常量池中的“abc”常量,也沒有其他地方引用了這個字面量,如果這時發生內存回收,而且必要的話,這“abc”常量就會被系統清理出常量池。常量池中的其他類(接口)、方法、字段的符號引用也與此類似。
判定一個常量是否是“廢棄常量”比較簡單,而要判定一個類是否是“無用的類”的條件則相對苛刻許多。
類需要同時滿足下面3個條件才能算是“無用的類”:
- 該類所有實例已被回收,也就是Java堆中不存在該類的任何實例。
- 加載該類的ClassLoader已經被回收
- 該類對應的java.lang.Class對象沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法。
VM可對滿足上述3個條件的無用類進行回收,這里僅僅是“可以”,而并不是和對象一樣,不使用了就必然會回收。
是否對類進行回收,HotSpot VM提供了-Xnoclassgc參數進行控制,還可以使用-verbose:class以及-XX:+TraceClassLoading、-XX:+TraceClassUnLoading查看類加載和卸載信息,
其中-verbose:class、-XX:+TraceClassLoading可在Product版的虛擬機中使用,-XX:+TraceClassUnLoading參數需要FastDebug版的虛擬機支持。
在大量使用反射、動態代理、CGLib等ByteCode框架、動態生JSP以及OSGi這類頻繁自定義ClassLoader的場景都需要VM具備卸載的功能,以保證永久代不會溢出。
垃圾收集算法
這里只介紹算法思想,各種VM有各種垃圾收集算法
標記-清除算法
Mark-Sweep
算法分兩階段
- 標記 標記出所有需要回收的對象(標記過程在上章介紹)
- 清除 標記完成后統一回收所有被標記的對象
它的不足有兩個
- 效率問題,標記和清除兩個過程效率不高;
- 空間問題,標記清除走會產生大量不連續的內存碎片,空間碎片太多可能會導致一首在程序運行過程中需要分配較大對象時,無法找到足夠的連續內存而不得不提前出發另一次垃圾回收動作。
復制算法
Copying 為解決效率問題而出現的。
它將可用內存按容量劃分為大小相等的兩塊,每次只使用其中一塊。當這一塊內存用完了,就將還存活著的對象復制到另外一塊上面,然后再把已使用過的內存空間一次清理掉。
這樣使得每次都是對整個半區進行內存回收;內存分配時也就不用考慮內存碎片等復雜情況,只是移動堆頂指針,按順序分配內存即可,實現簡單,運行高效。
只是這種算法的代價是將內存縮小為了原來的一般,未免太高了一點。
現在VM都采用這種收集算法來回收新生代。
IBM研究發現,新生代中的對象98%是“朝生夕死”(短命),所以不必按照1:1比例劃分內存空間。
而是將內存分為一塊較大的Eden(伊甸園)空間和兩塊較小的Survivor空間,每次使用Eden和其中一塊Survivor。
當回收時,將Eden和Survivor中還存活著的對象一次性地復制到另一塊Survivor上,最后清理掉Eden和剛才用過的Survivor空間。
HotSpot虛擬機默認Eden和Survivor大小比例是8:1,也就是每次新生代可用內存空間為整個新生代容量的90%(80%+10%),只有10%內存會被“浪費”。當然,98%的對象可回收只是一般場景下的數據,無法保證每次回收都只有不多于10%的對象存活,當Survivor空間不夠用時,需要依賴其他內存(這里指老年代)進行分配擔保Handle Promotion
類比
內存的分配擔保就好比我么去銀行借款,若信譽良好,在98%情況下都能按時償還,于是銀行可能會默認我們下一次也能按時按量地償還貸款,只需要有一個擔保人能保證如果我不能還款時,可以從他的賬戶扣錢,那銀行就認為沒有風險了
若另一塊Survivor空間沒有足夠空間存放上一次新生代收集下來存活對象時,這些對象將直接通過分配擔保機制進入老年代。
標記-整理算法
復制收集算法在對象存活率較高時就要進行較多的復制操作,效率將會變低。
若不想浪費50%的空間,就需要有額外的空間進行分配擔保,以應對被使用內存中所有對象都100%存活的極端情況,所以老年代一般不能直接選用這種算法。
根據老年代的特點,有人提出了另外一種“標記-整理”Mark-Compact算法,標記過程仍然與“標記-清除”算法一樣,但后續步驟不是直接對可回收對象進行清理,而是讓所有存活的對象都向一端移動,然后直接清理掉端邊界以外的內存。
分代收集算法
當代商業VM的垃圾收集都采用“分代收集”Generational Collection算法,根據對象存活周期的不同將內存劃分為幾塊。
一般是把Java堆分為 新生代 和 老生代,然后根據各種年代特點采用最適當的收集算法。
在新生代中,每次垃圾收集時都發現有大批對象死掉,只有少量存活,那就選用復制算法,只需要付出少量存活對象的復制成本就可以完成收集。
而老年代中因為對象存活率高、沒有額外空間對它進行分配擔保,就必須使用“標記——清理”或者“標記——整理”算法來進行回收。
HotSpot的算法實現
枚舉根結點
從可達性分析中從GC Roots節點找引用鏈這個操作為例,可作為GC Roots的節點主要在全局性的引用(例如常量或靜態屬性)與執行上下文(例如棧幀中的本地變量表)中,現在很多應用僅僅方法區就有數百兆,若要逐個檢查這里面的引用,那必然會消耗很多時間(局限)。
另外,可達性分析對執行時間的敏感還體現在GC停頓上,因為這項分析工作必須在一個能確保一致性的快照中進行
這里“一致性”的意思是指在整個分析期間整個執行系統看起來就像被凍結在某個時間點上,不可以出現分析過程中對象引用關系還在不斷變化的情況,該點不滿足的話分析結果準確性就無法得到保證。
這點是導致GC進行時必須停頓所有Java執行線程的其中一個重要原因,即使是在號稱不會停頓CMS收集器中,枚舉根結點時也是必須要停頓的。
由于目前的主流JVM使用的多事準確性GC,所以當執行系統停頓下來,并不需一個不漏地檢查完所有執行上下文和全局的引用位置,VM應當是有辦法直接得知哪些地方存放著對象引用。
在HotSpot的實現中,是使用一組稱為OopMap的數據結構來達到這個目的的,在類加載完成的時候,HotSpot就把對象內什么偏移量上是什么類型的數據計算出來,在JIT編譯過程中,也會在特定的位置記錄下棧和寄存器中哪些位置是引用。這樣,GC在掃描時就可以直接得知這些信息了。
總結:用OopMap記錄對象存放地方,減少枚舉根結點消耗時間(空間換時間)
安全點
Safepoint
在OopMap的協助下,HotSpot可以快速且準確地完成GC Roots枚舉。
但一個很現實的問題隨之而來:可能導致引用關系變化,或者說OopMap內容變化指令非常多,如果為每一條指令都生成對應的OopMap,那將會需要大量的額外空間,這樣GC的空間成本將會變得很高。
實際上,HotSpot也的確沒有為每條指令都生成OopMap,只是在特定的位置記錄這些信息,這些位置稱為安全點(Safepoint),即程序執行時并非在所有地方都能停頓下來開始GC,只有在到達安全點時才能暫停。
安全點的選定即不能太少以致于讓GC等待時間太長,也不能過于頻繁以致于過分增大運行時的負荷。
所以,安全點的選定基本上是以程序“是否具有讓程序長時間執行的特征”為標準進行選定的——因為每條指令執行的時間都非常短暫,程序不太可能因為指令流長度太長這個原因而過長時間運行。“長時間執行”的最明顯特征就是指令序列復用,例如
- 方法調用
- 循環跳轉
- 異常跳轉
- …
所以具有這些功能的指令才會產生Safepoint。
對于Safepoint,另一個需要考慮的問題是如何在GC發生時讓所有線程(這里不包括執行JNI調用的線程)都“跑”到最近的安全點上再停頓下來。
這里兩種方案可供選擇:
- 搶先式中斷(Preemptive Suspension)不需要線程的執行代碼主動去配合,在GC發生時,首先把所有線程全部中斷,若發現有線程中斷的地方不在安全點上,就恢復線程,讓它“跑”到安全點上。現在幾乎沒喲VM實現采用這方案來暫停從而響應GC事件。
- 主動式中斷(Voluntary Suspension)不直接對線程操作,僅僅簡單地設置一個標志,各個線程執行時主動去輪詢這個標志,發現中斷標志為真時就自己中斷掛起。輪詢標志的地方和安全點是重合的,另外再加上創建對象需要分配內存的地方。(類似信號燈)
小結:選擇適合的Safepoint來而啟動GC,避免過多或過少啟動GC而影響性能。
安全區域
Safepoint機制保證了程序執行時,在不太長的時間內就會遇到可進入GC的Safepoint。
但是,程序“不執行”的時候呢?所謂的程序不執行就是沒有分配CPU時間,典型的例子就是線程處于Sleep狀態或者Blocked狀態,這時候線程無法響應JVM的中斷請求,“走”到安全的地方去中斷掛起,JVM也顯然不太可能等待線程重新被分配CPU時間。對于這種情況,就需要安全區域Safe Region來解決。
安全區是指在一段代碼片段之中,引用關系不會發生變化。在這區域中的任意地方開始GC都是安全,我們也可以吧Safe Region看作是被擴展了的Safepoint。
在線程執行到Safe Region中的代碼時,首先標識自己已經進入Safe Region,那樣,當在這段時間里JVM要發起GC時,就不用管標識自己為Safe Region狀態的線程了。
在線程要離開Safe Region時,它要檢查系統是否已經完成了根節點枚舉(或者是整個GC過程),如果完成了,那線程就繼續執行,否則它就必須等待直到收到可以安全離開Safe Region的信號為止。
小結:Safe Region看作是被擴展了的Safepoint
垃圾收集器
若說收集算法是內存回收的方法論,那么GC就是內存回收的具體實現。
JVM規范對垃圾收集器應該如何實現并沒有任何規定,因此不同的廠商、不同版本的VM所提供的GC可能大有差別,并且一般都會提供參數供用戶根據自己的應用特點和要求組合出各個年代所使用的收集器。
接著討論的GC基于JDK 1.7 Update14之后的HotSpot VM(這版本中正式提供了商用G1收集器,之前G1仍處于實驗狀態)
圖中展示了7種作用于不同分代的收集器,若兩個GC有聯線,就說明他們可以搭配使用。GC所處的區域,則表示它是屬于新生代收集器還是老年代收集器。
明確一點:雖然是在對各個GC進行比較,但并非為了挑出了一個最好的GC。因為直到現在為止還沒有最好的GC出現,更加沒有萬能的GC,所以我們選擇的只是對具體應用最合適的GC。這點不需要多加解釋就能證明:如果有一種放之四海而皆準、任何場景都適用的完美的GC存在,那HotSpot VM就沒必要實現那么多不同的GC了。
Serial
它是JDK 1.3之前是VM新生代收集的唯一選擇。
它是一個單線程的收集器,但它的“單線程”的意義并不僅僅說明它只會使用一個CPU或一條收集線程去完成來及收集工作,更重要的是在它進行垃圾收集時,必須暫停其他所有的工作線程,直到它收集結束。(Stop the World)。
這項工作實際上是由VM在后臺自動發起和自動完成的。在用戶不可見的情況下把用戶正常工作的線程全部停掉,這對很多應用來說都是難以接受的。
類比
你媽媽在給你打掃房間的時候,肯定也會讓你老老實實地在椅子上或者房間外待著,若她一邊打掃,你一邊亂扔紙屑,這件還能打掃完?
HotSpot VM開發團隊為消除或者減少工作線程因內存而導致停頓的努力一直在進行著。
Serial收集器實屬雞肋
但實際上到現在為止,它依然是VM運行在Client模式下的默認新生代收集器。
它的優點:簡單高效(與其他收集器的單線程比),對于限定單個CPU環境來說,Serial收集器沒有線程交互的開銷,專心垃圾收集,可獲得最高的單線程收集效率。
在用戶桌面應用場景中,分給VM管理的內存一般來說不會很大,收集(10,200)M的新生代,停頓時間完全可以控制在(10,100)ms內,只要不頻繁發生,這點停頓是可接受的。
所以,Serial收集器對于運行在Client模式下的VM來說是一個很好的選擇。
ParNew
ParNew收集器其實就是Serial收集器的多線程版本。
除了使用多條線程進行垃圾收集之外,其余行為包括:
- Serial收集器可用的所有控制參數
- -XX:SurvivorRatio
- -XX:PretenureSizeThreshold
- -XX:HandlePromotionFailure
- …
- 收集算法
- Stop the World
- 對象分配原則
- 回收策略
它兩公用了相當多代碼。
ParNew收集器除了多線程收集之外,其他與Serial收集器相比沒有太多創新之處。
但它是許多運行在Server模式下的VM中首選的新生代收集器,其中有一個與性能無關但很重要的語音是,除了Serial收集器,目前只有它能與CMS(Concurrent Mark Sweep)收集器配合工作
ParNew收集器在單CPU的環境中絕對不會有比Serial收集器更好的效果,甚至由于存在線程交互開銷,該收集器在通過超線程技術實現的兩個CPU的環境中都不能百分之百保證可以超越Serial收集器。
當然,隨著可使用CPU數量的增加,它對于GC時系統資源的有效利用還是有很多好處的。
并發和并行在GC上下文語境中的解釋:
并行Parallel/?p?r??l?l/ 指多條垃圾收集線程并行工作,但此時用戶線程仍然處于等待狀態。
并發Concurrent/k?n?k?:r?nt/ 指用戶線程與垃圾收集線程同時執行(但不一定是并行的,可能會交替執行),用戶程序在繼續運行,而垃圾收集程序運行于另一個CPU上。
Parallel Scavenge
新生代收集器
scavenge
英 [?sk?v?nd?] 美 [?sk?v?nd?]
vt.& vi.
清除污物,打掃;(在廢物中)尋覓;(動物)食腐肉
Parallel Scavenge收集器的特點是它的關注點與其他收集器不同
CMS等收集器的關注點是盡可能地縮短垃圾收集時用戶線程的停頓時間
Parallel Scavenge收集器的目標則是達到一個可控制的吞吐量Throughput
所謂吞吐量就是CPU用于運行用戶代碼的時間與CPU總消耗時間的比值,即吞吐量 = 運行用戶代碼時間 /(運行用戶代碼時間 + 垃圾收集時間),虛擬機總共運行了100分鐘,其中垃圾收集花掉1分鐘,那吞吐量就是99%。
停頓時間越短就越適合需要與用戶交互的程序,良好的響應速度能提升用戶體驗
而高吞吐量則可以高效率地利用CPU時間,盡快完成程序的運算任務,主要適合在后臺運算而不需要太多交互的任務。
Parallel Scavenge收集器提供了兩個參數用于精確控制吞吐量,分別是控制最大垃圾收集停頓時間的-XX:MaxGCPauseMillis參數以及直接設置吞吐量大小的-XX:GCTimeRatio參數。
MaxGCPauseMillis參數允許的值是一個大于0的毫秒數,收集器將盡可能地保證內存回收花費的時間不超過設定值。不過大家不要認為如果把這個參數的值設置得稍小一點就能使得系統的垃圾收集速度變得更快,GC停頓時間縮短是以犧牲吞吐量和新生代空間來換取的:系統把新生代調小一些,收集300MB新生代肯定比收集500MB快吧,這也直接導致垃圾收集發生得更頻繁一些,原來10秒收集一次、每次停頓100毫秒,現在變成5秒收集一次、每次停頓70毫秒。停頓時間的確在下降,但吞吐量也降下來了。
GCTimeRatio參數的值應當是一個大于0且小于100的整數,也就是垃圾收集時間占總時間的比率,相當于是吞吐量的倒數。如果把此參數設置為19,那允許的最大GC時間就占總時間的5%(即1 /(1+19)),默認值為99,就是允許最大1%(即1 /(1+99))的垃圾收集時間。
由于與吞吐量關系密切,Parallel Scavenge收集器也經常稱為“吞吐量優先”收集器。
GC自適應的調節策略
除上述兩個參數之外,Parallel Scavenge收集器還有一個參數**-XX:+UseAdaptiveSizePolicy**值得關注。
這是一個開關參數,當這個參數打開之后,就不需要手工指定新生代的大小(-Xmn)、Eden與Survivor區的比例(-XX:SurvivorRatio)、晉升老年代對象年齡(-XX:PretenureSizeThreshold)等細節參數了,虛擬機會根據當前系統的運行情況收集性能監控信息,動態調整這些參數以提供最合適的停頓時間或者最大的吞吐量,這種調節方式稱為GC自適應的調節策略(GC Ergonomics)。
如果讀者對于收集器運作原來不太了解,手工優化存在困難的時候,使用Parallel Scavenge收集器配合自適應調節策略,把內存管理的調優任務交給虛擬機去完成將是一個不錯的選擇。
只需要把基本的內存數據設置好(如-Xmx設置最大堆),然后使用MaxGCPauseMillis參數(更關注最大停頓時間)或GCTimeRatio(更關注吞吐量)參數給虛擬機設立一個優化目標,那具體細節參數的調節工作就由虛擬機完成了。自適應調節策略也是Parallel Scavenge收集器與ParNew收集器的一個重要區別。
Serial Old
Serial Old是Serial收集器的老年代版本,它同樣是單線程收集器,使用“標記-整理”算法。這個收集器的主要意義也是在于給Client模式下的虛擬機使用。
如果在Server模式下,那么它主要還有兩大途徑:
- 在JDK1.5 以及 之前的版本與Parallel Scavenge收集器搭配使用
- 作為CMS收集器的后備預案,在并發收集發生Concurrent/k?n?k?:r?nt/ Mode Failure
Parallel Old
Parallel Old是Parallel Scavenge收集器的老年代版本,使用多線程和“標記-整理”算法。
這個收集器是在JDK 1.6中才開始提供的,在此之前,新生代的Parallel Scavenge收集器一直處于比較尷尬的狀態。
原因是,如果新生代選擇了Parallel Scavenge收集器,老年代除了Serial Old(PS MarkSweep)收集器外別無選擇(還記得上面說過Parallel Scavenge收集器無法與CMS收集器配合工作嗎?)。
由于老年代Serial Old收集器在服務端應用性能上的“拖累”,使用了Parallel Scavenge收集器也未必能在整體應用上獲得吞吐量最大化的效果,由于單線程的老年代收集中無法充分利用服務器多CPU的處理能力,在老年代很大而且硬件比較高級的環境中,這種組合的吞吐量甚至還不一定有ParNew加CMS的組合“給力”。
直到Parallel Old收集器出現后,“吞吐量優先”收集器終于有了比較名副其實的應用組合,在注重吞吐量以及CPU資源敏感的場合,都可以優先考慮Parallel Scavenge加Parallel Old收集器。
CMS
CMS(Concurrent Mark Sweep)收集器時一種以獲取最短回收停頓時間為目標的收集器。
目前很大一部分的Java應用都集中在互聯網站或B/S系統的服務端上,這類應用尤其重視服務的響應速度,希望系統停頓時間最短,以給用戶帶來較好的體驗。
CMS收集器就非常符合這類應用的需求。
CMS運作步驟
CMS收集器時基于“標記——清除”算法實現,整體過程分為4個步驟:
- 初始標記(CMS initial mark)
- 并發標記(CMS concurrent mark)
- 重新標記(CMS remark)
- 并發清除(CMS concurrent sweep)
其中初始標記、重新標記這兩個步驟仍然需要“Stop The World”。
初始標記僅僅只是標記一下GC Roots能直接關聯到的對象,速度很快,并發標記階段就是進行GC Roots Tracing的過程,而重新標記階段則是為了修正并發標記期間,因用戶程序繼續運作而導致標記產生變動的那一部分對象的標記記錄,這個階段的停頓時間一般會比初始標記階段稍長一些,但遠比并發標記的時間短。
CMS優缺點
CMS是一款優秀的收集器,它的最主要優點:并發收集、低停頓
Sun的一些官方文檔里面也稱之為并發低停頓收集器(Concurrent Low Pause Collector)。
但是CMS還遠達不到完美的程度,它有以下三個顯著的缺點
- CMS收集器對CPU資源非常敏感。
在并發階段,它雖然不會導致用戶線程停頓,但是會因為占用了一部分線程(或者說CPU資源)而導致應用程序變慢,總吞吐量會降低。CMS默認啟動的回收線程數是(CPU數量+3)/ 4,也就是當CPU在4個以上時,并發回收時垃圾收集線程最多占用不超過25%的CPU資源。但是當CPU不足4個時(譬如2個),那么CMS對用戶程序的影響就可能變得很大,如果CPU負載本來就比較大的時候,還分出一半的運算能力去執行收集器線程,就可能導致用戶程序的執行速度忽然降低了50%,這也很讓人受不了。
為了解決這種情況,VM提供了一種稱為“增量式并發收集器”(Incremental Concurrent Mark Sweep / i-CMS)的CMS收集器變種,所做的事情和單CPU年代PC機操作系統使用搶占式來模擬多任務機制的思想一樣,就是在并發標記和并發清理的時候讓GC線程、用戶線程交替運行,盡量減少GC線程的獨占資源的時間,這樣整個垃圾收集的過程會更長,但對用戶程序的影響就會顯得少一些,速度下降也就沒有那么明顯,但是目前版本中,i-CMS已經被聲明為“deprecated”,即不再提倡用戶使用。
- CMS收集器無法處理浮動垃圾(Floating Garbage),可能出現“Concurrent Mode Failure”失敗而導致另一次Full GC的產生。
由于CMS并發清理階段用戶線程還在運行著,伴隨程序的運行自然還會有新的垃圾不斷產生,這一部分垃圾出現在標記過程之后,CMS無法在本次收集中處理掉它們,只好留待下一次GC時再將其清理掉。這一部分垃圾就稱為“浮動垃圾”。
也是由于在垃圾收集階段用戶線程還需要運行,即還需要預留足夠的內存空間給用戶線程使用,因此CMS收集器不能像其他收集器那樣等到老年代幾乎完全被填滿了再進行收集,需要預留一部分空間提供并發收集時的程序運作使用。
在默認設置下,CMS收集器在老年代使用了68%的空間后就會被激活,這是一個偏保守的設置,如果在應用中老年代增長不是太快,可以適當調高參數-XX:CMSInitiatingOccupancyFraction的值來提高觸發百分比,以便降低內存回收次數以獲取更好的性能。
要是CMS運行期間預留的內存無法滿足程序需要,就會出現一次“Concurrent Mode Failure”失敗,這時候虛擬機將啟動后備預案:臨時啟用Serial Old收集器來重新進行老年代的垃圾收集,這樣停頓時間就很長了。所以說參數-XX:CMSInitiatingOccupancyFraction設置得太高將會很容易導致大量“Concurrent Mode Failure”失敗,性能反而降低。
- CMS是一款基于“標記-清除”算法實現的收集器,就可能想到這意味著收集結束時會產生大量空間碎片。
空間碎片過多時,將會給大對象分配帶來很大的麻煩,往往會出現老年代還有很大的空間剩余,但是無法找到足夠大的連續空間來分配當前對象,不得不提前觸發一次Full GC。
為了解決這個問題,CMS收集器提供了一個-XX:+UseCMSCompactAtFullCollection開關參數,用于在“享受”完Full GC服務之后額外免費附送一個碎片整理過程,內存整理的過程是無法并發的。空間碎片問題沒有了,但停頓時間不得不變長了。虛擬機設計者們還提供了另外一個參數-XX: CMSFullGCsBeforeCompaction,這個參數用于設置在執行多少次不壓縮的Full GC后,跟著來一次帶壓縮的。
G1
G1(Garbage-First)收集器是當今收集器技術發展的最前沿成果之一,早在JDK 1.7剛剛確立項目目標,Sun公司給出的JDK 1.7 RoadMap里面,它就被視為JDK 1.7中HotSpot虛擬機的一個重要進化特征。從JDK 6u14中開始就有Early Access版本的G1收集器供開發人員實驗、試用,由此開始G1收集器的“Experimental”狀態持續了數年時間,直至JDK 7u4,Sun公司才認為它達到足夠成熟的商用程度,移除了“Experimental”的標識。
G1是一款面向服務端應用的垃圾收集器。HotSpot開發團隊賦予它的使命是(在比較長期的)未來可以替換掉JDK 1.5中發布的CMS收集器。與其他GC收集器相比,G1具備如下特點:
- 并行與并發:G1能充分利用多CPU、多核環境下的硬件優勢,使用多個CPU(CPU或者CPU核心)來縮短Stop-The-World停頓的時間,部分其他收集器原本需要停頓Java線程執行的GC動作,G1收集器仍然可以通過并發的方式讓Java程序繼續執行。
- 分代收集:與其他收集器一樣,分代概念在G1中依然得以保留。雖然G1可以不需其他收集器配合就能獨立管理整個GC堆,但它能夠采用不同的方式去處理新創建的對象和已經存活了一段時間、熬過多次GC的舊對象以獲取更好的收集效果。
- 空間整合:與CMS的“標記-清理”算法不同,G1從整體看來是基于“標記-整理”算法實現的收集器,從局部(兩個Region之間)上看是基于“復制”算法實現,無論如何,這兩種算法都意味著G1運作期間不會產生內存空間碎片,收集后能提供規整的可用內存。這種特性有利于程序長時間運行,分配大對象時不會因為無法找到連續內存空間而提前觸發下一次GC。
- 可預測的停頓:這是G1相對于CMS的另外一大優勢,降低停頓時間是G1和CMS共同的關注點,但G1除了追求低停頓外,還能建立可預測的停頓時間模型,能讓使用者明確指定在一個長度為M毫秒的時間片段內,消耗在垃圾收集上的時間不得超過N毫秒,這幾乎已經是實時Java(RTSJ)的垃圾收集器特征了。
G1實現原理
使用G1收集器時,Java堆的內存布局與就與其他收集器有很大差別,它將整個Java堆劃分為多個大小相等的獨立區域(Region),雖然還保留有新生代和老年代的概念,但新生代和老年代不再是物理隔離的了,它們都是一部分Region(不需要連續)的集合。
G1收集器之所以能建立可預測的停頓時間模型,是因為它可以有計劃地避免在整個Java堆中進行全區域的垃圾收集。G1跟蹤各個Region里面的垃圾堆積的價值大小(回收所獲得的空間大小以及回收所需時間的經驗值),在后臺維護一個優先列表,每次根據允許的收集時間,優先回價值最大的Region(這也就是Garbage-First名稱的來由)。這種使用Region劃分內存空間以及有優先級的區域回收方式,保證了G1收集器在有限的時間內獲可以獲取盡可能高的收集效率。
G1實現難點
G1把內存“化整為零”的思路,理解起來似乎很容易,但其中的實現細節卻遠遠沒有想象中那樣簡單,否則也不會從2004年Sun實驗室發表第一篇G1的論文開始直到今天(將近10年時間)才開發出G1的商用版。
以一個細節為例:把Java堆分為多個Region后,垃圾收集是否就真的能以Region為單位進行了?聽起來順理成章,再仔細想想就很容易發現問題所在:Region不可能是孤立的。一個對象分配在某個Region中,它并非只能被本Region中的其他對象引用,而是可以與整個Java堆任意的對象發生引用關系。那在做可達性判定確定對象是否存活的時候,豈不是還得掃描整個Java堆才能保證準確性?這個問題其實并非在G1中才有,只是在G1中更加突出而已。在以前的分代收集中,新生代的規模一般都比老年代要小許多,新生代的收集也比老年代要頻繁許多,那回收新生代中的對象時也面臨相同的問題,如果回收新生代時也不得不同時掃描老年代的話,那么Minor GC的效率可能下降不少。
在G1收集器中,Region之間的對象引用以及其他收集器中的新生代與老年代之間的對象引用,VM都是使用Remembered Set來避免全堆掃描的。G1中每個Region都有一個與之對應的Remembered Set,虛擬機發現程序在對Reference類型的數據進行寫操作時,會產生一個Write Barrier暫時中斷寫操作,檢查Reference引用的對象是否處于不同的Region之中(在分代的例子中就是檢查是否老年代中的對象引用了新生代中的對象),如果是,便通過CardTable把相關引用信息記錄到被引用對象所屬的Region的Remembered Set之中。當進行內存回收時,在GC根節點的枚舉范圍中加入Remembered Set即可保證不對全堆掃描也不會有遺漏。
G1運作步驟
如果不計算維護Remembered Set的操作,G1收集器的運作大致可劃分為以下幾個步驟:
- 初始標記(Initial Marking)
- 并發標記(Concurrent Marking)
- 最終標記(Final Marking)
- 篩選回收(Live Data Counting and Evacuation)
對CMS收集器運作過程熟悉的,一定已經發現G1的前幾個步驟的運作過程和CMS有很多相似之處。
初始標記階段僅僅只是標記一下GC Roots能直接關聯到的對象,并且修改TAMS(Next Top at Mark Start)的值,讓下一階段用戶程序并發運行時,能在正確可用的Region中創建新對象,這階段需要停頓線程,但耗時很短。
并發標記階段是從GC Root開始對堆中對象進行可達性分析,找出存活的對象,這階段耗時較長,但可與用戶程序并發執行。而最終標記階段則是為了修正在并發標記期間因用戶程序繼續運作而導致標記產生變動的那一部分標記記錄,VM將這段時間對象變化記錄在線程Remembered Set Logs里面,最終標記階段需要把Remembered Set Logs的數據合并到Remembered Set中,這階段需要停頓線程,但是可并行執行。
最后在篩選回收階段首先對各個Region的回收價值和成本進行排序,根據用戶所期望的GC停頓時間來制定回收計劃,從Sun公司透露出來的信息來看,這個階段其實也可以做到與用戶程序一起并發執行,但是因為只回收一部分Region,時間是用戶可控制的,而且停頓用戶線程將大幅提高收集效率。
G1性能評測
由于目前G1成熟版本的發布時間還很短,G1收集器幾乎可以說還沒有經過實際應用的考驗,網絡上關于G1收集器的性能測試也非常貧乏,到目前為止,還沒有搜索到有關的生產環境下的性能測試報告。強調“生產環境下的測試報告”是因為對于垃圾收集器來說,僅僅通過簡單的Java代碼寫個Microbenchmark程序來創建、移除Java對象,再用-XX:+PrintGCDetails等參數來查看GC日志是很難做到準確衡量其性能的。
關于G1收集器的性能部分,引用了Sun實驗室的論文《Garbage-First Garbage Collection》中的一段測試數據。
Sun給出的Benchmark的執行硬件為Sun V880服務器(8×750MHz UltraSPARC III CPU、32G內存、Solaris 10操作系統)。執行軟件有兩個,分別為SPECjbb(模擬商業數據庫應用,堆中存活對象約為165MB,結果反映吐量和最長事務處理時間)和telco(模擬電話應答服務應用,堆中存活對象約為100MB,結果反映系統能支持的較大吞吐量)。為了便于對比,還收集了一組使用ParNew+CMS收集器的測試數據。所有測試都配置為與CPU數量相同的8條GC線程。
在反應停頓時間的軟實時目標(Soft Real-Time Goal)測試中,橫向是兩個測試軟件的時間片段配置,單位是毫秒,以(X/Y)的形式表示,代表在Y毫秒內較大允許GC時間為X毫秒(對于CMS收集器,無法直接指定這個目標,通過調整分代大小的方式大致模擬)。縱向是兩個軟件在對應配置和不同的Java堆容量下的測試結果,V%、avgV%和wV%分別代表的含義如下。
在反應停頓時間的軟實時目標(Soft Real-Time Goal)測試中,橫向是兩個測試軟件的時間片段配置,單位是毫秒,以(X/Y)的形式表示,代表在Y毫秒內較大允許GC時間為X毫秒(對于CMS收集器,無法直接指定這個目標,通過調整分代大小的方式大致模擬)。縱向是兩個軟件在對應配置和不同的Java堆容量下的測試結果,V%、avgV%和wV%分別代表的含義如下。
V%:表示測試過程中,軟實時目標失敗的概率,軟實時目標失敗即某個時間片段中實際GC時間超過了允許的較大GC時間。
avgV%:表示在所有實際GC時間超標的時間片段里,實際GC時間超過較大GC時間的平均百分比(實際GC時間減去允許較大GC時間,再除以總時間片段)。
wV%:表示在測試結果最差的時間片段里,實際GC時間占用執行時間的百分比。
從上表結果可見,對于telco來說,軟實時目標失敗的概率控制在0.5%~0.7%之間,SPECjbb就要差一些,但也控制在2%~5%之間,概率隨著(X/Y)的比值減小而增加。
另一方面,失敗時超出允許GC時間的比值隨著總時間片段增加而變小(分母變大了),在(100/200)、512MB的配置下,G1收集器出現了某些時間片段下100%時間在進行GC的最壞情況。而相比之下,CMS收集器的測試結果就要差很多,3種Java堆容量下都出現了100%時間進行GC的情況。
在吞吐量測試中,測試數據取3次SPECjbb和15次telco的平均結果如圖3-12所示。在SPECjbb的應用下,各種配置下的G1收集器表現出了一致的行為,吞吐量看起來只與允許較大GC時間成正比關系,而在telco的應用中,不同配置對吞吐量的影響則顯得很微弱。與CMS收集器的吞吐量對比可以看到,在SPECjbb測試中,在堆容量超過768MB時,CMS收集器有5%~10%的優勢,而在telco測試中,CMS的優勢則要小一些,只有3%~4%左右。
在更大規模的生產環境下,引用一段在StackOverflow.com上看到的經驗分享:
我在一個真實的、較大規模的應用程序中使用過G1:大約分配有6070GB內存,存活對象大約在2050GB之間。服務器運行Linux操作系統,JDK版本為6u22。G1與PS/PS Old相比,最大的好處是停頓時間更加可控、可預測,如果我在PS中設置一個很低的最大允許GC時間,譬如期望50毫秒內完成GC(-XX:MaxGCPauseMillis=50),但在65GB的Java堆下有可能得到的直接結果是一次長達30秒至2分鐘的漫長的Stop-The-World過程;
而G1與CMS相比,它們都立足于低停頓時間,CMS仍然是我現在的選擇,但是隨著Oracle對G1 的持續改進,我相信G1會是最終的勝利者。如果你現在采用的收集器沒有出現問題,那就沒有任何理由現在去選擇G1,如果你的應用追求低停頓,那G1現在已經可以作為一個可嘗試的選擇,如果你的應用追求吞吐量,那G1并不會為你帶來什么特別的好處。
理解GC日志
每個收集器的日志格式都可以不一樣。但VM設計者為了方便用戶閱讀,將各個收集器的日志都維護一定的個性。
1.GC發生的時間,其含義從JVM啟動以來經過的秒數。
2.說明了這次垃圾收集的停頓類型,而不是用來區分 新生代GC 還是 老年代GC的。如果有“Full”,說明這次GC是發生了Stop-The-World的。
例如,下面這段新生代收集器ParNew的日志也會出現“[Full GC”(這一般是因為出現了分配擔保失敗之類的問題,所以才導致STW)。如果是調用System.gc() 方法所觸發的收集,那么在這里將顯示“[Full GC(System)”
[Full GC 283.736:[ParNew:261599K->261599K(261952K), 0.0000288 secs]
3.表示GC發生的區域,這里顯示的區域名稱與使用的GC收集是密切相關的,例如上面樣例所使用的Serial收集器中的新生代名為“Default New Generation”,所以顯示的是“[DefNew”。
如果是ParNew收集器,新生代名稱就會變為“[ParNew”,意為“Parallel New Generation”。
如果采用Parallel Scavenge收集器,那它配套的新生代稱為“PSYoungGen”,老年代和永久代同理,名稱也是由收集器決定的。
4.后面方括號內部的“3324K->152K(3712K)”含義是“GC前該內存區域已使用容量->GC后該內存區域已使用容量(該內存區域總容量)”。
而在方括號之外的“3324K->152K(11904K)”表示“GC前Java堆已使用容量->GC后Java堆已使用容量(Java堆總容量)”
5.表示該內存區域GC所占用的時間,單位是秒。
有的收集器會給出更具體的時間數據,如“[Times:user=0.01 sys=0.00,real=0.02 secs]”,這里面的user、sys和real與Linux的time命令所輸出的時間含義一致,分別代表1.用戶態消耗的CPU時間、2.內核態消耗的CPU事件和3.操作從開始到結束所經過的墻鐘時間(Wall Clock Time)。
CPU時間與墻鐘時間的區別是,墻鐘時間包括各種非運算的等待耗時,例如等待磁盤I/O、等待線程阻塞,而CPU時間不包括這些耗時,但當系統有多CPU或者多核的話,多線程操作會疊加這些CPU時間,所以讀者看到user或sys時間超過real時間是完全正常的。
GC小結
名稱 | 描述 | 所處區域 | 可搭配合作的GC | 特點 | 相關參數(不完全) |
Serial | 單線程的收集器,垃圾收集時,需暫停所有工作線程 | Young 新生代 | CMS、Serial Old | 1.停掉所有工作線程進行垃圾收集,用戶體驗良好度下降 2.“雞肋” 3.簡單、高效,但在Client模式下的默認新生代收集器 | |
ParNew | Serial多線程版本 | CMS、Serial Old | ParNew在單CPU環境絕對不會有比Serial有更好的效果 | UseConcMarkSweepGC、UseParallelGC、ParallelGCThreads | |
Parallel Scavenge | “吞吐量優先”收集器,高吞吐量可高效地利用CPU時間 | Serial Old、Parallel Old | 擁有GC自適應調節策略 | UseAdaptiveSizePolicy、GCTimeRatio、MaxGCPauseMillis | |
Serial Old | Serial的老年代版本 | Tenured 老年代 | CMS、Serial、ParNew、Parallel Scavenge | 主要意義給Client模式的VM使用 | |
Parallel Old | Parallel Scavenge的老年代版本 | Parallel Scavenge | 與Parallel Scavenge組合使用,特別適合在注重吞吐量以及CPU資源敏感的場合 | ||
CMS(Concurrent Mark Sweep) | 一種以獲取最短回收停頓時間為目標的收集器。 | Serial、ParNew | 1.對CPU資源非常敏感 2.無法處理浮動垃圾 3.可能造成過多的空間碎片 | CMSInitiatingOccupancyFraction、UseCMSCompactAtFullCollection、CMSFullGCsBeforeCompaction | |
G1 | 當今收集器技術發展的最前沿成果之一。 | Young & Tenured | 無 | 初來乍到、能力有待考驗 |
垃圾收集器參數總結
參 數 | 描 述 |
---|---|
UseSerialGC | 虛擬機運行在Client 模式下的默認值,打開此開關后,使用Serial + Serial Old 的收集器組合進行內存回收 |
UseParNewGC | 打開此開關后,使用ParNew + Serial Old 的收集器組合進行內存回收 |
UseConcMarkSweepGC | 打開此開關后,使用ParNew + CMS + Serial Old 的收集器組合進行內存回收。Serial Old 收集器將作為CMS 收集器出現Concurrent Mode Failure失敗后的后備收集器使用 |
UseParallelGC | 虛擬機運行在Server 模式下的默認值,打開此開關后,使用Parallel Scavenge + Serial Old(PS MarkSweep)的收集器組合進行內存回收 |
UseParallelOldGC | 打開此開關后,使用Parallel Scavenge + Parallel Old 的收集器組合進行內存回收 |
SurvivorRatio | 新生代中Eden 區域與Survivor 區域的容量比值, 默認為8, 代表Eden :Survivor=8∶1 |
PretenureSizeThreshold | 直接晉升到老年代的對象大小,設置這個參數后,大于這個參數的對象將直接在老年代分配 |
MaxTenuringThreshold | 晉升到老年代的對象年齡。每個對象在堅持過一次Minor GC 之后,年齡就加1,當超過這個參數值時就進入老年代 |
UseAdaptiveSizePolicy | 動態調整Java 堆中各個區域的大小以及進入老年代的年齡 |
HandlePromotionFailure | 是否允許分配擔保失敗,即老年代的剩余空間不足以應付新生代的整個Eden 和Survivor 區的所有對象都存活的極端情況 |
ParallelGCThreads | 設置并行GC 時進行內存回收的線程數 |
GCTimeRatio | GC 時間占總時間的比率,默認值為99,即允許1% 的GC 時間。僅在使用Parallel Scavenge 收集器時生效 |
MaxGCPauseMillis | 設置GC 的最大停頓時間。僅在使用Parallel Scavenge 收集器時生效 |
CMSInitiatingOccupancyFraction | 設置CMS 收集器在老年代空間被使用多少后觸發垃圾收集。默認值為68%,僅在使用CMS 收集器時生效 |
UseCMSCompactAtFullCollection | 設置CMS 收集器在完成垃圾收集后是否要進行一次內存碎片整理。僅在使用CMS 收集器時生效 |
CMSFullGCsBeforeCompaction | 設置CMS 收集器在進行若干次垃圾收集后再啟動一次內存碎片整理。僅在使用CMS 收集器時生效 |
內存分配與回收策略
Java技術體系中所提倡的自動內存管理最終可以歸結為自動化地解決了兩個問題:
- 給對象分配內存
- 回收分配給對象的內存。
對象的內存分配,往大方向講,就是在堆上分配(但也可能經過JIT編譯后被拆散為標量類型并間接地棧上分配),對象主要分配在新生代的Eden區上,如果啟動了本地線程分配緩沖,將按線程優先在TLAB上分配。
少數情況下也可能會直接分配在老年代中,分配的規則并不是百分之百固定的,其細節取決于當前使用的是哪一種垃圾收集器組合,還有虛擬機中與內存相關的參數的設置。
接下來將會講解幾條最普遍的內存分配規則,并通過代碼去驗證這些規則。
本節下面的代碼在測試時使用Client模式虛擬機運行,沒有手工指定收集器組合,換句話說,驗證的是在使用Serial/Serial Old收集器下(ParNew/Serial Old收集器組合的規則也基本一致)的內存分配和回收的策略。
對象優先在Eden分配
大多數情況下,對象在新生代Eden區中分配。當Eden區沒有足夠空間進行分配時,虛擬機將發起一次Minor GC。
虛擬機提供了**-XX:+PrintGCDetails**這個收集器日志參數,告訴虛擬機在發生垃圾收集行為時打印內存回收日志,并且在進程退出的時候輸出當前的內存各區域分配情況。在實際應用中,內存回收日志一般是打印到文件后通過日志工具進行分析,不過本實驗的日志并不多,直接閱讀就能看得很清楚。
package com.lun.c03;/*** VM參數: -verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8*/
public class TestAllocation {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}public static void main(String[] args) {testAllocation();}}/* 在JDK1.8的運行結果,與書本的有些輸入[GC (Allocation Failure) [DefNew: 7295K->633K(9216K), 0.1036966 secs] 7295K->6777K(19456K), 0.1037849 secs] [Times: user=0.00 sys=0.09, real=0.10 secs]
Heapdef new generation total 9216K, used 4811K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)eden space 8192K, 51% used [0x00000000fec00000, 0x00000000ff014930, 0x00000000ff400000)from space 1024K, 61% used [0x00000000ff500000, 0x00000000ff59e668, 0x00000000ff600000)to space 1024K, 0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)tenured generation total 10240K, used 6144K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)the space 10240K, 60% used [0x00000000ff600000, 0x00000000ffc00030, 0x00000000ffc00200, 0x0000000100000000)Metaspace used 2791K, capacity 4486K, committed 4864K, reserved 1056768Kclass space used 298K, capacity 386K, committed 512K, reserved 1048576K*/
代碼的testAllocation()
方法中,嘗試分配3個2MB大小和1個4MB大小的對象,
在運行時通過-Xms20M、-Xmx20M、-Xmn10M
這3個參數限制了Java堆大小為20MB,不可擴展,其中10MB分配給新生代,剩下的10MB分配給老年代。
-XX:SurvivorRatio=8
決定了新生代中Eden區與一個Survivor區的空間比例是8:1,從輸出的結果也可以清晰地看到“eden space 8192K、from space 1024K、to space 1024K”的信息,新生代總可用空間為9216KB(Eden區+1個Survivor區的總容量)。
程序運行結果解釋1
執行testAllocation()
中分配allocation4對象的語句時會發生一次Minor GC,這次GC的結果是新生代6651KB變為148KB,而總內存占用量則幾乎沒有減少(因為allocation1、allocation2、allocation3三個對象都是存活的,VM幾乎沒有找到可回收的對象)。
這次GC發生的原因是給allocation4分配內存的時候,發現Eden已經被占用了6MB,剩余空間已不足以分配allocation4所需的4MB內存,因此發生Minor GC。GC期間虛擬機又發現已有的3個2MB大小的對象全部無法放入Survivor空間(Survivor空間只有1MB大小),所以只好通過分配擔保機制提前轉移到老年代去。
這次GC結束后,4MB的allocation4對象順利分配在Eden中,因此程序執行完的結果是Eden占用4MB(被allocation4占用),Survivor空閑,老年代被占用6MB(被allocation1、allocation2、allocation3占用)。通過GC日志可以證實這一點。
新生代GC與老年代GC區別
新生代GC(Minor GC):指發生在新生代的垃圾收集動作,因為Java對象大多都具備朝生夕滅的特性,所以Minor GC非常頻繁,一般回收速度也比較快。
**老年代GC(Major GC/Full GC):**指發生在老年代的GC,出現了Major GC,經常會伴隨至少一次的Minor GC(但非絕對的,在Parallel Scavenge收集器的收集策略里就有直接進行Major GC的策略選擇過程)。Major 的速度一般會比Minor GC慢10倍以上。
大對象直接進入老年代
大對象是指,需要大量連續內存空間的Java對象,最典型的大對象就是那種很長的字符串以及數組(列出的例子中的byte[]數組就是典型的大對象)。
大對象對VM的內存分配來說就是一個壞消息(替JVM抱怨一句,比遇到一個大對象更加壞的消息就是遇到一群“朝生夕滅”的“短命大對象”,寫程序的時候應當避免),經常出現大對象容易導致內存還有不少空間時就提前觸發垃圾收集以獲取足夠的連續空間來“安置”它們。
JVM提供了一個**-XX:PretenureSizeThreshold參數,令大于這個設置值的對象直接在老年代分配。這樣做的目的是避免在Eden區及兩個Survivor區之間發生大量的內存復制(新生代采用復制算法**收集內存)。
tenure/?teny?r/ v.give (someone) a permanent post, especially as a teacher or professor.
package com.lun.c03;/*** VM參數:-XX:+UseSerialGC -verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8* -XX:PretenureSizeThreshold=3145728*/
public class TestPretenureSizeThreshold {private static final int _1MB = 1024 * 1024;public static void testPretenureSizeThreshold() {byte[] allocation;allocation = new byte[4 * _1MB]; //直接分配在老年代中}public static void main(String[] args) {testPretenureSizeThreshold();}}/*
Heapdef new generation total 9216K, used 1315K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)eden space 8192K, 16% used [0x00000000fec00000, 0x00000000fed48d50, 0x00000000ff400000)from space 1024K, 0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)to space 1024K, 0% used [0x00000000ff500000, 0x00000000ff500000, 0x00000000ff600000)tenured generation total 10240K, used 4096K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)the space 10240K, 40% used [0x00000000ff600000, 0x00000000ffa00010, 0x00000000ffa00200, 0x0000000100000000)Metaspace used 2791K, capacity 4486K, committed 4864K, reserved 1056768Kclass space used 298K, capacity 386K, committed 512K, reserved 1048576K
*/
程序運行結果解分析2
執行代碼中的testPretenureSizeThreshold()方法后,我們看到Eden空間幾乎沒有被使用,而老年代的10MB空間被使用了40%,也就是4MB的allocation對象直接就分配在老年代中,這是因為PretenureSizeThreshold被設置為3MB(就是3145728,這個參數不能像-Xmx之類的參數一樣直接寫3MB),因此超過3MB的對象都會直接在老年代進行分配。
注意PretenureSizeThreshold參數只對Serial和ParNew兩款收集器有效,Parallel Scavenge收集器不認識這個參數,Parallel Scavenge收集器一般并不需要設置。如果遇到必須使用此參數的場合,可以考慮ParNew加CMS的收集器組合。
長期存活的對象將進入老年代
既然VM采用了分代收集的思想來管理內存,那么內存回收時就必須能識別哪些對象應放在新生代,哪些對象應放在老年代中。
為了做到這點,虛擬機給每個對象定義了一個對象年齡(Age)計數器。如果對象在Eden出生并經過第一次Minor GC后仍然存活,并且能被Survivor容納的話,將被移動到Survivor空間中,并且對象年齡設為1。對象在Survivor區中每“熬過”一次Minor GC,年齡就增加1歲,當它的年齡增加到一定程度(默認為15歲),就將會被晉升到老年代中。對象晉升老年代的年齡閾值,可以通過參數**-XX:MaxTenuringThreshold**設置。
package com.lun.c03;public class TestTenuringThreshold {private static final int _1MB = 1024 * 1024;/*** VM參數:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=1* -XX:+PrintTenuringDistribution*/@SuppressWarnings("unused")public static void testTenuringThreshold() {byte[] allocation1, allocation2, allocation3;allocation1 = new byte[_1MB / 4]; // 什么時候進入老年代決定于XX:MaxTenuringThreshold設置allocation2 = new byte[4 * _1MB];allocation3 = new byte[4 * _1MB];allocation3 = null;allocation3 = new byte[4 * _1MB];}public static void main(String[] args) {testTenuringThreshold();}}/* -XX:MaxTenuringThreshold=1[GC (Allocation Failure) [DefNew
Desired survivor size 524288 bytes, new threshold 1 (max 1)
- age 1: 911032 bytes, 911032 total
: 5503K->889K(9216K), 0.0127713 secs] 5503K->4985K(19456K), 0.0128544 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
[GC (Allocation Failure) [DefNew
Desired survivor size 524288 bytes, new threshold 1 (max 1)
: 4985K->0K(9216K), 0.0025695 secs] 9081K->4984K(19456K), 0.0026409 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
Heapdef new generation total 9216K, used 4178K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)eden space 8192K, 51% used [0x00000000fec00000, 0x00000000ff014930, 0x00000000ff400000)from space 1024K, 0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)to space 1024K, 0% used [0x00000000ff500000, 0x00000000ff500000, 0x00000000ff600000)tenured generation total 10240K, used 4984K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)the space 10240K, 48% used [0x00000000ff600000, 0x00000000ffade188, 0x00000000ffade200, 0x0000000100000000)Metaspace used 2791K, capacity 4486K, committed 4864K, reserved 1056768Kclass space used 298K, capacity 386K, committed 512K, reserved 1048576K
*//* -XX:MaxTenuringThreshold=15 //這與書上的不同[GC (Allocation Failure) [DefNew
Desired survivor size 524288 bytes, new threshold 1 (max 15)
- age 1: 911032 bytes, 911032 total
: 5503K->889K(9216K), 0.0067026 secs] 5503K->4985K(19456K), 0.0067924 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
[GC (Allocation Failure) [DefNew
Desired survivor size 524288 bytes, new threshold 15 (max 15)
: 4985K->0K(9216K), 0.0032538 secs] 9081K->4984K(19456K), 0.0032959 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
Heapdef new generation total 9216K, used 4178K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)eden space 8192K, 51% used [0x00000000fec00000, 0x00000000ff014930, 0x00000000ff400000)from space 1024K, 0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)to space 1024K, 0% used [0x00000000ff500000, 0x00000000ff500000, 0x00000000ff600000)tenured generation total 10240K, used 4984K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)the space 10240K, 48% used [0x00000000ff600000, 0x00000000ffade188, 0x00000000ffade200, 0x0000000100000000)Metaspace used 2791K, capacity 4486K, committed 4864K, reserved 1056768Kclass space used 298K, capacity 386K, committed 512K, reserved 1048576K*/
程序運行結果解分析3
分別以-XX:MaxTenuringThreshold=1和-XX:MaxTenuringThreshold=15兩種設置來執行代碼中的testTenuringThreshold()方法,此方法中的allocation1對象需要256KB內存,Survivor空間可以容納。
以MaxTenuringThreshold=1參數來運行的結果
當MaxTenuringThreshold=1時,allocation1對象在第二次GC發生時進入老年代,新生代已使用的內存GC后非常干凈地變成0KB。
以MaxTenuringThreshold=15參數來運行的結果
而MaxTenuringThreshold=15時,第二次GC發生后,allocation1對象則還留在新生代Survivor空間,這時新生代仍然有404KB被占用。
PS.在JDK1.8沒運行上述結果,反而運行出下一節的結果。
動態對象年齡判定
為了能更好地適應不同程序的內存狀況,VM并不是永遠地要求對象的年齡必須達到了MaxTenuringThreshold才能晉升老年代,如果在Survivor空間中相同年齡所有對象大小的總和大于Survivor空間的一半,年齡大于或等于該年齡的對象就可以直接進入老年代,無須等到MaxTenuringThreshold中要求的年齡。
程序運行結果解分析4
執行代碼的testTenuringThreshold2()方法,并設置-XX:
MaxTenuringThreshold=15,會發現運行結果中Survivor的空間占用仍然為0%,而老年代比預期增加了6%,也就是說,allocation1、allocation2對象都直接進入了老年代,而沒有等到15歲的臨界年齡。因為這兩個對象加起來已經到達了512KB,并且它們是同年的,滿足同年對象達到Survivor空間的一半規則。我們只要注釋掉其中一個對象new操作,就會發現另外一個就不會晉升到老年代中去了。
空間分配擔保
在發生Minor GC之前,虛擬機會先檢查老年代最大可用的連續空間是否大于 新生代所有對象總空間,如果這個條件成立,那么Minor GC可以確保是安全的。如果不成立,則VM會查看HandlePromotionFailure設置值是否允許擔保失敗。如果允許,那么會繼續檢查老年代最大可用的連續空間是否大于歷次晉升到老年代對象的平均大小,如果大于,將嘗試著進行一次Minor GC,盡管這次Minor GC是有風險的;如果小于,或者HandlePromotionFailure設置不允許冒險,那這時也要改為進行一次Full GC。
下面解釋一下“冒險”是冒了什么風險,前面提到過,新生代使用復制收集算法,但為了內存利用率,只使用其中一個Survivor空間來作為輪換備份,因此當出現大量對象在Minor GC后仍然存活的情況(最極端的情況就是內存回收后新生代中所有對象都存活),就需要老年代進行分配擔保,把Survivor無法容納的對象直接進入老年代。與生活中的貸款擔保類似,老年代要進行這樣的擔保,前提是老年代本身還有容納這些對象的剩余空間,一共有多少對象會活下來在實際完成內存回收之前是無法明確知道的,所以只好取之前每一次回收晉升到老年代對象容量的平均大小值作為經驗值,與老年代的剩余空間進行比較,決定是否進行Full GC來讓老年代騰出更多空間。
取平均值進行比較其實仍然是一種動態概率的手段,也就是說,如果某次Minor GC存活后的對象突增,遠遠高于平均值的話,依然會導致擔保失敗(Handle Promotion Failure)。如果出現了HandlePromotionFailure失敗,那就只好在失敗后重新發起一次Full GC。雖然擔保失敗時繞的圈子是最大的,但大部分情況下都還是會將HandlePromotionFailure開關打開,避免Full GC過于頻繁。
參見代碼,請在JDK 6 Update 24之前的版本中運行測試。
package com.lun.c03;/*** VM參數:-Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:-HandlePromotionFailure*/
public class TestHandlePromotion {private static final int _1MB = 1024 * 1024;@SuppressWarnings("unused")public static void testHandlePromotion() {byte[] allocation1, allocation2, allocation3, allocation4, allocation5, allocation6, allocation7;allocation1 = new byte[2 * _1MB];allocation2 = new byte[2 * _1MB];allocation3 = new byte[2 * _1MB];allocation1 = null;allocation4 = new byte[2 * _1MB];allocation5 = new byte[2 * _1MB];allocation6 = new byte[2 * _1MB];allocation4 = null;allocation5 = null;allocation6 = null;allocation7 = new byte[2 * _1MB];}public static void main(String[] args) {//請在JDK 6 Update 24之前的版本中運行測試}}
以HandlePromotionFailure=false參數來運行的結果:
以HandlePromotionFailure=true參數來運行的結果:
在JDK 6 Update 24之后,這個測試結果會有差異,HandlePromotionFailure參數不會再影響到虛擬機的空間分配擔保策略,觀察OpenJDK中的源碼變化(下面代碼),雖然源碼中還定義了HandlePromotionFailure參數,但是在代碼中已經不會再使用它。JDK 6 Update 24之后的規則變為只要老年代的連續空間大于新生代對象總大小或者歷次晉升的平均大小就會進行Minor GC,否則將進行Full GC。
HotSpot中空間分配檢查的代碼片段
bool TenuredGeneration:promotion_attempt_is_safe(size_t
max_promotion_in_bytes)const{//老年代最大可用的連續空間size_t available=max_contiguous_available();//每次晉升到老年代的平均大小size_t av_promo=(size_t)gc_stats()->avg_promoted()->padded_average();//老年代可用空間是否大于平均晉升大小,或者老年代可用空間是否大于當此GC時新生代所有對象容量bool res=(available>=av_promo)||(available>=max_promotion_in_bytes);return res;
}
本章小結
內存回收與垃圾收集器在很多時候都是影響系統性能、并發能力的主要因素之一,VM之所以提供多種不同的收集器以及提供大量的調節參數,是因為只有根據實際應用需求、實現方式選擇最優的收集方式才能獲取最高的性能。
沒有固定收集器、參數組合,也沒有最優的調優方法,VM也就沒有什么必然的內存回收行為。因此,學習VM內存知識,如果要到實踐調優階段,那么必須了解每個具體收集器的行為、優勢和劣勢、調節參數。