1. 程式人生 > 實用技巧 >JVM之垃圾收集器

JVM之垃圾收集器

前言

《Java虛擬機器規範》中並沒有關於垃圾收集器的相關章節,所以本篇文章的內容將完全參考周志明老師的書籍,目的同樣是歸納總結形成自己的理解。虛擬機器的執行時資料區可劃分為程式計數器、Java虛擬機器棧、本地方法棧、堆、方法區,其中前三者隨著執行緒的建立而建立,後兩者隨著虛擬機器的啟動而建立,所以線上程結束時,這部分的內容也會隨之回收,而對於方法區來說,有多少類需要載入都只能在執行時才能確定,這部分的記憶體需要動態分配與回收,所以垃圾收集器關注的正是這部分的記憶體區域該如何管理。

哪些物件該被回收

考慮回收之前應該明確哪些物件該被回收,簡單來說就是哪些物件不在使用,書中介紹了引用計數法

可達性分析法

引用計數法:在物件中新增一個引用計數器,有一個地方引用它時,計數器值就加一;當引用失效時,計數器值就減一;任何時刻計數器為零的物件就是不可能在被使用的。這種方式看似很簡單,實際上隱藏著很多缺點,比如它很難解決物件之間相互迴圈引用的問題。

可達性分析法:通過GC Roots作為根物件開始,根據引用關係向下搜尋,搜尋過程所走過的路徑稱為"引用鏈",如果物件到GC Roots間沒有任何引用鏈相連或者說GC Roots到物件不可達時,則證明此物件是不可能在被使用。其中可作為GC Roots物件的Java虛擬機器棧中引用的物件、靜態屬性引用的物件、常量引用的物件、本地方法棧中引用的物件、Java虛擬機器內部的引用、所有持有同步鎖的物件等。

現在已經知道了哪些物件要被回收,下一個問題就是該考慮什麼時候回收、如何回收的問題了,關於這些問題應該具體到某一種垃圾收集器來詳細說明,在這之前先介紹垃圾回收的演算法。

垃圾回收演算法

  • 標記清除

通過上面介紹的方式進行標記,在標記完成後回收所有被標記的物件。看著很簡單實際上存在問題,第一個方面是:執行效率不穩定,當堆中包含大量物件,且這些物件是要被回收了,那麼就必須要進行大量地標記和清除,這兩個動作的執行效率會隨著物件數量的增長而逐漸降低;第二個方面是:記憶體空間的碎片化,在執行標記清除動作後會產生不連續的記憶體碎片,當以後想為大物件分配連續的記憶體時不得不提前觸發下一次垃圾收集,以便騰出更大的記憶體空間來使用。如圖所示:

  • 標記複製

將原有的記憶體空間分為大小相等的兩塊,每次只使用其中一塊,發生垃圾收集時,將正在使用的一塊記憶體中的存活物件複製到另一塊記憶體中(存活物件依次排列),接著清除正在使用的記憶體塊中的所有物件。說說優缺點,一方面是:如果正在使用的記憶體塊中有多數物件是存活的,這種演算法在記憶體間上的複製上將會有一定的開銷,相反,對於有少數存活物件來說,這種演算法就顯得簡單高效;另一方面是:由於每次垃圾收集時都是對其中一塊記憶體區域進行操作,所以在分配記憶體時就不用考慮記憶體的碎片化問題,只需要按順序分配即可;第三方面是:既然將記憶體劃分成兩塊,那麼能使用的記憶體就變得少了。優秀的設計者總會想方設法的規避缺點,提出了更優化的複製策略,將新生代劃分為一塊較大的Eden空間、兩塊較小的Survivor空間(HotSpot虛擬機器預設Eden和Survivor的大小比例是8:1)。其中兩塊Survivor空間又被稱為from、to空間,且大小相等,可進行角色互換,用於存放存活的物件。接下來介紹下這幾個區域是如何相互協作的,關於這部分內容其實我很糾結,周志明老師基本上都是點到為止,沒有提及重要的點,比如兩個Survivor空間是如何協作的,為了能吃透這部分知識還專門看了《實戰Java虛擬機器》,至少提到了相關的內容,但仍然還是不夠精準,比如from區與to區是如何進行角色轉換的,後面又去搜了相關視訊,所以下面闡述的理論更多的是結合以上三方加上自己的理解得出來的。

