1. 程式人生 > >JVM之GC原理解析

JVM之GC原理解析

1. GC ROOT

  首先要說的還應該是垃圾回收首先要做的一件事情:判斷一個物件是否已經GG需要被回收?垃圾回收時是依據這一步判斷哪些物件是否需要回收來繼續進行的,現在主流的JVM用的基本都是可達性分析演算法,即所謂的GC ROOT。該演算法的核心思想是通過某些初始化的物件節點(GC-ROOTS)開始,將任意兩個有關聯的物件之間建立建立連線,最終通過這些初始節點開始向下不斷延伸,最終得到類似於一個或多個無向圖,而判斷某個物件是否能夠被垃圾回收的依據就是是否有一個或一個以上的初始節點通過無向圖能夠抵達該物件節點,如果不可達則能夠進行回收,否則不行。
  常用的GC-ROOTS主要來源於堆記憶體以外的jvm記憶體,例如jvm棧中引用的物件、方法區中引用的靜態變數和物件、本地方法區中引用的物件等。

2. 垃圾回收演算法

  在通過GC ROOT演算法判斷完哪些物件可以被回收以後,接下來就是要準備回收這些物件了,回收需要制定相關的回收規範/策略,這也就是我們說的垃圾回收演算法,這裡主要介紹標記-清除演算法、複製演算法、標記-整理演算法。

2.1 標記-清除演算法

  這個演算法思想十分簡單,分為標記和清除兩個階段,標記就是通過GC ROOT標記哪些物件可以被回收,標記完成後就進入清除階段,清除能夠被回收的物件,這裡在清除的時候只是把物件佔據的記憶體空間給釋放出來了,並沒有做其它事情,而jvm記憶體空間是一塊連續著的區域,這樣經歷了若干次回收之後,會導致存在較多的記憶體碎片,導致記憶體利用率不行。

2.2 複製演算法

複製演算法就是我們常用的幾個垃圾回收器年輕代回收演算法的延伸,最初的複製演算法是把記憶體分為兩塊一樣大小的區域,每次只用其中一半儲存物件,然後其中正在使用的這一半需要GC的時候,在清除的時候,直接把所有存活的物件複製到另外空閒的那邊,然後需要回收的這塊記憶體就可以安心做全部清理了,這樣實現起來較為簡單,也不會出現記憶體碎片問題,不過最初的這種思想會導致記憶體中始終是有一半是處於空閒的,利用率太低了。
  所以針對這一問題,經過相關牛B公司的分析,發現絕大多數物件都是短命鬼,所以每次垃圾回收後的倖存者是很少的,所以如果每次都為了這少數的倖存者而騰出一半的空間未免過於浪費。因此,就有了壓縮空閒記憶體部分的佔比,並且在具體垃圾回收器中優化為記憶體分為三部分,分別就是我們常說的eden、s0、s1三部分,預設比例是8:1:1,分為三部分的作用是,每次jvm申請物件只會使用eden中記憶體,在eden分割槽滿了以後,會觸發垃圾回收,然後會將物件從eden和s0、s1中在使用的那一部分中物件一起進行回收,複製到另外一部分survivor分割槽中,這樣其實最後空閒的記憶體就只有10%了,對空間的利用率就高了很多,這就是在複製演算法思想基礎上進化而來的 複製EX演算法(個人命名!!)。

2.3 標記-整理演算法

  複製EX演算法在絕大多數情況下表現很好,但是記住核心的一點這個演算法的需要絕大多數物件是短命鬼的情況下,如果出現極端情況,某些物件的生命力那叫一個頑強啊,而且還是成群結隊的來到了我們的記憶體中,導致每次存活的物件特別多,如果這個時候還用複製EX演算法,會導致每次需要copy的物件非常多,效率會有明顯影響。這一點尤其是在老年代中,由於老年代都是一些久經沙場活下來的戰士,生命力普遍頑強不是年輕代裡的渣渣們能比的,所以如果在老年代裡需要進行垃圾回收,複製EX直接GG!
  針對這種情況,我們就需要繼續回到最初的標記-清除演算法祖宗這裡取經,得到的啟示是標記-清除演算法的EX版本 — 標記-整理演算法!!!這個演算法的標記階段和老祖宗一樣,但是對清除過程的記憶體碎片不足進行了改良,改良的辦法是,當我標記完後,我不馬上直接進行清除釋放記憶體,而是先把倖存者們依次排排站,向記憶體空間中的一端一起移動,大家聚攏起來在末日抱團取暖,這樣就使得所有幸存者佔據的地盤(記憶體)是連續起來的了,然後對於倖存者佔據的營地以外的地方,直接全部進行人道毀滅,殺死所有喪屍(不可達物件們),這樣完畢後,就不存在記憶體碎片一說了。
  以上就是回收演算法的介紹,接下來就是針對這些演算法,來調教調教實際的那些個垃圾回收器們了。

