1. 程式人生 > >Java 分代收集演算法[imp]

Java 分代收集演算法[imp]

轉載: https://blog.csdn.net/mccand1234/article/details/52078645

摘要

當前商業虛擬機器的垃圾收集都採用“分代收集”(Generational Collection)演算法,這種演算法並沒有什麼新的思想,只是根據物件的存活週期的不同將記憶體劃分為幾塊。一般是把Java堆分為新生代和老年代,這樣就可以根據各個年代的特點採用最適當的收集演算法。在新生代中,每次垃圾收集時都發現有大批物件死去,只有少量存活,那就選用複製演算法,只需要付出少量存活物件的複製成本就可以完成收集。而老年代中因為物件存活率高、沒有額外空間對它進行分配擔保,就必須使用“標記-清理

”或“標記-整理”演算法來進行回收。方法區永久代,回收方法同老年代。

物件分類

分代蒐集演算法是針對物件的不同特性,而使用適合的演算法,這裡面並沒有實際上的新演算法產生。與其說分代蒐集演算法是第四個演算法,不如說它是對前三個演算法的實際應用。 
首先我們來探討一下物件的不同特性,接下來和各位來一起給這些物件選擇GC演算法。 
記憶體中的物件按照生命週期的長短大致可以分為三種,以下命名均為個人的命名。 
1、夭折物件(新生代):朝生夕滅的物件,通俗點講就是活不了多久就得死的物件。 
例子:某一個方法的局域變數、迴圈內的臨時變數等等。 
2、老不死物件(老年代):這類物件一般活的比較久,歲數很大還不死,但歸根結底,老不死物件也幾乎早晚要死的,但也只是幾乎而已。 
例子:快取物件、資料庫連線物件、單例物件(單例模式)

等等。 
3、不滅物件(永久代):此類物件一般一旦出生就幾乎不死了,它們幾乎會一直永生不滅,記得,只是幾乎不滅而已。 
例子:String池中的物件(享元模式)、載入過的類資訊等等。

物件對應的記憶體區域

還記得前面介紹記憶體管理時,JVM對記憶體的劃分嗎? 
我們將上面三種物件對應到記憶體區域當中,就是夭折物件和老不死物件都在JAVA堆,而不滅物件在方法區。 
之前的一章中我們就已經說過,對於JAVA堆,JVM規範要求必須實現GC,因而對於夭折物件和老不死物件來說,死幾乎是必然的結局,但也只是幾乎,還是難免會有一些物件會一直存活到應用結束。然而JVM規範對方法區的GC並不做要求,所以假設一個JVM實現沒有對方法區實現GC,那麼不滅物件就是真的不滅物件了。 
由於不滅物件的生命週期過長,因此分代蒐集演算法就是針對的JAVA堆而設計的,也就是針對夭折物件和老不死物件。

JAVA堆的物件回收(夭折物件和老不死物件)

有了以上分析,我們來看看分代蒐集演算法如何處理JAVA堆的記憶體回收的,也就是夭折物件與老不死物件的回收。 
夭折物件:這類物件朝生夕滅,存活時間短,還記得複製演算法的使用要求嗎?那就是物件存活率不能太高,因此夭折物件是最適合使用複製演算法的。 
小疑問:50%記憶體的浪費怎麼辦? 
答疑:因為夭折物件一般存活率較低,因此可以不使用50%的記憶體作為空閒,一般的,複製演算法:使用兩塊10%的記憶體作為空閒和活動區間,而另外80%的記憶體,則是用來給新建物件分配記憶體的。一旦發生GC,將10%的活動區間與另外80%中存活的物件轉移到10%的空閒區間,接下來,將之前90%的記憶體全部釋放,以此類推。 
GC流程: 


這裡寫圖片描述

 

 


這裡寫圖片描述

 

