1. 程式人生 > >JVM系列(三):java的垃圾回收機制

JVM系列(三):java的垃圾回收機制

java垃圾回收機制介紹

   上一篇講述了JVM的記憶體模型,瞭解了到了絕大部分的物件是分配在堆上面的,我們在編碼的時候並沒有顯示的指明哪些物件需要回收,但是程式在執行的過程中是會一直建立物件的,之所以沒有記憶體溢位是因為我們的虛擬機器幫我我們自動進行了垃圾回收,保證程式執行的時候有足夠的空間來分配我們建立的物件。
   JVM被分為五大記憶體區域,其中程式計數器、虛擬機器棧,本地方法棧是執行緒私有的,記憶體隨著執行緒的銷燬而退出。堆和方法區是動態分配的,由於方法區的垃圾收集收效甚微,所以本章所說的垃圾回收主要指的是堆記憶體的垃圾回收。

什麼樣的物件會被回收

   什麼樣的物件會被回收呢?我們想象下在生活中,什麼樣的東西會被我們扔進垃圾桶呢,是不是已經不再使用的東西或者說是沒有任何利用價值的東西,在java中也是一樣的,就是不會再使用到的物件。那麼在java中,怎麼判斷這個物件是不是不會再被使用呢?顯然,這似乎要比現實生活中判斷哪些東西是垃圾要複雜許多。

如何確定一個物件是垃圾

前面說到,我們需要知道哪些物件是需要被回收的,那麼怎麼判斷這個物件是否需要回收呢?

引用計數法。

   建立物件的時候,給物件新增一個引用計數器,每當有一個地方引用的時候,就給計數器加1,當引用失效時,就給計數器減1,當引用計數器為0的時候,說明這個物件不會再被使用。這種方法被稱為引用計數法。引用計數法的邏輯比較簡單,效率高,但是卻無法解決物件和物件之間的迴圈引用的問題。

可達性演算法分析

   可達性分析演算法的基本思想是通過被稱為GC Roots的起始點向下搜尋,搜尋走過的鏈路被稱為引用鏈,如果沒有任何一條鏈路到達這個物件,那麼這個物件就不會再被使用,就可以將其回收。

在java語言中,以下物件可以被稱為GC Roots:

  • 虛擬機器棧中引用的物件,
  • 方法區的類的靜態屬性引用的物件
  • 方法區中常量引用的物件
  • 本地方法棧中Native方法引用的物件。

如圖所示:Object1、Object2、Object3通過Gc Roos是可達的,所以這些物件是不可回收的,而Object4、Object5通過GC Roots不可達,這些物件是可以回收的。

                                                               

垃圾回收演算法

1.標記-清除演算法(Mark-Sweep)

標記清除演算法是最基礎的,它分為兩個階段,標記和清除。先標記回收的物件,然後清除這一部分物件的記憶體。

標記階段堆中所有的物件都會被掃描一遍才能確定需要回收的物件,比較耗時。

                               
缺點:
(1)標記和清除兩個過程都比較耗時,效率不高
(2)會產生大量不連續的記憶體碎片,空間碎片太多可能會導致以後在程式執行過程中需要分配較大物件時,無法找到足夠的連續記憶體而不得不提前觸發另一次垃圾收集動作。

2.複製-回收演算法

   複製回收,顧名思義,就是將存活的物件複製出來,然後清理剩下的記憶體。這種演算法不會產生記憶體碎片。將記憶體劃分為兩塊相等的區域,把存活的物件直接複製到另一塊記憶體,之所以分配相等,是因為在極端的情況下,第一塊記憶體區域的物件都是存活的。但是這樣記憶體的利用率非常低,後來經過研究新生代中的物件基本都是存活率比較低,基本98%的物件都會在垃圾回收的時候被回收掉。所以將新生代劃分為三個區域,eden區,survivor0和survivor1區,預設按照 8:1:1的比例分配,eden區經過回收後,將存活的物件複製到survivor0區,這樣就只會有10%的空間沒有使用到,但是,我們無法保證每次回收的物件都低於10%,因此,當survivor空間不夠用的時候,就需要依賴於其他的記憶體空間。

                                  

