垃圾回收算法的原理及應用

概述

有java開發經歷的小夥伴必然對垃圾回收不陌生。垃圾回收簡單來說就是一種自動的內存管理機制,當一個電腦上的某一塊內存不在被使用時,就應該釋放,供其他應用利用,而對該內存的回收過程,就稱之爲垃圾回收

那如何進行垃圾回收那?

簡單來說只有兩步:

第一步,找到垃圾。

第二步,將垃圾扔掉。

好,既然需要找到垃圾,那肯定需要對垃圾有一個判別標準,即"什麼是垃圾?"

什麼是垃圾?

就計算機而言,不在使用的內存空間就都可以稱之爲垃圾。在java中一切皆爲對象,因而在java中,那些已經“死去”不再被使用的對象就可以稱之爲垃圾

如何找到垃圾?

我們知道 JVM在運行程序的過程中,必然會伴隨着對象的創建與消亡,並且具體創建哪些對象,創建多少對象,這部分在程序運行前是不知道的,因而針對對象的分配和回收是動態的。所以在做垃圾回收之前,我們必須要有算法來區分這些“活着”的對象以及“死去”的對象。

引用計數算法

該算法整個過程如下:

首先在對象中添加一個計數器,每當有一個地方用到該對象的時候就將該計數器數加一;當引用失效時,計數器的值減一;當一個計數器的值減爲零時,則認爲該對象是不可能被使用的,即爲“垃圾”對象。

客觀來講,雖然引用計數算法佔據了一些額外的空間,但其算法原理簡單,而且判定效率很高,因此它有着廣泛的應用。

但該算法也有着其不足的一面,比如該算法很難解決對象的相互引用問題

例如下邊的兩個對象,對象A和對象B,兩者相互引用。

image.png

因而導致即使對象A和對象B都不再使用,它們的引用計數器也不爲零,如果單純的使用引用計數算法,那麼對象A和對象B永遠不可能被定義爲垃圾對象而被回收。

基於以上原因,當前主流的商用的程序語言的內存管理系統,都是通過可達性分析(Reachability Analysis)算法來判斷對象是否存活的。

可達性分析算法

可達性分析算法判斷一個對象是否存活的過程,就像一顆森林前序遍歷,首先以一系列(注意根節點不止一個)稱之爲GC Root的根對象作爲起始節點,根據對象的引用關係開始向下搜索,搜索過程中所走過的路徑,稱爲引用鏈,如果一個對象到GC Root間沒有任何引用鏈,則認爲該對象是可被回收的

以下圖爲例

image.png

Object5、Object6、Object7即使相互引用,但由於和GC Roots沒有任何引用鏈相連,這三個對象也是可回收的。

前邊我們說了GC Roots是一系列根節點,那究竟什麼樣的對象可以作爲GC Roots

在java技術體系中,固定可以作爲GC Roots的對象包括以下幾種:

  • 在虛擬機棧中所引用的對象,譬如各個線程被調用的方法堆棧中所使用到的參數、局部變量、臨時變量。
  • 在方法區中類靜態屬性引用的對象,譬如java類的引用類型靜態變量。
  • 本地方法中常量引用的對象,比如字符串常量池中的引用。
  • 本地方法棧中JNI(Native 方法)引用的對象。
  • java虛擬機的內部引用,比如基本數據類型對應的Class對象,一些常駐的異常對象等,還有類加載器。
  • 所有被同步鎖(synchronized關鍵字)持有的對象。
  • 反映Java虛擬機內部情況的JMXBean、JVMTI中註冊的回調、本地代碼緩存等。

當然除了這些固定的GC Roots集合之外,根據用戶所選用的垃圾收集器以及當前回收的內容區域,也有一些其他的對象會被放到GC Roots集合中,此處不再詳述。

"再談引用"

縱觀可達性分析算法執行的整個過程,對象的引用關係是及其重要的,但是否所有的引用都能夠構成引用鏈?

JDK 1.2之前Java中對引用的定義十分傳統,如果reference類型的數據中存儲的數值代表另一塊內存的起始地址,則該reference數據就代表了某塊內存或者某個對象的引用。按照這個定義,一個對象的引用狀態只有兩種,被引用或者未被引用

