一、概述
GC要完成3件事:
- 哪些記憶體需要回收?
- 什麼時候回收?
- 如何回收?
1、兩種算法
1、引用計數算法
給對象添加一個引用計數器,每當有一個地方引用它時,計數器的值就加1;當引用失效時,計數器的值就減1;任何時刻計數器為0的對象就是不可能再被使用的。它非常高效,但是怒能解決對象間互相引用的問題。例如如下的例子:
public class RefenceCountingGC {
public Object instance = null;
public static void main(String[] args) {
RefenceCountingGC a = new RefenceCountingGC();
RefenceCountingGC b = new RefenceCountingGC();
a.instance = b;
b.instance =a ;
a = null;
b = null;
System.gc();
}
}
a和b兩個對象已經不能再被通路了,但因為他們互相引用着對方,導緻它們的引用計數器不為0。
2、可達性算法
Java中使用可達性分析( Reachability Analysis) 來判定對象是否存活的。
通過一系列的稱為“GC Roots”的對象作為起始點,從這些節點開始向下搜尋,搜尋所走過的 路徑稱為引用鍊( Reference Chain) ,當一個對象到GC Roots沒有任何引用鍊相連時,則證明此對象是不可用的。
在Java語言中,可作為GC Roots的對象包括下面幾種:
- 虛拟機棧( 棧幀中的本地變量表) 中引用的對象。
- 方法區中類靜态屬性引用的對象。
- 方法區中常量引用的對象。
- 本地方法棧中JNI( 即一般說的Native方法) 引用的對象。
2、引用的分類
引用分為強引用( Strong Reference) 、軟引用( Soft Reference) 、弱引用( Weak Reference) 、虛引用( Phantom Reference) 4種,這4種引用強度依次逐漸減弱。
- 強引用就是指在程式代碼之中普遍存在的,類似“Object obj = new Object()”這類的引 用,隻要強引用還存在,垃圾收集器永遠不會回收掉被引用的對象。
- 軟引用是用來描述一些還有用但并非必需的對象。對于軟引用關聯着的對象,在系統将 要發生記憶體溢出異常之前,将會把這些對象列進回收範圍之中進行第二次回收。如果這 次回收還沒有足夠的記憶體,才會抛出記憶體溢出異常。在JDK 1.2之後,提供了 SoftReference類來實作軟引用。
- 弱引用也是用來描述非必需對象的,但是它的強度比軟引用更弱一些,被弱引用關聯的 對象隻能生存到下一次垃圾收集發生之前。當垃圾收集器工作時,無論目前記憶體是否足 夠,都會回收掉隻被弱引用關聯的對象。在JDK 1.2之後,提供了WeakReference類來實作弱引用。
- 虛引用也稱為幽靈引用或者幻影引用,它是最弱的一種引用關系。一個對象是否有虛引 用的存在,完全不會對其生存時間構成影響,也無法通過虛引用來取得一個對象執行個體。 為一個對象設定虛引用關聯的唯一目的就是能在這個對象被收集器回收時收到一個系統 通知。在JDK 1.2之後,提供了PhantomReference類來實作虛引用。
3、方法區的回收
方法區一般可以不回收,回收效率很低。在堆中,新生代的垃圾收集效率70%-90%,而永久代的垃圾回收效率遠低于此。
永久代的垃圾回收主要回收兩部分内容:廢棄常量和無用的類。“廢棄常量”判斷比較簡單,但 是“無用的類”的判斷複雜一些,需要滿足下面3個條件:
- 該類所有的執行個體都已經被回收,也就是java堆中不存在該類的任何執行個體。
- 加載該類的ClassLoader已經被回收
- 該類對應的Class對象沒有在任何地方被引用,無法在任何地方通過反射通路該類的方法。
二、垃圾收集算法
1、标記-清除算法
标記-清除(Mark-Sweep)算法分為标記和清除兩個階段:首先标記出所有需要回收的對象,在标記完成後統一回收所 有被标記的對象,它的标記過程就是使用可達性算法進行标記的。
主要缺點有兩個:
- 效率問題,标記和清除兩個過程的效率都不高
- 空間問題,标記清除之後會産生大量不連續的記憶體碎片
2、複制算法
複制(Copying)算法是為了解決标記-清除算法産生的那些碎片。
首先将記憶體分為大小相等的兩部分(假設A、B兩部分),每次呢隻使用其中的一部分(這裡我們假設為A區),等這部分用完了,這時候就将這裡面還能活下來的對象複制到另一部分記憶體(這裡設為B區)中,然後把A區中的剩下部分全部清理掉。
HotSpot虛拟機将記憶體分為一塊較大的Eden空間和兩塊較小的Survivor空間(預設占比是8:1:1),每次使用Eden和其中一塊Survivor,當回首時,将Eden和Survivor中還存活着的對象一次性複制到另外一塊Survivor上,最後清理掉剛才的Eden和Survivor空間。如果還存活的對象站的記憶體空間多餘10%,就需要依賴其他記憶體空間(老年代)了。
3、标記-整理算法
标記整理算法( Mark-Compact) ,标記過程仍然和“标記-清除”一樣,但後續不走不是直接對 可回收對象進行清理,而是讓所有存活對象向一端移動,然後直接清理掉端邊界以外的記憶體。
4、分代收集算法
根據對象存活周期的不同将記憶體分為幾塊。一般把Java堆分為新生代和老年代,根據各個年 代的特點采用最合适的收集算法。在新生代中,每次垃圾收集時有大批對象死去,隻有少量 存活,可以選用複制算法。而老年代對象存活率高,使用标記清理或者标記整理算法。
三、垃圾收集器
1、Serial收集器
Serial(串行)垃圾收集器是最基本、發展曆史最悠久的收集器;JDK1.3.1前是HotSpot新生代收集的唯一選擇;
Serial收集器是單線程收集器,是分代收集器。它進行垃圾收集時,必須暫停其他所有的工作 線程,直到它收集結束。
新生代:單線程複制收集算法;老年代:單線程标記整理算法。
應用場景
依然是HotSpot在Client模式下預設的新生代收集器;
也有優于其他收集器的地方:
簡單高效(與其他收集器的單線程相比);
對于限定單個CPU的環境來說,Serial收集器沒有線程互動(切換)開銷,可以獲得最高的單線程收集效率;
在使用者的桌面應用場景中,可用記憶體一般不大(幾十M至一兩百M),可以在較短時間内完成垃圾收集(幾十MS至一百多MS),隻要不頻繁發生,這是可以接受的
設定參數
“-XX:+UseSerialGC”:添加該參數來顯式的使用串行垃圾收集器;
2、ParNew收集器
ParNew垃圾收集器是Serial收集器的多線程版本。
Parallel收集器和Serial收集器的主要差別是新生代的收集, 一個是單線程一個是多線程。 老年代的收集和Serial收集器是一樣的。
應用場景
在Server模式下,ParNew收集器是一個非常重要的收集器,因為除Serial外,目前隻有它能與CMS收集器配合工作;
但在單個CPU環境中,不會比Serail收集器有更好的效果,因為存線上程互動開銷。
設定參數
“-XX:+UseConcMarkSweepGC”:指定使用CMS後,會預設使用ParNew作為新生代收集器;
“-XX:+UseParNewGC”:強制指定使用ParNew;
“-XX:ParallelGCThreads”:指定垃圾收集的線程數量,ParNew預設開啟的收集線程與CPU的數量相同;
3、Parallel Scavenge收集器
一個新生代收集器,使用複制算法的收集器,又是并行( 使用者線程阻塞) 的多線程收集器。
目标是達到一個可控制的吞吐量。
吞吐量=運作使用者代碼時間/(運作使用者代碼時間+垃圾收集時間)
應用場景
高吞吐量為目标,即減少垃圾收集時間,讓使用者代碼獲得更長的運作時間;
當應用程式運作在具有多個CPU上,對暫停時間沒有特别高的要求時,即程式主要在背景進行計算,而不需要與使用者進行太多互動;
例如,那些執行批量處理、訂單處理、工資支付、科學計算的應用程式;
設定參數
Parallel Scavenge收集器提供兩個參數用于精确控制吞吐量:
“-XX:MaxGCPauseMillis”
控制最大垃圾收集停頓時間,大于0的毫秒數;
MaxGCPauseMillis設定得稍小,停頓時間可能會縮短,但也可能會使得吞吐量下降;
因為可能導緻垃圾收集發生得更頻繁;
“-XX:GCTimeRatio”
設定垃圾收集時間占總時間的比率,0
4、Serial Old收集器
Serial Old是Serial收集器的老年代版本,它同樣是單線程的。使用“标記-整理”算法。
5、Parallel Old收集器
Parallel old是Parallel Scavenge收集器的老年代版本,使用多線程和“标記-整理”算法。這個 收集器是在JDK1.6中才開始提供的。
應用場景
JDK1.6及之後用來代替老年代的Serial Old收集器;
特别是在Server模式,多CPU的情況下;
在注重吞吐量以及CPU資源敏感的場景,可以優先考慮Parallel Scavenge加Parallel Old收集器。
設定參數
“-XX:+UseParallelOldGC”:指定使用Parallel Old收集器;
6、CMS收集器
并發标記清理(Concurrent Mark Sweep,CMS)收集器也稱為并發低停頓收集器(Concurrent Low Pause Collector)或低延遲(low-latency)垃圾收集器;
cms是針對老年代,基于”标記-清除”算法,以擷取最短回收停頓時間為目标的垃圾收集器;
是HotSpot在JDK1.5推出的第一款真正意義上的并發(Concurrent)收集器;
第一次實作了讓垃圾收集線程與使用者線程(基本上)同時工作;
運作過程:
1)、初始标記(initial mark)
僅标記一下GC Roots能直接關聯到的對象;
速度很快;
需要”Stop The World”;
2)、并發标記(concurrent mark)
進行GC Roots Tracing的過程;
在步驟一産生的集合中标記出存活對象;
由于應用程式也在運作,并不能保證可以标記出所有的存活對象;
3)、重新标記(remark)
為了修正步驟二期間因使用者程式繼續運作而導緻标記變動的那一部分對象的标記記錄;
需要”Stop The World”,且停頓時間比初始标記稍長,但遠比并發标記短;
采用多線程并行執行來提升效率;
4)、并發清除(CMS concurrent sweep)
回收所有的垃圾對象;
和應用程式同時運作。
整個過程中耗時最長的并發标記和并發清除都可以與使用者線程一起工作;
是以總體上說,CMS收集器的記憶體回收過程與使用者線程一起并發執行;
缺點:
1)、對CPU資源非常敏感
并發收集雖然不會暫停使用者線程,但因為占用一部分CPU資源,還是會導緻應用程式變慢,總吞吐量降低。
CMS的預設收集線程數量是=(CPU數量+3)/4;
當CPU數量多于4個,收集線程占用的CPU資源多于25%,對使用者程式影響可能不大;但CPU數量不足4個時,就會影響較大,使用者可能無法接受。
2)、無法處理浮動垃圾,可能出現”Concurrent Mode Failure”失敗
浮動垃圾:在并發清除時,使用者線程新産生的垃圾,稱為浮動垃圾;
這使得并發清除時需要預留一定的記憶體空間,不能像其他收集器在老年代幾乎填滿再進行收集;
“-XX:CMSInitiatingOccupancyFraction”:設定CMS預留記憶體空間;
JDK1.5預設值為68%:當老年代使用了68%的空間後,CMS就會被激活
JDK1.6變為大約92%:當老年代使用了92%的空間後,CMS就會被激活
“Concurrent Mode Failure”失敗
如果CMS預留記憶體空間無法滿足程式需要,就會出現一次”Concurrent Mode Failure”失敗;
這時JVM啟用後備預案:臨時啟用Serail Old收集器,而導緻另一次Full GC的産生;
這樣的代價是很大的,是以CMSInitiatingOccupancyFraction不能設定得太大。
3)、産生大量記憶體碎片
采用标記清除算法,是以會産生很多不連續的記憶體碎片。
産生大量不連續的記憶體碎片會導緻配置設定大記憶體對象時,無法找到足夠的連續記憶體,進而需要提前觸發另一次Full GC動作。
解決方法:
“-XX:+UseCMSCompactAtFullCollection” :
使得CMS出現上面這種情況時不進行Full GC,而開啟記憶體碎片的合并整理過程;但合并整理過程無法并發,停頓時間會變長;預設是開啟的。
“-XX:+CMSFullGCsBeforeCompaction”:
設定執行多少次不壓縮的Full GC後,來一次壓縮整理;
為減少合并整理過程的停頓時間;
預設為0,也就是說每次都執行Full GC,不會進行壓縮整理;
7、G1收集器
G1(Garbage-First)是JDK7-u4才推出的收集器;
特點
1)并行與并發
能充分利用多CPU、多核環境下的硬體優勢;
可以并行來縮短”Stop The World”停頓時間;
也可以并發讓垃圾收集與使用者程式同時進行;
2)分代收集,收集範圍包括新生代和老年代
能獨立管理整個GC堆(新生代和老年代),而不需要與其他收集器搭配;
能夠采用不同方式處理不同時期的對象;
雖然保留分代概念,但Java堆的記憶體布局有很大差别;
将整個堆劃分為多個大小相等的獨立區域(Region);
新生代和老年代不再是實體隔離,它們都是一部分Region(不需要連續)的集合;
3)結合多種垃圾收集算法,空間整合,不産生碎片
從整體看,是基于标記-整理算法;
從局部(兩個Region間)看,是基于複制算法;
4)可預測的停頓:低停頓的同時實作高吞吐量
G1除了追求低停頓處,還能建立可預測的停頓時間模型;
可以明确指定M毫秒時間片内,垃圾收集消耗的時間不超過N毫秒;
應用場景
面向服務端應用,針對具有大記憶體、多處理器的機器;
最主要的應用是為需要低GC延遲,并具有大堆的應用程式提供解決方案;
用來替換掉JDK1.5中的CMS收集器;
設定參數
“-XX:+UseG1GC”:指定使用G1收集器;
“-XX:InitiatingHeapOccupancyPercent”:當整個Java堆的占用率達到參數值時,開始并發标記階段;預設為45;
“-XX:MaxGCPauseMillis”:為G1設定暫停時間目标,預設值為200毫秒;
“-XX:G1HeapRegionSize”:設定每個Region大小,範圍1MB到32MB;目标是在最小Java堆時可以擁有約2048個Region;
G1收集器可以實作可預測的停頓的原因:
使用G1時,它講整個Java堆劃分為多個大小相等的獨立區域(Region),雖然還保留着新生代和老年代的概念,但新生代和老年代不再是實體相隔了,它們都是一部分Region(不需要連續)的集合。
G1跟蹤各個Region獲得其收集價值大小,在背景維護一個優先清單;
每次根據允許的收集時間,優先回收價值最大的Region(名稱Garbage-First的由來);
這就保證了在有限的時間内可以擷取盡可能高的收集效率;
可以有計劃地避免在Java堆的進行全區域的垃圾收集;
一個對象被不同區域的Region中的對象引用:
一個Region不可能是孤立的,一個Region中的對象可能被其他任意Region中對象引用,判斷對象存活時,是否需要掃描整個Java堆才能保證準确?在其他的分代收集器,也存在這樣的問題(而G1更突出):回收新生代也不得不同時掃描老年代?這樣的話會降低Minor GC的效率;
解決方法:
無論G1還是其他分代收集器,JVM都是使用Remembered Set來避免全局掃描:
每個Region都有一個對應的Remembered Set;
每次Reference類型資料寫操作時,都會産生一個Write Barrier暫時中斷操作;
然後檢查将要寫入的引用指向的對象是否和該Reference類型資料在不同的Region(其他收集器:檢查老年代對象是否引用了新生代對象);
如果不同,通過CardTable把相關引用資訊記錄到引用指向對象的所在Region對應的Remembered Set中;
當進行垃圾收集時,在GC根節點的枚舉範圍加入Remembered Set;就可以保證不進行全局掃描,也不會有遺漏。
G1收集器運作過程
1)初始标記(Initial Marking)
僅标記一下GC Roots能直接關聯到的對象;
且修改TAMS(Next Top at Mark Start),讓下一階段并發運作時,使用者程式能在正确可用的Region中建立新象;
需要”Stop The World”,但速度很快;
2)并發标記(Concurrent Marking)
進行GC Roots Tracing的過程;
剛才産生的集合中标記出存活對象;
耗時較長,但應用程式也在運作;
并不能保證可以标記出所有的存活對象;
3)最終标記(Final Marking)
為了修正并發标記期間因使用者程式繼續運作而導緻标記變動的那一部分對象的标記記錄;
上一階段對象的變化記錄線上程的Remembered Set Log;
這裡把Remembered Set Log合并到Remembered Set中;
需要”Stop The World”,且停頓時間比初始标記稍長,但遠比并發标記短;
采用多線程并行執行來提升效率;
4)篩選回收(Live Data Counting and Evacuation)
首先排序各個Region的回收價值和成本;
然後根據使用者期望的GC停頓時間來制定回收計劃;
最後按計劃回收一些價值高的Region中垃圾對象;
回收時采用”複制”算法,從一個或多個Region複制存活對象到堆上的另一個空的Region,并且在此過程中壓縮和釋放記憶體;
可以并發進行,降低停頓時間,并增加吞吐量;
四、記憶體配置設定和回收政策
1、對象優先在Eden配置設定
大多數情況下,對象在新生代Eden區中配置設定。當Eden區沒有足夠空間進行配置設定時,虛拟機将發起一次Minor GC。
Eden和Survivor:
- jvm将新生代記憶體分為一塊較大的Eden空間和兩塊較小的Survivor空間(from和to);
- 每次使用Eden和其中一塊Survivor;
- 當回收時,将Eden和使用中的Survivor中還存活的對象一次性複制到另外一塊Survivor;
- 而後清理掉Eden和使用過的Survivor空間;
- 後面就使用Eden和複制到的那一塊Survivor空間,重複步驟3;
預設Eden:Survivor=8:1,即每次可以使用90%的空間,隻有一塊Survivor的空間被浪費;
當Eden區沒有足夠空間進行配置設定時,JVM将發起一次Minor GC(新生代GC);
Minor GC時,如果發現存活的對象無法全部放入Survivor空間,隻好通過配置設定擔保機制提前轉移到老年代
2、大對象直接進入老年代
大對象指需要大量連續記憶體空間的Java對象,如,很長的字元串、數組;
經常出現大對象容易導緻記憶體還有不少空間就提前觸發GC,以擷取足夠的連續空間來存放它們,是以應該盡量避免使用建立大對象;
虛拟機提供了一個-XX:PretenureSizeThreshold(隻對Serail和ParNew兩款收集器有效 )參數,令大于這個設定值的對象直接在老年代 配置設定。這樣做的目的是避免在Eden區及兩個Survivor區之間發生大量的記憶體複制。
3、長期存活的對象将進入老年代
JVM給每個對象定義一個對象年齡計數器,其計算流程如下:
如果對象在Eden出生并經過第一次Minor GC後仍然存活,并且能被 Survivor容納的話,将被移動到Survivor空間中,并且對象年齡設為1。
對象在Survivor區中每“熬過”一次Minor GC,年齡就增加1歲,當它的年齡增加到一定程度( 預設為15歲) ,就将 會被晉升到老年代中。
對象晉升老年代的年齡門檻值,可以通過參數- XX:MaxTenuringThreshold(預設15)設定。
4、動态對象年齡判定
為了能更好地适應不同程式的記憶體狀況,虛拟機并不是永遠地要求對象的年齡必須達到了MaxTenuringThreshold才能晉升老年代。
如果在Survivor空間中相同年齡的所有對象大小總和大于Survivor空間的一半,大于或等于該年齡的對象就可以直接進入老年代;
5、空間配置設定擔保
當Survivor空間不夠用時,需要依賴其他記憶體(老年代)進行配置設定擔保(Handle Promotion);
配置設定擔保的流程如下:
- 在發生Minor GC前,JVM先檢查老年代最大可用的連續空間是否大于新生代所有對象空間;
- 如果大于,那可以確定Minor GC是安全的;
- 如果不大于,則JVM檢視HandlePromotionFailure值是否允許擔保失敗;
- 如果允許,就繼續檢查老年代最大可用的連續空間是否大于曆次晉升到老年代對象的平均大小;
- 如果大于,将嘗試進行一次Minor GC,但這是有風險的;
-
如果小于或HandlePromotionFailure值不允許冒險,那這些也要改為進行一次Full GC;
嘗試Minor GC的風險–擔保失敗:
因為嘗試Minor GC前面,無法知道存活的對象大小,是以使用曆次晉升到老年代對象的平均大小作為經驗值;
假如嘗試的Minor GC最終存活的對象遠遠高于經驗值的話,會導緻擔保失敗(Handle Promotion Failure);
失敗後隻有重新發起一次Full GC,這繞了一個大圈,代價較高;
但一般還是要開啟HandlePromotionFailure,避免Full GC過于頻繁,而且擔保失敗機率還是比較低的;
JDK6-u24後,JVM代碼中已經不再使用HandlePromotionFailure參數了;
規則變為:
隻要老年代最大可用的連續空間大于新生所有對象空間或曆次晉升到老年代對象的平均大小,就會進行Minor GC;否則進行Full GC;
即老年代最大可用的連續空間小于新生所有對象空間時,不再檢查HandelPromotionFailure,而直接檢查曆次晉升到老年代對象的平均大小;