圖中標註了三個區域中在各個階段,各自記憶體的情況。相信看著圖,它的GC流程已經不難理解了。 
第一點是使用這樣的方式,我們只浪費了10%的記憶體,這個是可以接受的,因為我們換來了記憶體的整齊排列與GC速度。第二點是,這個策略的前提是,每次存活的物件佔用的記憶體不能超過這10%的大小,一旦超過,多出的物件將無法複製。 
為了解決上面的意外情況,也就是存活物件佔用的記憶體太大時的情況,高手們將JAVA堆分成兩部分來處理,上述三個區域則是第一部分,稱為新生代或者年輕代。而餘下的一部分,專門存放老不死物件的則稱為年老代。 
是不是很貼切的名字呢?下面我們看看老不死物件的處理方式。 
老不死物件:這一類物件存活率非常高,因為它們大多是從新生代轉過來的。就像人一樣,活的年月久了,就變成老不死了。 
通常情況下,以下兩種情況發生的時候,物件會從新生代區域轉到年老帶區域。 
1. 在新生代裡的每一個物件,都會有一個年齡,當這些物件的年齡到達一定程度時(年齡就是熬過的GC次數,每次GC如果物件存活下來,則年齡加1),則會被轉到年老代,而這個轉入年老代的年齡值,一般在JVM中是可以設定的。 
2. 在新生代存活物件佔用的記憶體超過10%時,則多餘的物件會放入年老代。這種時候,年老代就是新生代的“備用倉庫”。 
針對老不死物件的特性,顯然不再適合使用複製演算法,因為它的存活率太高,而且不要忘了,如果年老代再使用複製演算法,它可是沒有備用倉庫的。因此一般針對老不死物件只能採用標記/整理或者標記/清除演算法。 
以上兩種情況已經解決了GC的大部分問題,因為JAVA堆是GC的主要關注物件,而以上也已經包含了分代蒐集演算法的全部內容,接下來對於不滅物件的回收,已經不屬於分代蒐集演算法的內容。

方法區的物件回收(不滅物件)

不滅物件存在於方法區,在我們常用的hotspot虛擬機器(JDK預設的JVM)中,方法區也被親切的稱為永久代,又是一個很貼切的名字不是嗎? 
其實在很久很久以前,是不存在永久代的。當時永久代與年老代都存放在一起,裡面包含了JAVA類的例項資訊以及類資訊。但是後來發現,對於類資訊的解除安裝幾乎很少發生,因此便將二者分離開來。幸運的是,這樣做確實提高了不少效能。於是永久代便被拆分出來了。 
這一部分割槽域的GC與年老代採用相似的方法,由於都沒有“備用倉庫”,二者都是隻能使用標記/清除和標記/整理演算法。

回收的時機

JVM在進行GC時,並非每次都對上面三個記憶體區域一起回收的,大部分時候回收的都是指新生代。因此GC按照回收的區域又分了兩種型別,一種是普通GC(minor GC),一種是全域性GC(major GC or Full GC),它們所針對的區域如下。 
普通GC(minor GC):只針對新生代區域的GC。 
全域性GC(major GC or Full GC):針對所有分代區域(新生代、年老代)的GC。 
由於年老代與永久代相對來說GC效果不好,而且二者的記憶體使用增長速度也慢,因此一般情況下,需要經過好幾次普通GC,才會觸發一次全域性GC

Java記憶體分配機制

這裡所說的記憶體分配,主要指的是在堆上的分配,一般的,物件的記憶體分配都是在堆上進行,但現代技術也支援將物件拆成標量型別(標量型別即原子型別,表示單個值,可以是基本型別或String等),然後在棧上分配,在棧上分配的很少見,我們這裡不考慮。 
 Java記憶體分配和回收的機制概括的說,就是:分代分配,分代回收。物件將根據存活的時間被分為:年輕代(Young Generation)、年老代(Old Generation)、永久代(Permanent Generation,也就是方法區)。如下圖 
  這裡寫圖片描述 
     
  年輕代(Young Generation):物件被建立時,記憶體的分配首先發生在年輕代(大物件可以直接 被建立在年老代),大部分的物件在建立後很快就不再使用,因此很快變得不可達,於是被年輕代的GC機制清理掉(IBM的研究表明,98%的物件都是很快消 亡的),這個GC機制被稱為Minor GC或叫Young GC。注意,Minor GC並不代表年輕代記憶體不足,它事實上只表示在Eden區上的GC。

  年輕代上的記憶體分配是這樣的,年輕代可以分為3個區域:Eden區(伊甸園,亞當和夏娃偷吃禁果生娃娃的地方,用來表示記憶體首次分配的區域,再貼切不過)和兩個存活區(Survivor 0 、Survivor 1)。記憶體分配過程為 
  這裡寫圖片描述

