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收集器(個人感覺這是加分項)

補充:full gc也會清理沒有堆內對象引用的direct buffer,不過direct buffer不會觸發cms的gc,所以使用cms小心堆外內存oom,參數-XX:MaxDirectMemorySize調整大小

DirectBuffer內存回收主要有兩種方式,一種是通過System.gc來回收,另一種是通過構造函數裏創建的Cleaner對象來回收。 這也是面試常考知識點之一。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章