但這樣對引用的狹義定義在描述一些“可有可無”對象時,就會顯得“力不從心”。比如描述一個對象在內存充足時可以駐留內存;當內存空間不足時,可被回收,釋放空間。這裏可能有些同學會有疑問,我們爲什麼會需要一個“可有可無”的對象的呢?

我們以一個例子來說明這個“可有可無對象”存在的必要性:

當我們點擊一個瀏覽器的回退按鈕時,回退時顯示的網頁應該重新請求還是應該從緩存中取?

這和瀏覽器的具體實現策略有關,如果一個網頁在瀏覽結束就被回收,那麼只能重新請求。如果瀏覽過的網頁存於緩存中,那便可以從緩存中取。但如果全部都存緩存中,可能會造成大量內存的浪費,甚至會造成OOM。

但如果我們把一個網頁對象定義成一個“可有可無”的對象,豈不完美的解決了這個問題?如果空間足夠就緩存網頁,增強用戶體驗,如果內存不足,回收空間,提高空間利用率😄 !!!

按照引用存在的必要性,將引用分成了四種:

強引用

強引用是最傳統的引用,也就是程序代碼之間普遍存在的引用賦值`,比如下邊的代碼:

Object obj = new Object();

內存空間不足時,JVM虛擬機寧可跑出OutOfMemoryError錯誤,使程序異常終止也不會隨意會有具有強引用的對象來解決內存不足的問題。

如果強引用對象不使用時,需要弱化引用從而能夠使GC回收,例如爲引用賦空值。

obj = null;

軟引用

軟引用就是前邊所說的"可有可無對象" ,當內存空間充足時,GC不會回收它,當內存空間不足時纔會進行回收,並且在回收時,會按照那些長時間不用的弱引用對象,有點類似於"LRU算法"。

軟引用創建可以通過SoftReference類來創建:

    // 強引用
    String strongReference = new String("abc");
    // 軟引用
    String str = new String("abc");
    SoftReference<String> softReference = new SoftReference<String>(str);

弱引用

弱引用對象也是用來描述那些可有可無的對象,但它的強度比軟引用要更弱一些,它只能生存到下一次垃圾收集發生之前,當垃圾收集器開始工作時,無論當前內存是否足夠,弱引用對象都會被回收。我們可以通過WeakReference來創建一個弱引用對象:

 WeakReference<String> weakReference = new WeakReference<>(str);

虛引用

虛引用 顧名思義,就是形同虛設。與其他幾種引用不同,虛引用完全不會影響其所指向對象的生命週期,也無法通過該引用獲取對象的實例,它存在的主要作用就是用來跟蹤對象 被垃圾回收器回收 的活動,即爲了能在這個對象被垃圾回收時,收到一個通知。可以通過PhantomReference類來創建一個虛引用。

垃圾一定馬上就會被回收嗎?

那些被可達性分析算法,判定爲不可達的對象,就一定會被回收嗎?

答案明顯是否定的,真正宣告一個對象死亡,需要經過兩次標記過程。

我們藉助一個流程圖來說明

image.png

首先一個對象在進行可達性分析後發現沒有與GC Root相連的引用鏈,就會對該對象就行第一次標記,隨後進行一次篩選,篩選條件是否有必要執行finilize()方法。而判定finilize()需要被執行的情況只有一種:finilize()方法被重寫過,且從來沒有被調用過 。這兩條任何一條不滿足,虛擬機都會認爲finilize()方法不需要執行。

這裏的finilize()方法也是逃脫死亡命運的最後機會,後續對象就會正式進入回收流程無法拯救。具體流程如下:

  1. 將待回收對象放到到F-Queue隊列中
  2. 等待收集器F-Queue隊列中的對象進行第二次標記。
  3. 標記後的對象會進入到待回收集合中,等待收集器回收。
  4. 至此對象回收結束。

前邊我們說了finilize()方法是對象回收自己的最後一次機會,那我們該如何“抓住”這個機會來拯救對象呢?

簡單來說,只要我們在重寫finilize()後,將該對象重新與引用鏈上的任意對象建立引用鏈即可,比如將自己的this關鍵字賦值給類變量或者對象的成員變量即可。具體代碼如下:

Public class FinalizeEscapeGC {
	public static FinalizeEscapeGC SAVE_HOOK = null;
	@Override
	protected void finilize() throws Exception {
		super.finalize();
		FinalizeEscapeGC.SAVE_HOOK = this;
	}
}

上述對象在第一次被垃圾回收時,就會觸發finilize()方法完成對對象的第一次拯救。但其拯救行動只會又一次。當下一次垃圾回收被觸發時,對象依然會被回收,因爲在第二次垃圾回收時,finilize()方法已經被執行過一次,會被判定沒有必要被執行,直接進入回收流程。

如何對垃圾進行收集?

前邊我們已經知道了如何判定某個對象是否是垃圾,但如果想要對垃圾進行回收還需要藉助於垃圾回收算法來對垃圾進行收集。

具體垃圾收集算法關係如下圖所示:

- 垃圾回收算法
  - 引用計數式垃圾收集
    - java虛擬機中未使用
  - 追蹤式垃圾收集
    - 標記-清除算法
    - 標記-複製算法
    - 標記-整理算法

在正式垃圾具體的垃圾算法,我們需要先了解一下垃圾的分代收集理論。因爲當前的垃圾收集器都基於該理論進行設計。

"分代垃圾回收理論"

分代收集理論名爲理論`實際上就是一套符合大多數程序運行實際情況的經驗法則,它建立在兩個分代假說之上:

  1. 弱分代假說: 絕多數對象都是朝生夕滅的,即存活時間很短。
  2. 強分代假說:熬過越多次垃圾回收的對象,就越難以消亡。