絕大多數剛建立的物件會被分配在Eden區,其中的大多數物件很快就會消亡。Eden區是連續的記憶體空間,因此在其上分配記憶體極快; 
當Eden區滿的時候,執行Minor GC,將消亡的物件清理掉,並將剩餘的物件複製到一個存活區Survivor0(此時,Survivor1是空白的,兩個Survivor總有一個是空白的); 
此後,每次Eden區滿了,就執行一次Minor GC,並將剩餘的物件都新增到Survivor0; 
當Survivor0也滿的時候,將其中仍然活著的物件直接複製到Survivor1,以後Eden區執行Minor GC後,就將剩餘的物件新增Survivor1(此時,Survivor0是空白的)。 
當兩個存活區切換了幾次(HotSpot虛擬機器預設15次,用-XX:MaxTenuringThreshold控制,大於該值進入老年代)之後,仍然存活的物件(其實只有一小部分,比如,我們自己定義的物件),將被複制到老年代。 
  從上面的過程可以看出,Eden區是連續的空間,且Survivor總有一個為空。經過一次GC和複製,一個Survivor中儲存著當前還活 著的物件,而Eden區和另一個Survivor區的內容都不再需要了,可以直接清空,到下一次GC時,兩個Survivor的角色再互換。因此,這種方 式分配記憶體和清理記憶體的效率都極高,這種垃圾回收的方式就是著名的“停止-複製(Stop-and-copy)”清理法(將Eden區和一個Survivor中仍然存活的物件拷貝到另一個Survivor中),這不代表著停止複製清理法很高效,其實,它也只在這種情況下高效,如果在老年代採用停止複製,則挺悲劇的。

  在Eden區,HotSpot虛擬機器使用了兩種技術來加快記憶體分配。分別是bump-the-pointer和TLAB(Thread- Local Allocation Buffers),這兩種技術的做法分別是:由於Eden區是連續的,因此bump-the-pointer技術的核心就是跟蹤最後建立的一個物件,在對 象建立時,只需要檢查最後一個物件後面是否有足夠的記憶體即可,從而大大加快記憶體分配速度;而對於TLAB技術是對於多執行緒而言的,將Eden區分為若干 段,每個執行緒使用獨立的一段,避免相互影響。TLAB結合bump-the-pointer技術,將保證每個執行緒都使用Eden區的一段,並快速的分配內 存。 
  年老代(Old Generation):物件如果在年輕代存活了足夠長的時間而沒有被清理掉(即在幾次 Young GC後存活了下來),則會被複制到年老代,年老代的空間一般比年輕代大,能存放更多的物件,在年老代上發生的GC次數也比年輕代少。當年老代記憶體不足時, 將執行Major GC,也叫 Full GC。   
  可以使用-XX:+UseAdaptiveSizePolicy開關來控制是否採用動態控制策略,如果動態控制,則動態調整Java堆中各個區域的大小以及進入老年代的年齡。 
  如果物件比較大(比如長字串或大陣列),Young空間不足,則大物件會直接分配到老年代上(大物件可能觸發提前GC,應少用,更應避免使用短命的大物件)。用-XX:PretenureSizeThreshold來控制直接升入老年代的物件大小,大於這個值的物件會直接分配在老年代上。 
 可能存在年老代物件引用新生代物件的情況,如果需要執行Young GC,則可能需要查詢整個老年代以確定是否可以清理回收,這顯然是低效的。解決的方法是,年老代中維護一個512 byte的塊——”card table“,所有老年代物件引用新生代物件的記錄都記錄在這裡。Young GC時,只要查這裡即可,不用再去查全部老年代,因此效能大大提高。