3.標記-整理演算法

   複製-回收演算法在物件存活比較少的情況下效率很高,但是當物件存活率很高的時候就不適合使用了。標記-整理演算法與標記清除有點類似,都是先標記,但是標記-整理演算法會將可回收的物件都向一端移動,然後直接清理掉可回收物件邊界以外的物件。這樣的好處是不會產生記憶體碎片。

                                  

4.分代收集演算法

   其實這種演算法可以看做是前幾個演算法的結合,根據物件存活的特點,將堆分為新生代和老年代。新生代的物件存活率低,存活物件少,使用複製回收演算法的效率高,而老年代物件存活率高,存活物件多,顯然是使用標記整理的演算法效率高。

java堆的記憶體模型

   前面提到,根據物件存活的特點以及使垃圾回收產生演算法產生最大的收益,將堆區分為兩大塊,一個是Old區,一個是Young區。Young區分為兩大塊,一個是Survivor區(S0+S1),一塊是Eden區。 S0和S1一樣大,也可以叫From和To。

物件建立所在區域
   一般情況下,新建立的物件都會被分配到Eden區,一些特殊的大的物件會直接分配到Old區。
比如有物件A,B,C等建立在Eden區,但是Eden區的記憶體空間肯定有限,比如有100M,假如已經使用了
100M或者達到一個設定的臨界值,這時候就需要對Eden記憶體空間進行清理,即垃圾收集(Garbage Collect),
這樣的GC我們稱之為Minor GC,Minor GC指得是Young區的GC。經過GC之後,有些物件就會被清理掉,有些物件可能還存活著,對於存活著的物件需要將其複製到Survivor區,然後再清空Eden區中的這些物件。

為什麼要分為surivor0和surivor1

   下面根據垃圾收集演算法,詳細講解下為什麼要分為surivor0和surivor1,難道一個survivor區不行嗎?
   假設只有一個s0區,eden區回收之後,一部分物件存放到了s0區,此時eden區空間全部釋放,記憶體都是連續的。但是因為s0區也會進行垃圾回收,它有一部分存活的物件進入到了Old區,還有一部分物件存活留下來,這時候s0區就產生了記憶體碎片,為了使s0區的記憶體空間相對連續,再分配一個s1區,大小和s0一樣,每次垃圾回收的時候,將eden區和s0區存活的物件移動到s1區,這樣永遠都能保證s0或者s1的記憶體空間是連續的。當然,這樣的情況下會使得s0或者s1區有一個空間永遠為空,浪費10%的記憶體空間,當然為了最大化的利用young區,這樣的浪費是被接受的。所以,young區一次GC流程是這樣的:在同一個時間點上,S0和S1只能有一個區有資料,另外一個是空的。假設s0區有資料,此時進行一次GC操作,s0區中物件的年齡就會+1,而Eden區中所有存活的物件會被複制到是s1區,s0區中還能存活的物件會有兩個去處。若物件年齡達到之前設定好的年齡閾值,此時物件會被移動到Old區,Eden區和s0區沒有達到閾值的物件會被複制到s1區,s0區將又會變為空的。

整個young區的回收過程是這樣的:

                                             

一個物件的一生


   我是一個普通的物件,我出生在Eden區,周圍還有一些和我長得很像的兄弟姐妹,我在Eden區玩了一段時間後,後來我的兄弟們越來越多,多到住不下了,於是我的JVM爸爸就把我趕出了Eden區,我被髮配到了s0區,在s0區,我認識了一個女生Baby,她說它的故鄉也是Eden區,她比我早來幾年,我們互相心生好感,我們彼此約定白頭偕老,在這段蜜月期我們時不時的從s0區逛到s1區,又從s1區逛到s0區,可是好景不長,有一天早上醒來,我發現我的Baby不見了,臥槽不見了,我抓狂,她給我留了個字條,說n年後去Old區找她。我很傷心,但是我一直覺得老天用一根無形的絲線將我們聯絡在一起。我想了一下,兩情若是久長時,又豈在朝朝暮暮,我心裡有她就行。n多年過去了,我一直記得這個事情,這一天終於到來了,我立即收拾包袱來到了Old區,找了許久,可當我找到她的時候,她已白髮蒼蒼,行將就木,她說她終於等到我了,我要是晚來幾分鐘連她最後一面都見不到。說完她就拜拜了,身體消散在Old區,我心裡已然了無牽掛,決定追隨我的愛人,於是我也消散在這片天空,泯然於世間,彷彿我從來沒有來過一樣。