基於以上原則,java收集器將java堆分成了不同的區域,然後需要回收的對象根據其年齡分配到不同的區域中進行存儲。這樣,如果一個區域中大多數對象都是朝生夕滅,難以熬過垃圾收集過程的話,就把它們放在一起,每次回收都重點關注那些少量存活的對象,而不需要去標註那些大量需要被回收的對象,這樣就可以以較低的代價回收大量的空間。對於那些難以消亡的對象,把它們放在一起,虛擬機便可以以較低的頻率來回收這個區域,從而就兼顧了垃圾回收的時間開銷和空間利用率。

另外由於對象進行了分代,因而免不了會有跨代引用的情況發生,爲了解決該問題,就提出了第三條經驗法則:

  1. 跨代引用假說: 跨代引用相對於同代引用來說僅僅佔了極少數

基於這條理論,我們就沒有必要爲了少量的跨代引用區掃描整個老年區,也沒有必要梁飛空間專門記錄每一個對象是否存在以及存在哪些跨帶引用,只需要在新生代上建立起一個全局的數據結構--"記憶集" 來解決。

標記-清除算法

首先最早出現也是最基礎的垃圾回收算法是“標記-清除”算法,它回收的整個過程如下圖所示:

image.png

該算法如其名稱一樣,分成“標記”和“清除”兩個階段:

首先標記處所有需要回收的對象,然後統一進行回收,當然反之也是可以的,標記存活的對象,然後統一回收未被標記的對象。

但該算法有兩個明顯的缺點:

  1. 執行效率不穩定,標記和清除的執行效率會隨着對象數量的增長降低
  2. 空間問題,可能造成大量的內存碎片。

標記-複製算法

爲了解決標記-回收算法存在的兩個問題,有人提出了半區複製算法,它將內存空間分成大小相等的兩塊,每次只是用其中的一塊,當這一塊內存空間使用完後,一次性將該塊空間的內容複製到另一塊空間中,然後清空該塊空間。

image.png

這個算法雖然引入了複製開銷,但是有效的解決了空間碎片的問題,但這種算法的缺陷也顯而易見,就是將可用的內存縮小到了原來的一半,空間利用率降低。

爲了提高空間的利用率,大神Appel根據對象“朝生夕滅”的特點,提出了一種更加優化的分區複製分代策略,稱之爲Appel式回收。

虛擬機將java堆分成新生代老年代,新生代又被分成Eden區From區To區三塊區域。

其中我們把 Eden : From Survivor : To Survivor 空間大小設成 8 : 1 : 1,新創建的對象總是Eden區創建,From區存放當前存活的對象。To區爲空,一次gc發生後:

Eden區中存活的對象和From區中的對象複製到to區,然後清空Eden區和From區;交換From區和To區的邏輯關係,即From區變成To區,To區變成From區。

整個過程中,可以看出只有Eden區快慢的時候纔會觸發Miror GC(新生代垃圾回收),而Eden區佔整個新生代的大多數,因而Miror GC的頻率大爲降低。

這裏可能會有小夥伴問,爲何要預留一個To空間來複製