上面介紹說有一塊Eden區、一塊from區、一塊to區,按照我的理解,Eden區和from區是可以用來分配物件的,to區則是用於存放存活的物件,也就是說將eden區和from區的存活物件複製到to區,然後在將這兩片區域清空,而此時的to區將變成from區,from區變成to區,下次回收仍然按照上面的步驟,這樣子的優化措施提高了記憶體空間的使用率。物件每經過一次垃圾回收後其年齡都會加1,當其年齡達到15歲時就會進入到老年代中,周志明老師還提出並不是所有的物件都會按照指定年齡進入到老年代中,當survivior區中相同年齡的物件大小總和大於survivor區的一半時,其大於或等於該年齡的物件會直接進入到老年代中,還有一種情況就是當to區不足以容納存活的物件時,這些物件將通過分配擔保機制進入到老年代中,如果說老年代的空間也不足以存放物件時,將會觸發Full GC,新生代的垃圾回收叫做Minjor GC。如圖所示:

  • 標記整理

與標記清除不同的是,在標記完成後不是直接清除所有被標記的物件,而是讓所有存活的物件往記憶體空間一側移動,然後在清除掉邊界以外的記憶體。說下優缺點,一方面是:避免了記憶體碎片化,相比標記複製演算法來說記憶體使用率增大了;另外一方面是:在對存活物件進行移動時,為了更新存活物件的引用位置,同時儘可能地減小存活物件之間的引用關係發生變化,在發生回收時需要全程暫停使用者執行緒,這將導致使用者執行緒的響應時間被拉長,整體的吞吐量就會呈現下降的趨勢。如圖所示:

概念

  • 空間分配擔保

從上面的知識點可以知道每次發生Minor GC可能有會物件轉移到老年代中,需要考慮的是老年代中是否有足夠的空間可以存放,也就是說在執行Minor GC之前要先判斷老年代是否有足夠的空間,由於還未執行Minor GC所以並不知道存活的物件有多少個,不過可以判斷老年代的可用空間是否大於新生代所有物件總空間,如果大於,那麼執行Minor GC後將存活的物件轉移到老年代中自然也就沒問題了;如果小於,虛擬機器會先檢視HandlePromotionFailure引數值是否允許擔保失敗;如果允許,會繼續判斷老年代的可用空間是否大於之前晉升到老年代的存活物件的平均大小,也就是拿過往的經驗值來判斷,這種做法具有風險性,就跟賭博一樣...為什麼說有風險呢,經驗只能拿來參考,不具有實際意義,在實際發生Minor GC後仍然有可能造成存活的物件比之前多了多,導致老年代存不下,最終還是回到了要執行Full GC的結局,繞了一圈又繞回來了;如果大於,將執行Minor GC,有可能因為存活物件的增加導致結果執行了Full GC;如果小於或者說HandlePromotionFailure不允許擔保失敗,那就只能老老實實執行Full GC。其實設定HandlePromotionFailure引數值是為了儘可能地避免執行Full GC,因為它會增大使用者執行緒的執行時間,Java 6之後的規則變成了老年代的可用空間大於新生代所有物件總大小或之前晉升的平均大小,則執行Minor GC,否則執行Full GC,也就是說直接預設HandlePromotionFailure引數值為true了,不在判斷是否允許擔保失敗了。

  • Stop The World

如果老年代滿了則觸發Full GC,同時回收新生代和老年代,它會造成Stop The World,造成很大的開銷。Stop The World指的是在執行GC期間,只有垃圾收集執行緒在工作,其他使用者執行緒會被掛起。

  • Safe Point