3. 垃圾回收器們

針對以上各種理論演算法,其實各有各的優缺點,由於實際應用的場景和需求的複雜性,造物主們創造了一個個針對不同情況下的垃圾回收器們,它們主要有:Serial、ParNew、Parallel Scavenge、Serial Old、Parallel Old、CMS、G1等等。造物主太能創造了,物種過於多,我們這裡主要挑幾個常用到的(或者說我遇到的)來介紹。

Serial/Serial Old收集器

  Serial收集器屬於設計給年輕代處理戰五渣們用的收集器,它內部用到的是複製EX演算法來進行垃圾回收,它屬於一個序列工作的收集器,即在進行垃圾回收時,使用者的工作執行緒必須停止,而且它在清理時用的是單CPU單執行緒進行清理,從而導致它缺點是STOP THE WORLD(清理時停止使用者執行緒)的時間太長,現在的計算機大多數都是多核多CPU的,所以用Serial的地方不多,但是書上說它在Client模式下作為垃圾回收器是很好的選擇,這一點我暫未遇到,估計也不會遇到,忽略之。
  和Serial對應的,Serial Old則是為了專門為老年代的戰士們設計的,它和Serial區別在於它清理記憶體時的回收演算法用的是標記-整理演算法,從而有效避免記憶體碎片過多而導致如果有大的物件來到老年代,由於無法分配到足夠大的連續空間而提前觸發FULL GC。

ParNew收集器

  這個傢伙就是Serial收集器的多執行緒版本,即在進行垃圾回收的時候它用的是多執行緒,用在年輕代中,所以會比單執行緒的Serial快一些,從而對使用者的STOP THE WORLD耗時少一些。當然使用它的一個額外也是十分重要的原因是,只有它能夠和CMS收集器配合使用,分別處理年輕代和老年代的垃圾回收。

CMS收集器

  這個傢伙可老牛逼了,造物主們創造它的意圖就是儘量減少STOP THE WORLD的時間,它用到的垃圾回收演算法是標記-清除演算法,執行過程的整個流程分為四個階段:

  • 初始標記(CMS initial mark)
  • 併發標記(CMS concurrent mark)
  • 重新標記(CMS remark)
  • 併發清除(CMS concurrent sweep)

  這四個步驟中只有初始標記和重新標記會導致STOP THE WORLD,不過初始標記只是 標記直接和GC Roots有關聯的物件們,所以這一步的速度和光一樣快。初始標記完成後,依據標記結果,啟動多個執行緒來標記後續的不是直接和GC Roots們直連的物件們,但是這個過程使用者執行緒也會正常執行,所以有一定可能性會出現前一刻標記為不可達的物件,下一秒由於使用者執行緒中某個物件和剛標記的那個物件建立了關係,導致會存在部分物件標記有誤。所以,完成併發標記後,還不能夠進行垃圾回收,不然會錯殺無辜。對此,就有了第三步的觸發STOP THE WORLD讓使用者執行緒乾瞪眼的重新標記階段,這個階段使用者執行緒停止執行,收集器啟動多個執行緒再次進行一次標記,這個過程雖然會比初始標記慢,但是比不進行步驟1、2直接停止使用者執行緒進行標記來說,要快得多。最後,標記完成後就是進行最終的垃圾回收了,這個過程使用者執行緒是可以一起進行的。
  針對CMS,由於為了儘量減少STOP THE WORLD耗時,使用的是標記-清除演算法,那麼很顯然會導致記憶體碎片問題,進行了若干次CMS垃圾收集後,可能會導致記憶體碎片較多,無法給大的物件分配空間,導致頻繁出發FULL GC,對此,虛擬機器中有一個引數-XX:+UseCMSCompactAtFullCollection是用來配置在FULL GC觸發時,是否開啟記憶體合併整理,即把存活的物件集中到一塊連續的記憶體中。這個開關預設是開啟的,即預設情況下發生一次FULL GC時,是會進行記憶體整理的,而且要注意記憶體整理過程中也是會停止使用者執行緒的,當然這種情況雖然碎片問題沒了但是時間自然會消耗更多。對此,一個JVM提供了另外一個引數-XX:CMSFullGCsBeforeCompaction控制執行了多少次FULL GC後進行記憶體整理,預設是0,即每次FULL GC都會進行碎片整理,這兩個引數書中說的,本人暫時未使用過。
  另外一點,由於併發清除階段,使用者執行緒會繼續執行,所以這個過程中可能會產生新的物件之類的,對此在進行垃圾回收的時候,還需要騰出一部分的記憶體給這些執行緒來進行記憶體分配之類的,所以一般在老年代記憶體還未佔滿之前就會提前觸發GC,1.6以後的版本這個閥值是92%,即老年代記憶體使用達到92%時會觸發FULL GC。尤其是,如果老年代預留的空間不夠,那麼會導致物件無法進入老年代,那麼會導致CMS垃圾回收器失效,這個時候就會啟動備用方案用Serial Old來進行老年代的垃圾回收。