Minor GC、Major GC、Full GC

   新生代的垃圾回收叫Minor GC,老年代的垃圾回收叫Major GC,Full GC是指清理整個堆空間,包括年新生代和老年代。由於老年代大部分場景是由新生代垃圾回收觸發,所以,Major GC通常也會伴隨著一次Minor GC。

八種垃圾收集器

   前面講到了垃圾收集的演算法,這只是一種理論思想,我們需要把思想轉化為一種具體的垃圾收集工具,垃圾收集器就是垃圾收集演算法的具體實現。它們分別是新生代的:Serial、ParNew、Parallel Scavenge 老年代的:Serial Old、 Parallel Old、CMS以及適用於新生代和老年代的G1。算上jdk11的ZGC目前一共是八種垃圾收集器。目前現代網際網路公司基本都採用CMS和G1作為線上的垃圾收集器,因此本文後續篇幅將會著重介紹這兩個垃圾收集器。

Serial收集器

Serial是最早的垃圾收集器,這是一個單執行緒收集器,它只適用一個CPU或者是一條收集線去執行回收任務。
                                             

   如圖所示,Serial收集器在工作的時候必須暫停所有的使用者執行緒,也就是 STW(Stop The World),使用者執行緒必須在收集任務完成之後才能工作。如果回收的時間過長的話是很影響使用者體驗的。Serial適用於單個CPU的環境,其實隨著計算機的發展,如今多核CPU已經很普遍,就算是個人的PC也是多核的更別說線上的伺服器了,所以個人認為Serial以後使用的場景將會非常少。

2.ParNew收集器

   ParNew是一個新生代的多執行緒的收集器,它相當於是Serial的多執行緒版本。它的一些引數配置和Serial基本完全相同。只不過ParNew收集器在工作的時候,是多個執行緒工作的,如圖所示:

                             

   ParNew適合在多個CPU場景下使用,而我們的線上伺服器基本都是多核CPU,所以,使用新生代的ParNew搭配老年代的CMS收集器還是挺常見的,我所在的部門的系統線上就是使用的ParNew+CMS組合。與Serial相同的是,ParNew在進行垃圾回收的時候,也會暫停所用的使用者執行緒。

3.Parallel Scavenge收集器

   Parallel Scavenge 也是一個新生代收集器,並且也是一個多執行緒收集器,Parallel Scavenge關注的點是應用的吞吐量,吞吐量 = 使用者程式碼執行時間/使用者執行程式碼時間+GC時間,它提供了兩個引數用來控制吞吐量,分別是控制最大垃圾收集停頓時間的 -XX:MaxGCPauseMillis 引數和直接設定吞吐量大小的-XX:GCTimeRatio 引數。GCTimeRatio引數的值是一個大於0且小於100的整數,也就是垃圾收集時間佔總時間的比率,相當於是吞吐量的倒數。高吞吐量可以高效的利用CPU時間,儘快完成計算任務,因此,Parallel Scavenge收集器也用於需要密集計算不需要進行使用者互動的一些後臺。

4.Serival Old收集器

Serival Old 收集器是垃圾收集的老年代版本,也是一個單執行緒收集器。

5.Parallel Old收集器

   Parallel Old收集器是Parallel Scavenge的老年代版本。可以使用Parallel Scavenge+Parallel Old組合,在注重吞吐量和CPU資源敏感的場合可以優先考慮Parallell SCavenge 和Parallell Old組合。
                           