我們知道判斷物件是否存活是通過GC Roots,而在程式的執行過程中其引用關係可能會發生變化,總不可能一旦發生變化就記錄,而我們知道只有垃圾收集器會使用到它,那為什麼不在垃圾收集前在更新呢,因為垃圾收集會影響效能,所以不能在任意位置觸發GC,而是要求必須在一個適合的位置才被允許,這個合適的位置稱為安全點,也就是在此位置更新了GC Roots,

  • Safe Region

還有一種場景,當程式沒有被分配處理器時間片的話,也就是說當用戶執行緒處於Sleep狀態或Blocked狀態,這時候執行緒無法響應虛擬機器的中斷請求,不能走到安全點去中斷掛起自己,虛擬機器顯然也無法等待執行緒被啟用,針對這種情況引入了安全區域,指能夠確保在某一段程式碼片段之中,引用關係不會發生變化,也就是說GC Roots是不會發生變化的,所以在這塊區域的任意地方開始垃圾收集都是安全的。當用戶執行緒執行到安全區域裡的程式碼時,首先會標識自己已經進入了安全區域,在這段時間裡虛擬機器可以直接發起垃圾收集;當執行緒要離開安全區域時,它要檢查虛擬機器是否已經完成GC Roots掃描,如果已經完成則執行緒繼續執行,當作什麼都沒有發生過,否則就要一直等待著,直到收到可以離開安全區域的訊號為止。

  • 記憶集與卡表

根據物件的存活時間長短來將其分配到新生代或老年代,但有可能存在新生代與老年代的物件互相引用,為了避免把關聯區域(某個區域的物件被其他區域的物件所引用,其他區域就可以稱作關聯區域)的所有物件都加入到GC Roots掃描範圍(為了保證可達性的準確性,通常會將關聯區域的物件也一併加入到GC Roots),引出來了記憶集:一種用於記錄非收集區域指向收集區域的指標集合的抽象資料結構,簡單來說是非收集區域是否存在有指向了收集區域的指標。個人理解關聯區域指的就是非收集區域(不一定正確),而卡表就是記憶集的具體實現,就跟HashMap與Map的關係一樣。卡表是一個位元組陣列,陣列中的每一個元素都對應著其標識的記憶體區域中一塊特定大小的記憶體塊,這個記憶體塊被稱作卡頁,只要卡頁內有一個物件的欄位存在著跨代引用,那就將卡表的陣列元素的值標識為1,稱這個元素便髒了,沒有則標識為0,就不需要在將關聯區域中的所有物件都加入到GC Roots掃描範圍中了,只需要篩選出卡表中變髒的元素,就能知道哪些記憶體塊包含跨代引用,將其加入到GC Roots。關於記憶集與卡表的描述其實很模糊,書中闡述的知識點其實也就這麼多。

垃圾收集器

  • Serial/Serial Old收集器

Serial收集器工作在新生代,通過單執行緒的方式,也就是說只有使用一個處理器或者說一個收集執行緒去完成垃圾收集工作,不僅如此,在它進行垃圾收集時,必須暫停其他使用者執行緒(STW),直到它收集結束。Serial Old收集器則是工作在老年代,同時也可以作為CMS收集器(另一款收集器)的替代品。Serial/Serial Old雖然是歷史最悠久的收集器,但即使到了現在仍然有它的一席之地。如圖所示:

優點:依然是HotSpot虛擬機器執行在客戶端模式下的預設收集器,相比於其他收集器來說,簡單高效是其一大特點;對於記憶體資源受限或者處理器核心數較少的環境來說,由於沒有執行緒互動的開銷,專心做垃圾收集自然可以獲得最高的單執行緒收集效率。

缺點:暫停使用者執行緒導致響應時間變長,很是影響體驗。

使用場景:只要適當降低停頓時間讓使用者不可知仍然是一款優秀的收集器,適用於虛擬機器佔用記憶體小的應用,比如使用者桌面的應用場景、微服務,只要給虛擬機器分配較少的記憶體,其垃圾收集器造成的停頓時間完全可以控制在幾十、最多一百毫秒內,所以適用於客戶端模式下的虛擬機器。

  • ParNew收集器

