1. 程式人生 > >一篇文章徹底搞定所有GC面試問題

一篇文章徹底搞定所有GC面試問題

眾所周知,在C++,記憶體的管理是程式設計師的任務,包括物件的建立和回收(記憶體的申請和釋放),而在java中,我們可以通過以下四種方式建立物件(面試考點):

  • new關鍵字建立物件

  • clone方法克隆產生物件

  • 反序列化獲得物件

  • 通過反射建立物件

而在java中物件的回收主要是GC完成:GC會在合適的時間被觸發,完成垃圾回收,將不需要的記憶體空間回收釋放,避免無限制的記憶體增長導致的OOM。由此可以看出,GC在java相關的應用程式中重要性,這也是為什麼面試官熱衷GC相關的面試問題。大部分面試,GC相關問題都是這樣開始的:“你知道GC嗎”?、“你瞭解GC機制嗎”?

上面的類似提問該從何處著手呢?往下看之前,建議讀者先思考:你是如何組織這個問題的回答的?這類似很“寬泛”的問題,其實並不容易回答好,會給人一種:我明明知道相關知識點,但是卻又好像無話可說。比如說GC,它就是用來垃圾回收的啊,但是這樣一句話不能讓面試官充分了解你,你也成功的把話“聊死”了,反正不會是面試的加分項......這類寬泛的問題不僅僅考察你對知識點的掌握,其實也考察讀者的文字組織、交流溝通能力~

如果博主遇到類似“寬泛”的問題,我會先預設:提出這個問題的面試官對問題的相關知識點“一無所知”。在這個前提下,我會依次從以下五個方面組織該問題的回答(這也是本文後續的主要內容):

  1. GC作用

  2. GC在什麼時候

  3. 對誰

  4. 做了什麼事情

  5. GC的種類及各自的特點

我們學習語文的時候,經常會遇到總結段落/文章大意的題目,記得當時語文老師是這麼說的:同學們應該按照“誰,在什麼時候,對誰,做了什麼事情”來組織問題的答案。在這裡也是一樣,問題其實就是要求我們總結概括GC。

下面依次回答上面5個問題:

 

GC作用:

這個比較簡單:在適當時候幫助回收JVM中的“垃圾”,接下來你可以接著說:這句話可以分為以下三個方面回答:什麼時候對誰(怎麼定義“垃圾”)做了什麼(如何回收)——這也就成功將話題向下面三點展開了:

 

什麼時候:

也就是GC會在什麼時候觸發,主要有以下幾種觸發條件:

  • 執行System.gc()的時候:建議執行Full GC,但是JVM並不保證一定會執行

  • 新生代空間不足(下面會詳細展開)

  • 老年代空間不足(下面會詳細展開)

什麼意思呢?物件大都在Eden區分配記憶體,如果某個時刻JVM需要給某一個物件在Eden區上分配一塊記憶體,但是此時Eden區剩餘的連續記憶體小於該物件需要的記憶體,Eden區空間不足會觸發minor GC。觸發minor GC前會檢查之前每次Minor GC時晉升到老年代的平均物件大小是否大於老年代剩餘空間大小,如果大於,則直接觸發Full GC;否則,檢視HandlePromotionFailure引數的值,如果為false,則直接觸發Full GC;如果為true(預設為true,表示允許擔保失敗,雖然剩餘空間大於之前晉升到老年代的平均大小,但是依舊可能擔保失敗),則僅觸發Minor GC,如果期間發生老年代不足以容納新生代存活的物件,此時會觸發Full GC 。

老年代滿了,會觸發Full GC(回收整個堆記憶體)。關於老年代:

  1. 分配很大的物件:大物件直接進入老年代,經常出現大物件容易導致記憶體還有不少空間時就提前觸發垃圾收集以獲取足夠多的連續空間;

  2. 長期存活的物件將進入老年代;

  3. 如果survivor空間中相同年齡所有物件大小的總和大於survivor空間的一半,年齡大於等於該年齡的物件就可以直接進入老年代;

  4. CMS GC在出現promotion failure和concurrent mode failure的時候

上面這三種情況會導致“老年代“滿”,會觸發full GC。

 

對誰:

     對不再使用的物件,怎麼判別一個物件是否還活著呢?這時可以從“引用計數法”講到“可達性分析演算法”。

  • 引用計數法:給物件新增一個引用計數器,每當有地方引用它時,計數器加1;當引用失效時,計數器減1。引用計數法實現簡單,判定效率高,但是它很難解決物件之間的互相迴圈引用(引用環問題)的問題。主流的java虛擬機器沒有選用引用計數法來管理記憶體。

  • 可達性分析演算法(主流實現判斷物件是否“活著”演算法):演算法的基本思路就是以一系列的稱為“GC Roots”的物件作為起點從這些節點開始向下搜尋,搜尋所走過的路徑稱為引用鏈,當一個物件到GC Roots沒有任何引用鏈相連的時候(即該物件不可達),則證明此物件是不可用的。在java中,可作為GC Roots的物件包括以下幾種:棧中引用的物件(棧幀中的本地變量表)、方法區中類靜態屬性引用的物件、方法區中常量引用的物件。

     

