JVM可以說是為了Java開發人員屏蔽了很多復雜性,讓Java開發的變的更加簡單,讓開發人員更加關注業務而不必關心底層技術細節,這些復雜性包括內存管理,垃圾回收,跨平台等,今天我們主要看看JVM的垃圾回收機制是怎么運行的,希望能夠幫到大家,
哪些對象是垃圾呢?
Java程序運行過程中時刻都在產生很多對象,我們都知道這些對象實例是被存儲在堆內存中,JVM的垃圾回收也主要是針對這部分內存,每個對象都有自己的生命周期,在這個對象不被使用時,這個對象將會變成垃圾對象被回收,內存被釋放,那么如何判斷這個對象不被使用呢?主要有如下兩種方法:
引用計數算法
這個方法什么意思呢?就是給每個對象綁定一個計數器,每當指向該對象的引用增加時,計數器加1,相反減少時也會減1,當計數器的值變為0時,該對象就會變成垃圾對象,也就是最終沒有任何引用指向該對象。這種方法比較簡單,實現起來也容易,但有一個致命缺點,有可能會造成內存泄漏,也就是垃圾對象無法被回收,我們看下面代碼,創建兩個對象,每個對象的成員變量都持有對方的引用,這就是一個循環引用,形成了一個環,此時雖然兩個對象都不再使用了,但每個對象的計數器並不為0,導致無法被回收,那有辦法解決嗎?當然有,看下面的算法。
class Person {
public Object object = null;
public void test(){
Person person1 = new Person();
Person person2 = new Person();
person1.object = person2;
person2.object = person1;
person1 = null;
person2 = null;
}
}
可達性分析算法
知道了上面算法的缺點,那么可達性分析是怎么解決的呢?在堆內存中,JVM定義了一系列GCroots對象,這些對象稱為GC時個根對象,沿着這些根對象像鏈表一樣一直往下找,凡是在這個鏈上的對象都是符合可達性的,否則認為這個對象不可達,那么這個對象就是一個垃圾對象,也就是說垃圾對象和GC根對象沒有直接或者間接關聯關系,如下圖,黃色的對象就是可以被回收的垃圾對象,因為根GC根對象沒有任何關聯。
理解了可達性分析算法的原理,那么估計有疑問了,哪些對象能作為GCroot對象呢,一起來看一下JVM中對GCroot對象定義的規范。
- Java虛擬機棧中引用的對象
- 堆中靜態屬性引用的對象(JDK8以前時方法區中)
- 堆中常量引用的對象(JDK8以前是方法區中)
- 本地方法(Native方法)棧中引用對象的
垃圾回收算法解讀
在確定了哪些垃圾對象可以被回收后,垃圾收集器要做的就是開始回收這些垃圾,那么如何在堆內存中高效的回收這些垃圾對象呢?,加下來我們介紹幾種算法思想
標記清除算法
標記清除是一種比較基礎的算法,其思想對內存中的所有對象掃描,將垃圾對象進行標記,最后將標記的垃圾對象清除,那么這部分內存就可以使用了,如下圖,第一行是回收前的內存狀態,第二行是回收后的內存狀態,發現了什么?對,就是內存碎片,內存碎片會導致大對象分配失敗,假設我們接下來的對象都是使用2M內存,則那個1M就會浪費掉。
標記整理算法
相對標記清除算法,標記整理多了一步,其思想也是對內存中的對象掃描,標記存活對象和垃圾對象,然后將對象移動,使得存活的對象一邊,待回收的對象在一邊,然后再對待回收對象進行回收,這樣就解決了內存碎片問題,但是對象頻繁的移動會帶來指針地址指向不斷發生變化,整理內存碎片會消耗較長時間,引起應用程序的暫停。
分半復制算法
標記整理算法解決了內存碎片問題,但內存整理也帶來了新的問題,復制算法能夠緩解對象移動的問題,但不能根本上解決,復制算法本質上是空間換時間的一種算法,將內存分為大小相等的兩部分, 在其中一部分內存使用完之后,將其中活着的對象移入到另一半內存中,然后將這一半內存清空。這種算法的代價浪費一半的內存,比如8G內存,只有4G是可以使用的。
分代算法(集所有優點,棄缺點)
上面三種算法各有優缺點,但都不能完美的解決垃圾回收中遇到的問題,那能不能將上面三種算法的優點都集合起來形成一種新的組合呢?是的,分代算法就是這樣的,我們常用不考慮業務的架構都是耍流氓,那么垃圾回收算法也需要結合對象的生命周期來決定,我們都知道應用程序中大多數對象都是朝生夕死的,分代算法將內存分為年輕代和年老代兩個區域,年輕代中采用復制算法,因為年輕代中每次收集時都有大量對象死去,只有少量對象存活,所以采用復制算法這樣移動的對象比較少,年老代中采用標記清除算法,年老代中的對象都是存活時間比較長的對象,但當內存碎片比較嚴重時可以進行一次整理(結合使用),
前面提到復制算法會浪費一半的內存,有沒有辦法浪費的少一點呢?分代算法在年輕代中是怎么解決呢?首先確定的每次垃圾收集時存活對象總是少量的,年輕代中將內存分成了三部分,Eden區域,Survivor1區,Survivor2區,后兩個區域用來存儲存活的對象,對象創建時總是在Eden區域,每當Eden區域滿了之后,垃圾回收時開始將所有存活的對象放入其中一個Survivor區域,並且將另一個Survivor區域和Eden區域清空,如此,兩個Survivor區域只需要少量內存空間,這樣就可以充分利用內存了。
JVM垃圾回收器詳解
基於上面的垃圾回收算法,有很多的垃圾收集器,JVM規范對於垃圾收集器的應該如何實現沒有任何規定,因此不同的廠商、不同版本的虛擬機所提供的垃圾收集器差別較大,這里只看HotSpot虛擬機。
Serial和Serial Old垃圾收集器
Serial收集器歷史非常悠久了,它是在新生代上實現垃圾收集的,SerialOld是在老年代上實現垃圾收集的
他們兩都是單線程工作的(早期多核發展還不是這么好),它在工作時必須暫停應用程序的線程,也就是會發生Stop The World,直到垃圾回收工作完成
Serial年輕代采用復制算法 ,Serial Old老年代采用標記整理算法,
這種收集器的優點是簡單,工作起來非常高效,對於單核CPU來說沒有線程切換的開銷,專門做自己的事,所以在單核CPU上或者內存較小時非常適用,缺點也很明顯,當內存過大時,應用程序暫停無法提供服務,"-XX:+UseSerialGC"這個參數用來開啟Serial垃圾收集器。
ParNew垃圾收集器
ParNew是Serial收集器的多線程版本,除了是多線程,其它的都一樣(也會發生Stop The World,也是新生代的收集器)。它是目前唯一能夠和CMS合作使用的新生代垃圾收集器。
Parallel Scavenge和Parallel Old垃圾收集器
Parallel Scavenge收集器是一個新生代收集器,Parallel Old是一個老年代收集器,前者使用的是復制算法,后者使用的是標記整理算法,他們又都是並行的多線程收集器。
Parallel Scavenge和Parallel Old收集器關注點是吞吐量(如何高效率的利用CPU)所謂吞吐量就是CPU中用於運行用戶代碼的時間與CPU總消耗時間的比值。
在對CPU (吞吐量)比較敏感的情況下,建議使用這兩者結合
CMS(Concurrent Mark Sweep)收集器
重點來了,CMS收集器的目標是獲取最短停頓的時間(即GC時應用程序線程暫停的時間最短),它是老年代收集器,基於標記清除算法(產生內存碎片),並發收集(多線程),CMS是HotSpot在JDK1.5推出的第一款真正意義上的並發(Concurrent)收集器;第一次實現了讓垃圾收集線程與用戶線程(基本上)同時工作;他的應用場景主要是在和用戶交互較多的地方使用,減少用戶感受到的服務延遲。
CMS收集器的運作過程比較復雜,下面我們仔細了解一下這個過程,看看CMS的優秀設計思想
上面提到CMS是基於標記清除算法,CMS將標記分為了三部分,清除一部分,總共四部分
初始標記
首先這個過程是發生STW的,也就是應用程序線程暫停,其次這個過程是非常短暫的,並且是單線程執行的,這一步的主要做的事情標記GCRoots能直接關聯老年代對象,遍歷新生代,標記新生代中可達的老年對象
並發標記
這一階段用戶線程是運行的,因為這一階段應用程序線程還在執行,所有還會持續產生新的對象,這一階段主要是根據初始階段標記出來的可達的GCRoots直接關聯對象繼續遞歸遍歷這些對象的可達對象,但是不會標記產生的新對象,為了避免后續重新掃描老年代,這一階段會把新產生的對象打一個標記(Dirty臟對象),后續只會掃描這些標記為Dirty的對象
這一階段耗時最長了,所以在這一階段用戶產生的垃圾對象足夠多時(也就是老年代已經無法存儲了)就會發生concurrent mode failure,當這一錯誤出現時CMS就會退化為另一個垃圾會收器(Serial Old)暫停用戶線程,單線程回收,這也是CMS缺點之一
預清理
這一階段用戶線程是運行的,主要是處理新生代已經發現的引用,比如在上面的並發階段,Enen區域分配了一個新的對象M,M引用了老年代的一個對象N,但這個N之前沒有被標記為存活,那么此時這個N就會被標記,同時也會把上一階段的Dirty對象重新標記,這一階段也可以通過參數CMSPrecleaningEnabled來進行關閉,默認是開啟
可中斷的預清理
這一階段用戶線程是運行的,該階段發生有一個前提,就是新生代Eden區域內存使用必須大於2M,這個值可以通過如下參數控制。
CMSScheduleRemarkEdenSizeThreshold
可中斷的預處理是什么意思呢?就是這一階段可以中斷,在該階段主要循環做兩件事,一是處理From和To區域的對象,標記可達的老年代對象,二是掃描標記Dirty對象
中斷就指的是這個循環是可以中斷的,條件有三個:
- MSMaxAbortablePrecleanLoops設置循環次數,默認是0,表示無限制
- CMSMaxAbortablePrecleanTime設置執行閾值,默認是5秒
- CMSScheduleRemarkEdenPenetration,新生代內存使用率到了閾值,默認是50%
並發重新標記
這一階段也是STW的,這個過程也會非常短暫,為什么呢?因為上面並發標記,預清理已經標記了大部分存活對象,這一階段也是針對上面新產生的對象進行掃描標記,可能產生的新的引用如下
- 老年代的新對象被GCRoots引用
- 老年代未標記的對象被新生代的對象引用
- 老年代已標記的對象增加新引用指向老年代其它未標記的對象
- 新生代對象指向老年的代的引用被刪除
上述對象中可能有一些已經在Precleaning階段和AbortablePreclean階段被處理過,但總存在沒來得及處理的,所以還有進行如下的處理
- 遍歷新生代對象,重新標記
- 根據GC Roots,重新標記
- 遍歷老年代的Dirty,重新標記,這里的Dirty Card大部分已經在clean階段處理過
這個過程中會遍歷所有新生代對象,如果新生代對象較多,可能比較耗時,但是如果上面可中斷預處理過程中發生了一次YGC,那么這次遍歷就會輕松很多,但是這一次並不可控制,CMS算法中提供了一個參數:CMSScavengeBeforeRemark,默認並沒有開啟,如果開啟該參數,在執行該階段之前,會強制觸發一次YGC,可以減少新生代對象的遍歷時間,回收的也更徹底一點。但這個參數也有缺點,利是降低了Remark階段的停頓時間,弊的是在新生代對象很少的情況下也多了一次YGC,就看運氣了。
並發清除
這一階段用戶線程是運行的,同時GC線程開始對為標記的區域做清掃,回收所有的垃圾對象,這一階段用戶線程還會產生新的對象,這一部分變成垃圾對象后,CMS是無法清理的,這一部分垃圾對象也被稱為浮動垃圾,這也是CMS缺點之一
內存碎片問題
我們知道CMS是基於標記-清除算法的,CMS只會刪除無用對象,會產生內存碎片,那么內存碎片什么時候整理呢?下面這個參數可以配置
-XX:CMSFullGCsBeforeCompaction=n
意思是說在經過n次CMS的GC時,才會做內存碎片整理。如果n等於3,也就是沒經過3次后的CMS-GC會進行一次內存碎片整理,這個默認值是0,代表着直到碎片空間無法存儲新對象時才會進行內存碎片整理。
還有一種情況,在進行Minor GC時,Survivor Space放不下,對象只能放入老年代,而此時老年代也放不下造成的,多數是由於老年帶有足夠的空閑空間,但是由於碎片較多,新生代要轉移到老年帶的對象比較大,找不到一段連續區域存放這個對象導致的,這個時候會發生FullGC,同時進行碎片空間整理。
針對concurrent mode failure解決辦法
-XX:CMSInitiatingOccupancyFraction=70
-XX:+UseCMSInitiatingOccupancyOnly
我們都知道了concurrent mode failure產生的原因,那么可以通過上面兩個參數來防止這個問題產生 第二個參數是用來指定使用第一個參數的,如果沒有第二個參數,則JVM垃圾回收時只有第一次會采用第一個參數,后續會自行調整。
第一個參數代表設定CMS在對內存占用率達到70%的時候開始GC,,這個參數要好不管監控調整以達到一個合適的值,如果過小則gc過於頻繁,如果過大則可能產生上面標題的問題(本身這個參數是用來解決這個問題,設置不當可能會引發這個問題)
還有一個參數,這個參數開啟后每次FulllGC都會壓縮整理內存碎片,默認值是false,不開啟
XX:+UseCMSCompactAtFullCollection
大多數情況下不需要設置這兩個參數,JVM會自行調優,決定在什么時候GC,除非你覺得你比JVM的自動調優做的好,那么你可以自行調優。
過早提升和提升失敗
在 Minor GC 過程中,Survivor Unused 可能不足以容納 Eden 和另一個 Survivor 中的存活對象, 那么多余的將被移到老年代, 稱為過早提升(Premature Promotion),這會導致老年代中短期存活對象的增長, 可能會引發嚴重的性能問題。 再進一步,如果老年代滿了, Minor GC 后會進行 Full GC, 這將導致遍歷整個堆, 稱為提升失敗(Promotion Failure)。
早提升的原因Survivor空間太小,容納不下全部的運行時短生命周期的對象,如果是這個原因,可以嘗試將Survivor調大,否則年輕代生命周期的對象提升過快,導致老年代很快就被占滿,從而引起頻繁的full gc;對象太大,Survivor和Eden沒有足夠大的空間來存放這些大對象。
提升失敗原因當提升的時候,發現老年代也沒有足夠的連續空間來容納該對象。為什么是沒有足夠的連續空間而不是空閑空間呢?老年代容納不下提升的對象有兩種情況:老年代空閑空間不夠用了;老年代雖然空閑空間很多,但是碎片太多,沒有連續的空閑空間存放該對象。
查看JDK8默認垃圾收集器
控制台輸入如下命令
java -XX:+PrintCommandLineFlags -version
得到結果如下,我們可以看到 -XX:+UseParallelGC 這個參數,這個參數表示JDK8的年輕代使用垃圾收集器為Parallel Scavenge,老年代垃圾收集器為Serial Old
XX:InitialHeapSize=266390080
-XX:MaxHeapSize=4262241280
-XX:+PrintCommandLineFlags
-XX:+UseCompressedClassPointers -XX:+UseCompressedOops
-XX:-UseLargePagesIndividualAllocation -XX:+UseParallelGC
java version "1.8.0_191"
Java(TM) SE Runtime Environment (build 1.8.0_191-b12)
Java HotSpot(TM) 64-Bit Server VM (build 25.191-b12, mixed mode)