這個主要是爲了保證程序運行的實時性,執行的過程中可以不停頓。因爲當前使用的空間如果直接回收,強制終止當前運行的進程,影響程序執行的實時性

同時能夠解決在垃圾回收過程中產生的內存碎片的問題,提供空間的利用率

那這個8:1:1這個比例又是怎麼來的?

這個是HotSpot虛擬機的默認設置,也即爲新生代中可被回收的內存空間爲整個新生代容量的90%。當然由於這個比例是在僅僅是在“普通場景”下測試得來的,在實際運行時,誰也無法保證每次只有報超過10%的對象可被回收。因此Appeal式回收又增加了一個逃生門設計。當Survior空間不足以容納一次Minor GC之後存活的對象時,就需要依賴其他內存區域(大多數情況下老年代)來進行擔保分配

具體分配時就是在to Surivor空間沒有足夠空間存放上一次新生代收集下來的存活對象,這些對象就可以通過擔保分配機制直接進入老年代

當然新生代對象直接進入老年代會有一些風險,因此在時間分配時,虛擬機會執行以下邏輯:

  1. 判斷老年代連續空間是否大於新生代所有對象總空間,如果滿足該條件,則可以保證此次Minor GC是安全的。
  2. 如果不成立則查看-XX:HandlePromotionFailture參數是否允許擔保失敗,不允許的話,直接進行一次Minor GC
  3. 如果允許則繼續檢查老年代最大可用空間是否大於歷次晉升到老年代對象的平均大小,如果大於,就進行Minor GC,如果小於則要進行一次Full GC

標記-整理算法

優化後的Appel算法似乎非常好用,但它必須要要有空間來進行"擔保分配",比如新生代在使用該算法時,可以通過老年代來進行擔保。但老年代使用的時候怎麼辦?似乎並沒有額外的空間可以爲老年代提供擔保,因而老年代一般不能直接使用該算法。

針對老年代對象的存亡特徵,一位大神提出了標記-整理算法,其中標記過程和原來一樣,但是後續步驟不是直接對可回收的對象進行回收,而是把所有存活的對象向內存的一端進行移動,然後直接清理掉邊界以外的內存。 具體的過程如下圖所示:

image.png

當然由於這個標記-整理算法是一個移動式回收算法,因而每次回收開銷極大,會出現“Stop The World”現象,會影響程序的實時性,造成延遲。

因而該算法會用在關注高吞吐量的收集器上,比如Parallel Scavenge收集器,而關注低延遲的收集器比如CMS收集器則會使用標記-清除算法,在空間碎片過多時再通過標記-整理算法來對碎片進行整理。

垃圾收集算法工程實現

前邊講的各種垃圾回收算法更多的只是停留的理論層面之上,那它們是如何在工程上得到應用的呢?

下邊我們展開來說:

- 垃圾收集器
	- Serial收集器
	- ParNew收集器
	- Parallel Scavenge收集器 
	- CMS收集器
	- G1收集器

Serial 收集器

Serial收集器是最古老的垃圾收集器,從它的名字Serial(串行)就大概能夠猜出它是一個單線程工作的垃圾收集器,它在工作時,必須暫停其他線程的工作直到它完成收集工作。其工作的示意圖如下所示:

image.png

很明顯對於該收集器會有Stop The World情況發生,但它也不是一無是處,由於其簡單而高效(與其他收集器的單線程相比) 。Serial 收集器由於沒有線程交互的開銷,自然可以獲得很高的單線程收集效率。Serial 收集器對於運行在 Client 模式下的虛擬機來說是個不錯的選擇。

ParNew收集器

ParNew 收集器其實就是 Serial 收集器的多線程版本,除了使用多線程進行垃圾收集外,其餘行爲(控制參數、收集算法、回收策略等等)和 Serial 收集器完全一樣。

新生代採用標記-複製算法,老年代採用標記-整理算法。

image.png

它是許多在Server模式下的虛擬機的首要選擇,除了Serial收集器之外,它只能和"CMS收集器"配合工作。

Parallel Scavenge收集器

Parallel Scaveng收集器從表面的一些特性跟PraNew收集器一樣,比如新生代都是通過標記-複製算法來收集,老年代則採用標記-整理算法來收集

但它最大的不同點在於:與CMS收集器儘可能縮短垃圾收集時用戶線程的停頓時間,Parallel Scavenge收集器的目標則是達到一個可控制的吞吐量(高效地利用CPU資源)