在“可達性分析演算法”中標記為不可達的物件,並非是“非死不可”的,還有迴旋的餘地。要宣告一個物件死亡,至少要經過兩次標記的過程:如果物件在進行可達性分析後發現沒有與GC Roots相連的引用鏈,那它將會被第一次標記並進行篩選,篩選的條件是此物件是否有必要執行finalize方法。如果物件沒有覆蓋finalize方法或者該方法已經執行過了,則被視為“沒有必要執行”,宣告死亡。剩下的物件將被加入一個低優先順序的佇列中執行finalize方法。這裡的執行指的是會觸發這個方法,並不保證執行完該方法(只保證虛擬機器會觸發該方法),否則如該方法存在死迴圈,該佇列就已經卡死了,GC也癱瘓了,所以只保證觸發該方法。Finalize是物件逃脫死亡的最後一次機會(可以在finalize方法中重新與引用鏈上的任何一個物件建立關聯)。在觸發finalize方法之後,GC將對該佇列中的物件進行第二次標記,如果此時該物件仍不在引用鏈上,該物件就會被回收。如果第二次標記前,該物件成功與引用鏈上的物件建立了連線,它會被移出“即將回收的集合”,自救成功。注:任何一個物件的finalize方法只會被系統呼叫一次,即在finalize方法中最多能實現一次自救。另外,finalize方法在jdk9中被標記為“廢棄”方法了,不建議使用。

 

做了什麼

不可達的物件,如何被回收:

  1. 標記-清除法:在標記(可達性演算法標記)完成後統一回收所有被標記的物件。它是最基礎的演算法,後續演算法都是基於它的不足而改進,主要不足有:效率問題,標記和清除效率都不高;另外一個是空間問題,標記清除後會產生大量不連續的記憶體碎片,碎片太多可能導致在需要分配較大物件時,無法找到足夠的連續記憶體而不得不提前觸發另一次垃圾回收動作。標記清除演算法回收後的記憶體圖,如下所示:

  2. 複製演算法:為了解決標記清除演算法的效率問題,“複製演算法”出現了。“複製演算法”將可用記憶體分為大小相同的兩部分,每次只使用其中的一塊,當使用的那一塊記憶體快用盡時,就將還存活的物件複製到另外一塊記憶體上,然後把已經使用過的記憶體空間一次性清理掉。這樣就是每次都對整個搬去記憶體進行回收,也不用考慮記憶體碎片等複雜問題,只需要移動指標,按順序分配記憶體即可,實現簡單,執行高效。但是代價就是每次只能使用一半的記憶體,代價有點高。現代商業虛擬機器都是採用這種手機演算法來回收新生代的。實際上新生代中的物件98%都是“朝生夕死”所以遠遠用不著每次僅僅使用一般的記憶體。新生代中將記憶體劃分為一塊較大的Eden空間和兩塊較小的Survivor空間,每次使用Eden和其中一塊Survivor。當回收時,將Eden和使用的Survivor中還存活的物件一次性複製到另外一塊Survivor空間上,最後清理掉Eden和剛剛使用過的Survivor空間。Hotspot預設Eden/Survivor=8,即每次可以使用新生代中90%的容量(80%Eden + 10%Survivor),只有10%會被“浪費”。當然我們沒法保證每次回收都只有不多於10%的物件存活,當Survivor空間不夠用時(超過10%的物件存活),需要依賴其他記憶體進行分配擔保(這裡指老年代),放不下的存活物件將進入老年代。

  3. 標記-整理法:複製收集演算法在物件存活率較高時就要進行較多的複製操作,效率將會變低。更關鍵是,如果不想有空間的浪費,就需要有額外的空間進行分配擔保,以應對被使用的記憶體中所有物件都100%存活的極端情況,所以老年代一般不採用“複製演算法”(沒有擔保人)。根據老年代的特點,提出了“標記-整理法”:標記過程不變,仍使用“可達性分析演算法”,標記完後不是直接對可回收物件進行清理,而是讓所有存活的物件都向一端移動,然後直接清理掉端邊界以外的記憶體如下圖所示:                                                                                                                   

  4. 分代收集演算法:JVM在實際垃圾回收中實際使用的是分代收集演算法根據物件存活週期的不同將記憶體劃分為:新生代和老年代。在新生代每次都只有少量物件存活,選用複製演算法;老年代中因為物件存活率高、沒有額外空間對它進行分配擔保,就必須使用標記-整理法或是標記-清理法進行回收。