Java GC機制

年輕代:

  事實上,在上一節,已經介紹了新生代的主要垃圾回收方法,在新生代中,使用“停止-複製”演算法進行清理,將新生代記憶體分為2部分,1部分 Eden區較大,1部分Survivor比較小,並被劃分為兩個等量的部分。每次進行清理時,將Eden區和一個Survivor中仍然存活的物件拷貝到 另一個Survivor中,然後清理掉Eden和剛才的Survivor。 
  這裡也可以發現,停止複製演算法中,用來複制的兩部分並不總是相等的(傳統的停止複製演算法兩部分記憶體相等,但新生代中使用1個大的Eden區和2個小的Survivor區來避免這個問題)

  由於絕大部分的物件都是短命的,甚至存活不到Survivor中,所以,Eden區與Survivor的比例較大,HotSpot預設是 8:1,即分別佔新生代的80%,10%,10%。如果一次回收中,Survivor+Eden中存活下來的記憶體超過了10%,則需要將一部分物件分配到 老年代。用-XX:SurvivorRatio引數來配置Eden區域Survivor區的容量比值,預設是8,代表Eden:Survivor1:Survivor2=8:1:1.

老年代:

老年代儲存的物件比年輕代多得多,而且不乏大物件,對老年代進行記憶體清理時,如果使用停止-複製演算法,則相當低效。一般,老年代用的演算法是標記-整理演算法,即:標記出仍然存活的物件(存在引用的),將所有存活的物件向一端移動,以保證記憶體的連續。 
在發生Minor GC時,虛擬機器會檢查每次晉升進入老年代的大小是否大於老年代的剩餘空間大小,如果大於,則直接觸發一次Full GC,否則,就檢視是否設 置了-XX:+HandlePromotionFailure(允許擔保失敗),如果允許,則只會進行MinorGC,此時可以容忍記憶體分配失敗;如果不 允許,則仍然進行Full GC(這代表著如果設定-XX:+Handle PromotionFailure,則觸發MinorGC就會同時觸發Full GC,哪怕老年代還有很多記憶體,所以,最好不要這樣做)。

方法區(永久代):

  永久代的回收有兩種:常量池中的常量,無用的類資訊,常量的回收很簡單,沒有引用了就可以被回收。對於無用的類進行回收,必須保證3點: 
類的所有例項都已經被回收 
載入類的ClassLoader已經被回收 
類物件的Class物件沒有被引用(即沒有通過反射引用該類的地方) 
永久代的回收並不是必須的,可以通過引數來設定是否對類進行回收。HotSpot提供-Xnoclassgc進行控制 
使用-verbose,-XX:+TraceClassLoading、-XX:+TraceClassUnLoading可以檢視類載入和解除安裝資訊 
-verbose、-XX:+TraceClassLoading可以在Product版HotSpot中使用; 
-XX:+TraceClassUnLoading需要fastdebug版HotSpot支援 
垃圾收集器

引數 內容
-Xms 初始堆大小。如:-Xms256m
-Xmx 最大堆大小。如:-Xmx512m
-Xmn 新生代大小。通常為 Xmx 的 1/3 或 1/4。新生代 = Eden + 2 個 Survivor 空間。實際可用空間為 = Eden + 1 個 Survivor,即 90%
-Xss JDK1.5+ 每個執行緒堆疊大小為 1M,一般來說如果棧不是很深的話, 1M 是絕對夠用了的。
-XX:NewRatio 新生代與老年代的比例,如 –XX:NewRatio=2,則新生代佔整個堆空間的1/3,老年代佔2/3
-XX:SurvivorRatio 新生代中 Eden(8) 與 Survivor(1+1) 的比值。預設值為 8。即 Eden 佔新生代空間的 8/10,另外兩個 Survivor 各佔 1/10
-XX:PermSize 永久代(方法區)的初始大小
-XX:MaxPermSize 永久代(方法區)的最大值
-XX:+PrintGCDetails 列印 GC 資訊
-XX:+HeapDumpOnOutOfMemoryError 讓虛擬機器在發生記憶體溢位時 Dump 出當前的記憶體堆轉儲快照,以便分析用

