1. 程式人生 > >深入JVM垃圾回收機制,值得你收藏

深入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的,這個過程也會非常短暫,為什麼呢?因為上面併發標記,預清理已經標記了大部分存活物件,這一階段也是針對上面新產生的物件進行掃描標記,可能產生的新的引用如下
  4. 老年代的新物件被GCRoots引用
  5. 老年代未標記的物件被新生代的物件引用
  6. 老年代已標記的物件增加新引用指向老年代其它未標記的物件
  7. 新生代物件指向老年的代的引用被刪除

上述物件中可能有一些已經在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)