上面的不同演算法在JVM中有不同的垃圾回收器的實現,在JVM中主要有下面幾種收集器:

新生代收集器有:Serial收集器、ParNew收集器、Parallel Scavenge收集器;老年代收集器:Cocurrent Mark Sweep(CMS)收集器、Serial Old(MSC)收集器、Parallel Old收集器。另外就是G1收集器,G1獨自管理整個記憶體,不再分新生代和老年代了。上圖中,如果兩個收集器之間有連線,表示他們可以相容使用;無連線則表示它們不能一起工作(不相容)。

下面介紹下這幾種收集器的特徵:

  • Serial收集器(新生代收集器):複製演算法、Serial:序列的意思。由名字就可知這是一個單執行緒的收集器,“單執行緒”的意義並不僅僅說明它只會使用一個cpu或是一條收集執行緒去完成垃圾收集工作,更重要的是在它進行垃圾收集時,必須暫停其他所有的工作執行緒直到垃圾收集結束。“Stop the world”是由虛擬機器在後臺自動發起和完成的,在使用者不可見的情況下把使用者正常工作的執行緒全部停掉,意味著“你的計算機每工作一小時就會暫停響應5分鐘。但是實際上它依然是虛擬機器執行在client模式下的預設新生代收集器。它也有著優於其他收集器的地方:簡單而高效。在使用者的桌面應用場景中,分配各虛擬機器管理的記憶體一般不會很大,收集幾十兆甚至一兩百兆的新生代,停頓時間可以控制在幾十毫秒最多一百多毫秒以內,只要是不平凡發生這點停頓是可以接收的。所以Serial收集器對於執行在Client模式下的虛擬機器來說是一個很好的選擇。

  • ParNew收集器(新生代收集器):它其實就是Serial收集器的多執行緒版本,複製演算法,除了使用多條執行緒進行垃圾收集外,其餘行為包括Serial收集器可用的所有控制引數。收集演算法、Stop The World、回收策略等都與Serial收集器完全一樣。Serial和parNew兩個收集器都可以並且只可以與老年代的CMS和serial old GC一起工作。

  •  Parallel Scavenge收集器(新生代):它是使用複製演算法的收集器,它可以和parallel old和serial old一起工作。它的關注點與其他收集器不同,CMS等收集器是儘可能的縮短垃圾收集時使用者執行緒的停頓時間。Parallel Scavenge收集器關注的是吞吐量,目標是達到一個可控制的吞吐量,吞吐量=執行使用者程式碼時間 /(執行使用者程式碼時間+垃圾收集時間),即為CPU執行使用者程式碼的時間與CPU總消耗時間的網速。停頓時間越短就越適合需要與使用者互動的程式,良好的響應速度能提升使用者體驗;而高吞吐量則可以高效率的利用CPU時間,儘快完成程式的運算任務,適合在後臺運算運算並且不需要太多互動的任務。Parallel Scavenge收集器提供了設定最大垃圾收集停頓時間:-XX:MaxGCPauseMills(收集器將盡量保證記憶體回收時間不超過設定值,但是注意這是以犧牲吞吐量和新生代空間為代價的:把它設定得太小:系統將會調整新生代空間,因為回收300M新生代肯定比回收500M快,但是GC的頻率也隨之增大了)和吞吐量大小:-XX:GCTimeRatio的引數以及一個開關引數UseAdaptiveSizePolicy,可以自動優化調整新生代(-xnm)大小、Eden與Survivor比值(-XX:SurvivorRatio)、晉升老年代大小(-XX:PretenuredThreshold)等細節引數,虛擬機器會根據當前系統執行情況當太調整這些引數已提供最合適的停頓時間或者最大的吞吐量,這種方式稱為“GC自適應”的調節策略。如果對收集器運作原理不太瞭解,手動優化存在困難時,使用Parallel Scavenge收集器把記憶體優化管理的任務交給虛擬機器(只需要設定基本記憶體資料:-Xmx、最大垃圾收停頓時間(更關注停頓時間)或者吞吐量(更關注吞吐量))。自適應調節策略”也是Parallel Scavenge收集器與ParNew收集器的一個重要區別。

  •  Serial Old是Serial收集器的老年代版本,它同樣是是一個單執行緒收集器。使用“標記-整理”演算法,主要意義也是給Client模式下的虛擬機器使用。

  • Parallel Old是Parallel Scavenge收集器的老年代版本,採用“標記-整理演算法”。在注重吞吐量以及CPU資源敏感的場合可以優先考慮:Parallel Scavenge + Parallel Old組合。

  • CMS(Concurrent Mark Sweep)收集器:一種以獲取最短回收停頓時間為目標的收集器(希望系統停頓時間最短,以給使用者帶來較好的體驗)。從名字中的“Mark Sweep”可以看出CMS收集器是基於“標記-清除”演算法實現的,它的運作過程可分為4個步驟:初始標記、併發標記、重新標記、併發清除。其中,初始標記、重新標記這兩個步驟仍然需要“Stop the World”。初始標記僅僅只是標記一下GC Roots能直接關聯到的物件,速度很快;併發標記階段就是進行GC Roots Tracing的過程;而重新標記階段,則是為了修正併發標記期間因使用者程式繼續執行而導致標記產生變動的那一部分物件的標記記錄,這一階段的停頓時間一般會比初始標記階段稍長一些,但遠比並發標記時間短。整個過程只有初試標記和重新標記需要“stop the world”,具有併發、低停頓優點。但是它由三個明顯缺點:1.CMS收集器對CPU資源非常敏感:在併發階段雖然不會導致使用者執行緒停頓,但是會因為佔用了一部分執行緒(CPU資源)而導致應用程式變慢,總吞吐量降低;CMS收集器無法收集浮動垃圾:可能出現“Concurrent Mode Failure”失敗而導致來一次Full GC的產生(這時會使用serial odl作為CMS的臨時替代收集器)。CMS併發清理階段使用者執行緒還在執行,期間自然會有新的垃圾產生,只能等待下一次GC時在清理,這部分垃圾稱為“浮動垃圾”。另外,由於在垃圾收集階段使用者執行緒還需要執行,那也就是還需要預留足夠的記憶體空間給使用者執行緒使用,因此CMS收集器不能像其他收集器那樣等到老年代幾乎被完全填滿了在進行收集,需要預留一部分空間供併發收集期間的程式運作使用;CMS是一款基於“標記-清除”演算法實現的收集器,這意味著GC後會有大量的空間碎片產生。空間碎片過多將會給大物件分配帶來很大的麻煩,往往會出現老年代還有很大的空間剩餘,但是無法找到足夠大的連續空間分配給當前物件,從而不得不提前觸發一次Full GC。對此CMS提供了一個引數,用於在觸發Full GC時開啟記憶體碎片的合併整理過程,記憶體整理過程是無法併發的,空間碎片問題沒有了,但是停頓時間不得不變長。

  • G1收集器:是當今收集器技術發展的最前沿超過之一。G1是一款面向服務端應用的垃圾收集器,具有如下特點:併發與並行:可以充分利用多CPU、多核環境來縮短“Stop the world”的時間;分代收集:G1可以不需要其他收集器配合就可以獨立管理整個GC堆,但它能夠採取不同的方式去處理新建立的物件和已經存活了一段時間、熬過多次GC的舊的物件以獲得更好的收集效果;空間整合:與CMS的“標記-清理”演算法不同,G1從整體來看是基於“標記-整理演算法”,從區域性(兩個region之間)看是基於“複製”演算法實現的。但無論如何,這兩種演算法意味著G1運作期間不會產生記憶體碎片,這種特性有利於程式長時間執行;可預測的停頓:這是G1相對於CMS的另一大優勢,降低停頓時間是G1和CMS共同的關注點,但G1除了追求低停頓外,還能建立可預測的停頓時間模型,能讓使用者明確指定在一個長度為M毫秒的時間片段內,消耗在在垃圾手機上的時間不得超過N毫秒,這幾乎是實時的java垃圾收集器的特徵了。在G1之前的其他收集器進行收集的範圍都是整個新生代或者老年代,使用G1收集器時,它將整個java堆劃分為多個大小相等的獨立區域,雖然還有新生代和老年代的區別,但是新生代和老年代不再是物理隔離了,它們都是一部分Region(不需要連續)的集合。G1優先回收價值最大的Region(有限時間內獲取儘可能高的效率)。G1收集器的運作大致可劃分為以下幾個步驟:初始標記、併發標記、最終標記、篩選回收。初試標記階段僅僅是隻是標記下GC Roots能直接關聯到的物件,這階段需要停頓執行緒,但是耗時很短;併發標記:從GC Roots開始對堆中物件進行可達性分析,找出活的物件,這部分耗時較長,但是可以與使用者程式併發執行。最終標記:為了修正在併發標記期間因使用者程式繼續執行而導致標記產生變動的那一部分標記記錄,這階段需要停頓執行緒,但是可以並行執行。

另外,注意:jdk9及更新的版本中預設的G1收集器;jdk8預設收集器:新生代GC:Parallel Scanvage收集器;老年代使用:parallel old收集器(個人感覺這是加分項)

 

公眾號後臺回覆“資料”即可獲得2T的學習資料(長期更新ing)以及博主整理好的精品資料一份。2T資料涵蓋各個求職方向,並且每一個方向都有對應的經典專案,可寫入簡歷的大型專案