ParNew收集器工作在新生代,本質上是Serial收集器的多執行緒並行版本,除此之外,其餘的行為包括垃圾演算法、STW、物件分配規則、回收策略等都與Serial收集器完全一致。除了Serial收集器之外,目前就只有它能與CMS(另一款收集器)配合工作。如圖所示:

優點:由於其多執行緒的特性加快了垃圾收集,降低了使用者執行緒的停頓時間。

缺點:仍然需要停頓時間。

使用場景:其多執行緒特性註定了它只能在多核心處理器的環境下使用,倘若在單核心處理器下使用,由於存線上程互動的開銷並不一定會比Serial優秀,很大程度上降低了停頓時間,說明提升了響應時間,所以適合於服務端模式下的虛擬機器。

  • Parallel Scavenge收集器

Parallel Scavenge收集器工作在新生代,也是一款能夠並行收集的多執行緒收集器,與ParNew或者其他收集器不同的是其關注點,ParNew等收集器關注的是儘可能地縮短使用者執行緒的停頓時間,而Parallel Scavenge收集器的目標是達到一個可控制的吞吐量,所謂吞吐量就是處理器用於執行使用者程式碼的時間與處理器總消耗時間的比值,即吞吐量 = 執行使用者程式碼時間/(執行使用者程式碼時間 + 執行垃圾收集時間)。Parallel Scavenge收集器提供了一個引數,啟用該引數後就不需要人工指定新生代的大小、Eden與Survivor區的比例、晉升老年代物件的大小等細節引數了,虛擬機器會根據當前系統的執行情況收集效能監控資訊,動態調整這些引數以提供最合適的停頓時間或者最大的吞吐量,這種方式被稱為自適應的調節策略。如圖所示:

優點:由於其多執行緒的特性加快了垃圾收集,降低了使用者執行緒的停頓時間;自適應調節策略。

缺點:仍然需要停頓時間。

使用場景:高吞吐量則可以最高效率地利用處理器資源,儘快完成程式的運算任務,適合在後臺運算而不需要太多互動的分析任務。

  • Parallel Old收集器

Parallel Old是Parallel Scavenge收集器的老年代版本,支援多執行緒併發收集,兩者的組合實現了真正的吞吐量優先。如圖所示:

優點:由於其多執行緒的特性加快了垃圾收集,降低了使用者執行緒的停頓時間。

缺點:仍然需要停頓時間。

使用場景:在注重吞吐量的場合,組合Parallel Scanvenge收集器一起使用。

  • CMS收集器

CMS(Concurrent Mark Sweep)收集器工作在老年代,上面提到的老年代收集器都是採用標記整理,CMS採用的是標記清除,其運作過程相對來說更加複雜,整個過程分為四個步驟:1、初始標記;2、併發標記;3、重新標記;4、併發清除。

初始標記:僅僅只是標記一下GC Roots能直接關聯到的物件,速度很快。

併發標記:從GC Roots的直接關聯物件開始遍歷整個物件圖的過程,該過程耗時較長但不需要暫停使用者執行緒,可以與垃圾收集執行緒一起併發執行。

重新標記:為了修正併發標記期間,因使用者程式繼續運作而導致標記產生變動的那一部分物件的標記記錄,該過程的停頓時間通過會比初始標記過程稍長一些,但也遠比並發標記過程的時間短。

併發清除:清除標記已經死亡的物件,由於不移動存活物件(標記清除),所以該過程也是可以與使用者執行緒同時併發的。

如圖所示:

優點:部分過程與使用者執行緒併發執行,極大程度地降低停頓時間。