以上就是常用的垃圾回收器,那麼瞭解了這下垃圾回收器後,我們還需要會看GC日誌啊,不然都是白搭,這裡用CMS的日誌來做個例子說明:

1193.683: [GC [1 CMS-initial-mark: 2674952K(3268608K)]   4260378K(7876608K), 0.8219810 secs] [Times: user=0.80 sys=0.02, real=0.82 secs] 
 時間           當前階段名稱   當前老年代大小(老年代總大小) 當前已有堆大小(堆總大小) 耗時
1194.505: [CMS-concurrent-mark-start]
1194.607: [CMS-concurrent-mark: 0.102/0.102 secs] [Times: user=0.61 sys=0.00, real=0.11 secs] 
1194.607: [CMS-concurrent-preclean-start]
1194.616: [CMS-concurrent-preclean: 0.009/0.009 secs] [Times: user=0.01 sys=0.00, real=0.00 secs] 
1194.616: [CMS-concurrent-abortable-preclean-start]
 CMS: abort preclean due to time 1199.770: [CMS-concurrent-abortable-preclean: 4.553/5.153 secs] [Times: user=8.79 sys=0.20, real=5.16 secs] 
1199.770: [GC[YG occupancy: 3590133 K (4608000 K)]1199.770: [Rescan (parallel) , 2.6855350 secs]1202.456: [weak refs processing, 0.0000340 secs]1202.456: [scrub string table, 0.0006550 secs] [1 CMS-remark: 2674952K(3268608K)] 6265085K(7876608K), 2.6863330 secs] [Times: user=42.25 sys=0.90, real=2.68 secs] 
1202.456: [CMS-concurrent-sweep-start]
1202.590: [CMS-concurrent-sweep: 0.132/0.133 secs] [Times: user=0.14 sys=0.01, real=0.14 secs] 
1202.590: [CMS-concurrent-reset-start]
1202.599: [CMS-concurrent-reset: 0.009/0.009 secs] [Times: user=0.01 sys=0.00, real=0.01 secs] 

首先每一行最前面的數字代表的是JVM啟動開始至當前這一步驟的時間,單位是秒,主要是處理前的:

1193.683: [GC [1 CMS-initial-mark: 2674952K(3268608K)]   4260378K(7876608K), 0.8219810 secs] [Times: user=0.80 sys=0.02, real=0.82 secs] 
 時間           當前階段名稱   當前老年代大小(老年代總大小) 當前已有堆大小(堆總大小) 耗時

垃圾回收後的:

1199.770: [GC[YG occupancy: 3590133 K (4608000 K)]1199.770: [Rescan (parallel) , 2.6855350 secs]1202.456: [weak refs processing, 0.0000340 secs]1202.456: [scrub string table, 0.0006550 secs] [1 CMS-remark: 2674952K(3268608K)] 6265085K(7876608K), 2.6863330 secs] [Times: user=42.25 sys=0.90, real=2.68 secs] 

其中:

[YG occupancy: 3590133 K (4608000 K)] 清理前表示當前年輕代大小(總年輕代大小);
Rescan (parallel)  重新掃描,步驟三的重新標記。
[1 CMS-remark: 2674952K(3268608K)]  清理完畢後,老年代佔大小(老年代總大小)
6265085K(7876608K)  清理完畢後,當前堆中物件大小(堆總大小)

  當然,由於是第三步得到的結果,這裡應該是依據統計了會被回收的總大小和當前堆內所有物件總大小後,計算得到的結果,真正執行時,由於使用者執行緒還會一起執行,這個過程中會產生浮動垃圾,所以真正執行完畢那一刻的堆大小是不確定的,只能依據執行前的資料進行計算。