6.CMS(Concurrent Mark Sweep) 收集器

   CMS(Concurrent Mark Sweep),併發標記清除,這是一種追求低停頓時間為的收集器。網際網路時代,使用者體驗為王,垃圾收集的時間越短,給使用者帶來的體驗就越好。CMS收集器整個回收過程可以分為四個步驟:

  • 初始標記(CMS inint mark)
  • 併發標記(CMS concurrent mark)
  • 重新標記(CMS remark mark)
  • 併發清除(CMS concurrent sweep)
    如圖所示:
                               

初始標記:
   初始標記只是標記著GC Roots 能直接關聯到的物件,這個過程需要對所有的物件進行標記,為了防止標記的過程中有物件的狀態發生改變,需要暫停使用者執行緒,因為只是標記GC Roots 能直接關聯到的物件,因此這部分的執行速度很快。

併發標記:
對初始標記中標記的存活物件進行trace,標記這些物件為可達物件,這個階段在標記的時候可以執行使用者執行緒,由於使用者執行緒會和標記的執行緒一起工作,可能會有新的垃圾物件產生而沒有標記完整。所以會將在併發階段新生代晉升到老年代的物件、直接在老年代分配的物件以及老年代引用關係發生變化的物件所在的card標記為dirty,避免在重新標記階段掃描整個老年代。

重新標記:
   重新標記階段是為了修正併發標記階段產生的垃圾物件,這一部分是暫停使用者執行緒的,但是執行時間也很快。

併發清除:
   這個階段是是清除標記好的垃圾物件,會和使用者執行緒同時進行。

   cms垃圾收集允許一定的誤差,因為併發標清除的階段會有使用者執行緒同時工作,又將會有新的垃圾物件產生。但是它主要考慮的是低停頓時間。由於整個過程中,併發標記和併發清除,收集器執行緒可以與使用者執行緒一起工作,所以總體上來說,CMS收集器的記憶體回收過程是與使用者執行緒一起併發地執行的。

cms收集器很好的展示了它的優點,低停頓,但是,它也存在著以下幾個缺點。

  • 吞吐量降低:由於是和使用者執行緒並行執行的,會佔用一部分的CPU資源,會導致使用者程序變慢影響吞吐量,這也是和Parallel Old相反的地方。
  • 產生浮動垃圾:什麼是浮動垃圾,前面也提到了,在併發清理的階段,由於清理的工作是和使用者執行緒一起工作的,那麼就會在清理的階段而再次產生垃圾物件,但是前面的標記階段已經結束,所以清理階段是無法清除這些新產生的垃圾物件的,只能等待下一次的垃圾回收,所以,就必須要留有一部分的記憶體空間給這些物件儲存。如果預留空間不夠的話,會出現“Concurrent Model Failure”,這時虛擬機器會臨時啟用Serial Old收集器來收集,這樣就會造成停頓時間過長。
  • 會產生記憶體碎片:由於CMS是採用標記-清除演算法來實現的,由前面的圖可知,標記清除演算法會使記憶體空間不連續,如果有大的物件分配過來而剛好又沒有足夠的連續空間儲存的話就會再次觸發Full GC。為了解決這個問題CMS提供了引數-XX:+UseCMSCompactAtFullCollection 來在Full GC之前進行壓縮空間,但是這不得不導致停頓時間變長。