缺點:1、CMS收集器對處理器資源非常敏感,在併發過程,雖然不會導致使用者執行緒停頓,但卻會因為佔用了一部分執行緒而導致應用程式變慢,降低總吞吐量,如果應用本來的處理器負載就很高,還要分出一半的運算能力去執行收集器執行緒,就可能導致使用者程式的執行速度忽然大幅度降低;2、在CMS的併發標記與併發清除過程,使用者執行緒是還在繼續執行的,程式在執行自然就還會伴隨有新的垃圾物件不斷產生,但這一部分垃圾物件是出現在標記過程結束以後,CMS無法在當次收集中處理掉它們,只好留待下一次垃圾收集時再清理掉,這一部分垃圾稱為"浮動垃圾"。同樣也是由於在垃圾收集過程中使用者執行緒還需要持續執行,那就還需要預留足夠的記憶體空間提供給使用者執行緒使用,因此CMS收集器不能像其他收集器那樣等待到老年代幾乎完全被填滿了再進行收集,必須預留一部分空間供併發收集時的程式運作使用,要是CMS執行期間預留的記憶體無法滿足程式分配新物件,就會出現一次"併發失敗",這時候虛擬機器將會凍結使用者執行緒的執行,臨時啟用Serial Old收集器來重新進行老年代的收集,而Serial Old收集器採用的單執行緒進行收集,將會使停頓時間更長;3、CMS採用的是標記清除,該演算法將會產生大量的記憶體碎片,碎片太多將會導致無法給大物件分配空間,而結果就是不得不提前觸發一次Full GC。

使用場景:適用於要求低停頓時間或者響應時間的應用程式。

  • G1收集器

G1(Garbage First)收集器不存在說工作在新生代或者老年代了,它將堆分成多個大小相等的獨立區域(Region),每一個Region可以根據需要扮演新生代的Eden空間、Survivor空間或者老年代空間,能夠對扮演不同角色的Region採用不同的策略去處理。還有一類特殊的Humongous區域,專門用來儲存大物件,G1認為只要大小超過Region容量一半的物件即可判定為大物件,對於超過整個Region容量的超級大物件,將會被存放在N個連續的Humongous區域中。如圖所示:

繼續說說它是如何工作的,它不在對整個堆進行全區域的垃圾收集,而是優先回收有"價值"的Region,G1收集器會跟蹤每個Region裡垃圾堆積的"價值"大小,其中價值指的是回收所獲得的空間大小以及回收所需要時間的經驗值,然後在後臺維護一個優先順序列表,每次根據使用者設定的停頓時間來優先處理回收價值收益最大的Region,保證在有限的時間內儘可能高的收集效率。整個過程分為四個步驟:1、初始標記;2、併發標記;3、最終標記;4、篩選回收。前面三個同CMS收集器基本相似就不過多介紹了,說說第四個過程。

篩選回收:標記結果後就應該更新每個Region的"價值",對這些Region進行排序,並根據使用者期望的停頓時間制定計劃,可以自由選擇任意多個Region構成回收集,然後把決定回收的那一部分Region的存活物件複製到空的Region中,在清理掉整個舊Region的全部空間。

如圖所示:

優點:在有限的停頓時間內儘可能獲取高吞吐量;不會產生記憶體碎片化;

缺點:1、我們知道通過維護記憶集來避免全域性掃描GC Roots,既然分成了多個Region,那麼Region中的物件自然也會存在跨代引用的情況,所以每個Region自然都要維護一個記憶集,結果就是它所需要的記憶體可能比傳統的收集器更高;2、同樣是與使用者執行緒併發執行,自然也要像CMS那樣預留空間給使用者執行緒使用來建立新物件,如果記憶體的收集速度趕不上記憶體分配的速度,同樣會出現"併發失敗",結果也是要凍結使用者執行緒執行Full GC。

使用場景:適用於大記憶體的應用且可控停頓時間的應用程式。

結束語

以上的幾款收集器都只是停留在理論上,更多的還是應該根據不同的應用進行實際測試才能得出最合適的結論,高版本的JDK中還出現了ZGC收集器,理解起來挺費勁的,有興趣的讀者可以去了解下,畢竟JDK9還沒玩的人怎麼敢去碰這些呢。

參考連結

《深入Java虛擬機器》
《實戰Java虛擬機器》
https://mp.weixin.qq.com/s/_AKQs-xXDHlk84HbwKUzOw