所謂吞吐量就是 CPU 中用於運行用戶代碼的時間與 CPU 總消耗時間的比值。

Serial Old收集器

Serial 收集器的老年代版本 ,它同樣是一個單線程收集器。它主要有兩大用途:一種用途是在 JDK1.5 以及以前的版本中與 Parallel Scavenge 收集器搭配使用,另一種用途是作爲 CMS 收集器的後備方案。

Parallel Old收集器

Parallel Scavenge 收集器的老年代版本 。使用多線程和“標記-整理”算法。在注重吞吐量以及 CPU 資源的場合,都可以優先考慮 Parallel Scavenge 收集器和 Parallel Old 收集器。

CMS收集器

CMS(Concurrent Mark Sweep)收集器是一種以獲取最短回收停頓時間爲目標的收集器。它非常符合在注重用戶體驗的應用上使用。

CMS(Concurrent Mark Sweep)收集器是 HotSpot 虛擬機第一款真正意義上的併發收集器,它第一次實現了讓垃圾收集線程與用戶線程(基本上)同時工作。

從名字中的Mark Sweep 這兩個詞可以看出,CMS 收集器是一種 “標記-清除”算法 實現的,它的運作過程相比於前面幾種垃圾收集器來說更加複雜一些。整個過程分爲四個步驟:

  • 初始標記: 暫停所有的其他線程,標記與GC Roots直接相連的對象,速度很快 ;
  • 併發標記: 同時開啓 GC 和用戶線程,從GC Roots直接相連的對象出發,遍歷整個對象圖
  • 重新標記: 重新標記階段就是爲了修正併發標記期間因爲用戶程序繼續運行而導致標記產生變動的那一部分對象的標記記錄,這個階段的停頓時間一般會比初始標記階段的時間稍長,遠遠比並發標記階段時間短
  • 併發清除: 開啓用戶線程,同時 GC 線程開始對未標記的區域做清掃。

image.png

從它的名字就可以看出它是一款優秀的垃圾收集器,主要優點:併發收集、低停頓 。但是它有下面三個明顯的缺點:

  • 對 CPU 資源敏感; 當CPU核心數少的時候,導致應用程序執行速度突然大幅度變慢。
  • 無法處理浮動垃圾,因爲它在回收的過程中,用戶進程繼續運行會伴隨這新的垃圾產生,而這部分垃圾需要等到下次GC時才能被回收。
  • 它使用的回收算法-“標記-清除”算法會導致收集結束時會有大量空間碎片產生。

G1收集器

G1 (Garbage-First) 是一款面向服務器的垃圾收集器,主要針對配備多顆處理器及大容量內存的機器. 以極高概率滿足 GC 停頓時間要求的同時,還具備高吞吐量性能特徵.

在空間劃分上,較之於之前的收集器最大不同在於,G1收集器不再堅持固定大小以及固定數量的分代區域劃分而是把連續的Java堆劃分成多個大小相等且獨立的區域(Region),每個區域都可以根據其需要扮演Eden空間,Surivor空間或者老年代空間。

同時因爲它將Region作爲單次回收目標,它可以建立可預測的時間模型,來提高回收的效率,優先回收那些價值高的空間(單位時間內回收的空間大)。

image.png

具體來說G1收集器的運作過程分爲以下四個步驟:

  1. 初始標記:標記與GC Roots直接相連的對象,修改TAMS指針。
  2. 併發標記
  3. 最終標記
  4. 篩選回收:負責更新Region的統計數據,對各個Region的回收價值和成本進行排序,然後將需要回收的那部分Region複製到空的Region中,然後清理掉舊的Region

CMS回收期和G1回收器都十分優秀,但具體場景上,如果內存較小(小於6個G)那麼CMS收集器會更佔優勢,反之則G1收集器會更好。

總結

本篇文章我們主要總結jvm虛擬機在進行垃圾回收時所使用的算法和原理以及其工程實現,縱觀這麼多垃圾回收算法,我們發現並沒有一個萬金油式的算法,每種算法以及垃圾收集器都是爲了解決某一類問題而設計出來的,都有對應的Trade Off,需要我們根據應用場景加以甄別使用。

引用

  1. 理解Java的強引用、軟引用、弱引用和虛引用
  2. JVM垃圾回收
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章