垃圾收集器

在GC機制中,起重要作用的是垃圾收集器,垃圾收集器是GC的具體實現,Java虛擬機器規範中對於垃圾收集器沒有任何規定,所以不同廠商實現的垃圾 收集器各不相同,HotSpot 1.6版使用的垃圾收集器如下圖(圖來源於《深入理解Java虛擬機器:JVM高階特效與最佳實現》,圖中兩個收集器之間有連線,說明它們可以配合使用): 
這裡寫圖片描述

在介紹垃圾收集器之前,需要明確一點,就是在新生代採用的停止複製演算法中,“停 止(Stop-the-world)”的意義是在回收記憶體時,需要暫停其他所 有執行緒的執行。這個是很低效的,現在的各種新生代收集器越來越優化這一點,但仍然只是將停止的時間變短,並未徹底取消停止。

Serial收集器:新生代收集器,使用停止複製演算法,使用一個執行緒進行GC,其它工作執行緒暫停。使用-XX:+UseSerialGC可以使用Serial+Serial Old模式執行進行記憶體回收(這也是虛擬機器在Client模式下執行的預設值) 
ParNew收集器:新生代收集器,使用停止複製演算法,Serial收集器的多執行緒版,用多個執行緒進行GC,其它工作執行緒暫停,關注縮短垃圾收集時間。使用-XX:+UseParNewGC開關來控制使用ParNew+Serial Old收集器組合收集記憶體;使用-XX:ParallelGCThreads來設定執行記憶體回收的執行緒數。 
Parallel Scavenge 收集器:新生代收集器,使用停止複製演算法,關注CPU吞吐量,即執行使用者程式碼的時間/總時間,比如:JVM執行100分鐘,其中執行使用者程式碼99分鐘,垃 圾收集1分鐘,則吞吐量是99%,這種收集器能最高效率的利用CPU,適合執行後臺運算(關注縮短垃圾收集時間的收集器,如CMS,等待時間很少,所以適 合用戶互動,提高使用者體驗)。使用-XX:+UseParallelGC開關控制使用 Parallel Scavenge+Serial Old收集器組合回收垃圾(這也是在Server模式下的預設值);使用-XX:GCTimeRatio來設定使用者執行時間佔總時間的比例,預設99,即 1%的時間用來進行垃圾回收。使用-XX:MaxGCPauseMillis設定GC的最大停頓時間(這個引數只對Parallel Scavenge有效) 
Serial Old收集器:老年代收集器,單執行緒收集器,使用標記整理(整理的方法是Sweep(清理)和Compact(壓縮),清理是將廢棄的物件幹掉,只留倖存 的物件,壓縮是將移動物件,將空間填滿保證記憶體分為2塊,一塊全是物件,一塊空閒)演算法,使用單執行緒進行GC,其它工作執行緒暫停(注意,在老年代中進行標 記整理演算法清理,也需要暫停其它執行緒),在JDK1.5之前,Serial Old收集器與ParallelScavenge搭配使用。 
Parallel Old收集器:老年代收集器,多執行緒,多執行緒機制與Parallel Scavenge差不錯,使用標記整理(與Serial Old不同,這裡的整理是Summary(彙總)和Compact(壓縮),彙總的意思就是將倖存的物件複製到預先準備好的區域,而不是像Sweep(清 理)那樣清理廢棄的物件)演算法,在Parallel Old執行時,仍然需要暫停其它執行緒。Parallel Old在多核計算中很有用。Parallel Old出現後(JDK 1.6),與Parallel Scavenge配合有很好的效果,充分體現Parallel Scavenge收集器吞吐量優先的效果。使用-XX:+UseParallelOldGC開關控制使用Parallel Scavenge +Parallel Old組合收集器進行收集。 
CMS(Concurrent Mark Sweep)收集器:老年代收集器,致力於獲取最短回收停頓時間,使用標記清除演算法,多執行緒,優點是併發收集(使用者執行緒可以和GC執行緒同時工作),停頓小。使用-XX:+UseConcMarkSweepGC進行ParNew+CMS+Serial Old進行記憶體回收,優先使用ParNew+CMS(原因見後面),當用戶執行緒記憶體不足時,採用備用方案Serial Old收集。 
CMS收集的方法是:先3次標記,再1次清除,3次標記中前兩次是初始標記和重新標記(此時仍然需要停止(stop the world)), 初始標記(Initial Remark)是標記GC Roots能關聯到的物件(即有引用的物件),停頓時間很短;併發標記(Concurrent remark)是執行GC Roots查詢引用的過程,不需要使用者執行緒停頓;重新標記(Remark)是在初始標記和併發標記期間,有標記變動的那部分仍需要標記,所以加上這一部分 標記的過程,停頓時間比並發標記小得多,但比初始標記稍長。在完成標記之後,就開始併發清除,不需要使用者執行緒停頓。 
所以在CMS清理過程中,只有初始標記和重新標記需要短暫停頓,併發標記和併發清除都不需要暫停使用者執行緒,因此效率很高,很適合高互動的場合。 
CMS也有缺點,它需要消耗額外的CPU和記憶體資源,在CPU和記憶體資源緊張,CPU較少時,會加重系統負擔(CMS預設啟動執行緒數為(CPU數量+3)/4)。 
另外,在併發收集過程中,使用者執行緒仍然在執行,仍然產生記憶體垃圾,所以可能產生“浮動垃圾”,本次無法清理,只能下一次Full GC才清理,因此在GC期間,需要預留足夠的記憶體給使用者執行緒使用。所以使用CMS的收集器並不是老年代滿了才觸發Full GC,而是在使用了一大半(預設68%,即2/3,使用-XX:CMSInitiatingOccupancyFraction來設定)的時候就要進行Full GC,如果使用者執行緒消耗記憶體不是特別大,可以適當調高-XX:CMSInitiatingOccupancyFraction以降低GC次數,提高效能,如果預留的使用者執行緒記憶體不夠,則會觸發Concurrent Mode Failure,此時,將觸發備用方案:使用Serial Old 收集器進行收集,但這樣停頓時間就長了,因此-XX:CMSInitiatingOccupancyFraction不宜設的過大。 
還有,CMS採用的是標記清除演算法,會導致記憶體碎片的產生,可以使用-XX:+UseCMSCompactAtFullCollection來設定是否在Full GC之後進行碎片整理,用-XX:CMSFullGCsBeforeCompaction來設定在執行多少次不壓縮的Full GC之後,來一次帶壓縮的Full GC。

G1收集器:在JDK1.7中正式釋出,與現狀的新生代、老年代概念有很大不同,目前使用較少,不做介紹。 
注意併發(Concurrent)和並行(Parallel)的區別: 

1. 併發是指使用者執行緒與GC執行緒同時執行(不一定是並行,可能交替,但總體上是在同時執行的),不需要停頓使用者執行緒(其實在CMS中使用者執行緒還是需要停頓的,只是非常短,GC執行緒在另一個CPU上執行); 
2. 並行收集是指多個GC執行緒並行工作,但此時使用者執行緒是暫停的;
 
所以,Serial和Parallel收集器都是並行的,而CMS收集器是併發的.

參考: 
http://www.it165.net/pro/html/201501/32890.html 
http://blog.csdn.net/ochangwen/article/details/51407574 
http://blog.csdn.net/ochangwen/article/details/51407167 
http://www.cnblogs.com/hnrainll/archive/2013/11/06/3410042.html