G1(GarBage-First)收集器

   G1收集器是一款面向服務端的收集器,也就是說,它將低停頓時間作為終極目標。G1與其他垃圾收集器的區別是它可以控制垃圾收集時間在某一個範圍之內。與CMS垃圾收集的執行過程類似,它分為初始標記,併發標記,最終標記,篩選回收。G1之所以能夠將停頓時間控制在一個指定的時間內,就是因為它可以選擇性的進行回收。

   G1嘗試著去滿足最小的停頓時間,在G1中,停頓時間是可以設定的,是可控制的,之所以可以建立可預測的停頓時間模型,是因為G1避免了在java堆中進行全區域的垃圾收集。傳統的新生代老年代的記憶體模型被多個大小相等的獨立區域(Region)所取代。如下圖所示,雖然新生代和老年代的概念還保留著,但是他們不再是物理隔離的了,他們都是由Region所組成。G1在清除階段是有選擇性的,它會根據設定的停頓時間,選擇回報率最大的Region。Region可以說是G1回收器一次回收的最小單元。即每一次回收都是回收N個Region。這個N是多少,主要受到G1回收的效率和使用者設定的軟實時目標有關。
G1的記憶體佈局:

                                 

G1中的巨型物件是指,佔用了Region容量的50%以上的一個物件。Humongous區,就專門用來儲存巨型物件。如果一個H區裝不下一個巨型物件,則會通過連續的若干H分割槽來儲存。因為巨型物件的轉移會影響GC效率,所以併發標記階段發現巨型物件不再存活時,會將其直接回收。分割槽可以有效利用記憶體空間,因為收集整體是使用“標記-整理”,Region之間基於“複製”演算法,GC後會將存活物件複製到可用分割槽(未分配的分割槽),所以不會產生空間碎片。

   前面說到,G1會選擇性的回收Region,避免掃描整個堆。但是正常情況下,每一個Region之間可能都會有互相引用的物件,這樣的話在垃圾收集掃描的時候還是不可避免的掃描整個堆來確定哪些是垃圾物件,G1是如何解決這一問題的呢?G1通過讓每一個Region都維護一個Remembered Set來避免全堆掃描,在程式對引用型別的物件進行寫操作的時候,虛擬機器會檢查Reference引用物件是否在不同的Region,並且會把這些引用的資訊記錄在Renembered Set中。

整個G1的垃圾回收階段可以分為:
初始標記:標記GC Roots能直接關聯到的物件,需要暫停使用者執行緒。
併發標記:從GC Root開始對堆中的物件進行可達性分型,標記出存活的物件,用時比較久,可與使用者執行緒併發執行。
重新標記:修正在併發標記階段因使用者執行緒執行發生改變的記錄,需要暫停使用者執行緒。
篩選回收:對各個Region的回收價值進行排序,根據使用者所設定的回收時間制定回收計劃,這個階段可與使用者執行緒併發執行。
如圖所示:
;                  

G1目前是jdk9的預設垃圾收集器,一般在以下場景中,需要考慮是否需要使用G1垃圾收集器:
(1)50%以上的堆被存活物件佔用
(2)物件分配和晉升的速度變化非常大
(3)垃圾回收時間比較長

ZGC

   Z垃圾收集器(ZGC)是可伸縮的低延遲垃圾收集器。ZGC可以同時執行所有昂貴的工作,而不會將應用程式執行緒的執行停止超過10ms,這使得它適合於要求低延遲和/或使用非常大的堆(數TB)的應用程式。
   目前ZGC沒有分代,每次GC都會標記整個堆,將堆分為 2M(small), 32M(medium), n*2M(large)三種大小的頁面(Page)來管理,根據物件的大小來判斷在哪種頁面分配,大部分物件標記和物件轉移都是可以和應用執行緒併發。只會在以下階段會發生stop-the-world:

  1. GC開始時對root set的標記時

  2. 在標記結束的時候,由於併發的原因,需要確認所有物件已完成遍歷,需要進行暫停

  3. 在relocate root-set 中的物件時

雖然ZGC屬於最新的GC技術, 但是隻在特定情況下具有絕對的優勢, 如巨大的堆和極低的暫停需求。

   本篇文章只是對java的垃圾回收涉及到的方面作一個簡單的概括,並沒有涉及到具體的演算法的分析以及垃圾收集器的內部實現原理。其旨在對java的垃圾回收機制有一個整體的瞭解,下一章將介紹垃圾收集器用到的一些引數來為GC日誌的分析和調優作準備。

參考書籍

深入理解java虛擬機器--周志明