深入JVM垃圾回收機制,值得你收藏


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對象定義的規范。

  1. Java虛擬機棧中引用的對象
  2. 堆中靜態屬性引用的對象(JDK8以前時方法區中)
  3. 堆中常量引用的對象(JDK8以前是方法區中)
  4. 本地方法(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對象

中斷就指的是這個循環是可以中斷的,條件有三個:

  1. MSMaxAbortablePrecleanLoops設置循環次數,默認是0,表示無限制
  2. CMSMaxAbortablePrecleanTime設置執行閾值,默認是5秒
  3. CMSScheduleRemarkEdenPenetration,新生代內存使用率到了閾值,默認是50%
並發重新標記

這一階段也是STW的,這個過程也會非常短暫,為什么呢?因為上面並發標記,預清理已經標記了大部分存活對象,這一階段也是針對上面新產生的對象進行掃描標記,可能產生的新的引用如下

  1. 老年代的新對象被GCRoots引用
  2. 老年代未標記的對象被新生代的對象引用
  3. 老年代已標記的對象增加新引用指向老年代其它未標記的對象
  4. 新生代對象指向老年的代的引用被刪除

上述對象中可能有一些已經在Precleaning階段和AbortablePreclean階段被處理過,但總存在沒來得及處理的,所以還有進行如下的處理

  1. 遍歷新生代對象,重新標記
  2. 根據GC Roots,重新標記
  3. 遍歷老年代的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)

![](https://img2018.cnblogs.com/blog/706455/201909/706455-20190911210708072-261554801.jpg)


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM