JVM垃圾回收(下)

wyfem發表於2021-09-09

接著上一篇,介紹完了 JVM 中識別需要回收的垃圾物件之後,這一篇我們來說說 JVM 是如何進行垃圾回收。

首先要在這裡介紹一下80/20 法則:

約僅有20%的變因操縱著80%的局面。也就是說:所有變數中,最重要的僅有20%,雖然剩餘的80%佔了多數,控制的範圍卻遠低於“關鍵的少數”。

Java 物件的生命週期也滿足也這樣的定律,即大部分的 Java 物件只存活一小段時間,而存活下來的小部分 Java 物件則會存活很長一段時間。

因此,這也就造就了 JVM 中分代回收的思想。簡單來說,就是將堆空間劃分為兩代,分別叫做新生代老年代。新生代用來儲存新建的物件。當物件存活時間夠長時,則將其移動到老年代。

這樣也就可以讓 JVM 給不同代使用不同的回收演算法。

對於新生代,我們猜測大部分的 Java 物件只存活一小段時間,那麼便可以頻繁地採用耗時較短的垃圾回收演算法,讓大部分的垃圾都能夠在新生代被回收掉。

對於老年代,我們猜測大部分的垃圾已經在新生代中被回收了,而在老年代中的物件有大機率會繼續存活。當真正觸發針對老年代的回收時,則代表這個假設出錯了,或者堆的空間已經耗盡了。此時,JVM 往往需要做一次全堆掃描,耗時也將不計成本。(當然,現代的垃圾回收器都在併發收集的道路上發展,來避免這種全堆掃描的情況。)

那麼,我們先來看看 JVM 中堆究竟是如何劃分的。

堆劃分

按照上文所述,JVM 將堆劃分為新生代和老年代,其中,新生代又被劃分為 Eden 區,以及兩個大小相同的 Survivor 區。

圖片描述

通常來說,當我們呼叫 new 指令時,它會在 Eden 區中劃出一塊作為儲存物件的記憶體。由於堆空間是執行緒共享的,因此直接在這裡邊劃空間是需要進行同步的。否則,將有可能出現兩個物件共用一段記憶體的事故。

JVM 的解決方法是為每個執行緒預先申請一段連續的堆空間,並且只允許每個執行緒在自己申請過的堆空間中建立物件,如果申請的堆空間被用完了,那麼再繼續申請即可,這也就是 TLAB(Thread Local Allocation Buffer,對應虛擬機器引數 -XX:+UseTLAB,預設開啟)。

此時,如果執行緒操作涉及到加鎖,則該執行緒需要維護兩個指標(實際上可能更多,但重要也就兩個),一個指向 TLAB 中空餘記憶體的起始位置,一個則指向 TLAB 末尾。

接下來的 new 指令,便可以直接透過指標加法(bump the pointer)來實現,即把指向空餘記憶體位置的指標加上所請求的位元組數。

如果加法後空餘記憶體指標的值仍小於或等於指向末尾的指標,則代表分配成功。否則,TLAB 已經沒有足夠的空間來滿足本次新建操作。這個時候,便需要當前執行緒重新申請新的 TLAB。

那有沒有可能出現申請不到的情況呢?有的,這個時候就會觸發Minor GC了。

Minor GC

所謂 Minor GC,就是指:

當 Eden 區的空間耗盡時,JVM 會進行一次 Minor GC,來收集新生代的垃圾。存活下來的物件,則會被送到 Survivor 區。

上文提到,新生代共有兩個 Survivor 區,我們分別用 from 和 to 來指代。其中 to 指向的 Survivior 區是空的。

當發生 Minor GC 時,Eden 區和 from 指向的 Survivor 區中的存活物件會被複制到 to 指向的 Survivor 區中,然後交換 from 和 to 指標,以保證下一次 Minor GC 時,to 指向的 Survivor 區還是空的。

JVM 會記錄 Survivor 區中每個物件一共被來回複製了幾次。如果一個物件被複制的次數為 15(對應虛擬機器引數 -XX:+MaxTenuringThreshold),那麼該物件將被晉升(promote)至老年代。

另外,如果單個 Survivor 區已經被佔用了 50%(對應虛擬機器引數 -XX:TargetSurvivorRatio),那麼較高複製次數的物件也會被晉升至老年代。

總而言之,當發生 Minor GC 時,我們應用了標記 - 複製演算法,將 Survivor 區中的老存活物件晉升到老年代,然後將剩下的存活物件和 Eden 區的存活物件複製到另一個 Survivor 區中。理想情況下,Eden 區中的物件基本都死亡了,那麼需要複製的資料將非常少,因此採用這種標記 - 複製演算法的效果極好。

Minor GC 的另外一個好處是不用對整個堆進行垃圾回收。但是,它卻有一個問題,那就是老年代中的物件可能引用新生代的物件。也就是說,在標記存活物件的時候,我們需要掃描老年代中的物件。如果該物件擁有對新生代物件的引用,那麼這個引用也會被作為 GC Roots。這樣一來,豈不是又做了一次全堆掃描呢?

為了避免掃描全堆,JVM 引入了名為卡表的技術,大致地標出可能存在老年代到新生代引用的記憶體區域。有興趣的朋友可以去詳細瞭解一下,這裡限於篇幅,就不具體介紹了。

Full GC

那什麼時候會發生Full GC呢?針對不同的垃圾收集器,Full GC 的觸發條件可能不都一樣。按 HotSpot VM 的 serial GC 的實現來看,觸發條件是:

當準備要觸發一次 Minor GC 時,如果發現統計資料說之前 Minor GC 的平均晉升大小比目前老年代剩餘的空間大,則不會觸發 Minor GC 而是轉為觸發 Full GC。

因為 HotSpot VM 的 GC 裡,除了垃圾回收器 CMS 能單獨收集老年代之外,其他的 GC 都會同時收集整個堆,所以不需要事先準備一次單獨的 Minor GC。

垃圾回收

基礎的回收方式有三種:清除壓縮複製,接下來讓我們來一一瞭解一下。

清除

所謂清除,就是把死亡物件所佔據的記憶體標記為空閒記憶體,並記錄在一個空閒列表之中。當需要新建物件時,記憶體管理模組便會從該空閒列表中尋找空閒記憶體,並劃分給新建的物件。

其原理十分簡單,但是有兩個缺點:

  1. 會造成記憶體碎片。由於 JVM 的堆中物件必須是連續分佈的,因此可能出現總空閒記憶體足夠,但是無法分配的極端情況。
  2. 分配效率較低。如果是一塊連續的記憶體空間,那麼我們可以透過指標加法(pointer bumping)來做分配。而對於空閒列表,JVM 則需要逐個訪問空閒列表中的項,來查詢能夠放入新建物件的空閒記憶體。

壓縮

所謂壓縮,就是把存活的物件聚集到記憶體區域的起始位置,從而留下一段連續的記憶體空間。

這種做法能夠解決記憶體碎片化的問題,但代價是壓縮演算法的效能開銷,因此分配效率問題依舊沒有解決。

複製

所謂複製,就是把記憶體區域平均分為兩塊,分別用兩個指標 from 和 to 來維護,並且只是用 from 指標指向的記憶體區域來分配記憶體。當發生垃圾回收時,便把存活的物件複製到 to 指標所指向的記憶體區域中,並且交換 from 指標和 to 指標的內容。

這種回收方式同樣能夠解決記憶體碎片化的問題,但是它的缺點也極其明顯,即堆空間的使用效率極其低下。

具體垃圾收集器

針對新生代的垃圾回收器共有三個:Serial ,Parallel Scavenge 和 Parallel New。這三個採用的都是標記 - 複製演算法。

其中,Serial 是一個單執行緒的,Parallel New 可以看成是 Serial 的多執行緒版本,Parallel Scavenge 和 Parallel New 類似,但更加註重吞吐率。此外,Parallel Scavenge 不能與 CMS 一起使用。

針對老年代的垃圾回收器也有三個:Serial Old ,Parallel Old 和 CMS。

Serial Old 和 Parallel Old 都是標記 - 壓縮演算法。同樣,前者是單執行緒的,而後者可以看成前者的多執行緒版本。

CMS 採用的是標記 - 清除演算法,並且是併發的。除了少數幾個操作需要 STW(Stop the world) 之外,它可以在應用程式執行過程中進行垃圾回收。在併發收集失敗的情況下,JVM 會使用其他兩個壓縮型垃圾回收器進行一次垃圾回收。由於 G1 的出現,CMS 在 Java 9 中已被廢棄。

G1(Garbage First)是一個橫跨新生代和老年代的垃圾回收器。實際上,它已經打亂了前面所說的堆結構,直接將堆分成極其多個區域。每個區域都可以充當 Eden 區、Survivor 區或者老年代中的一個。它採用的是標記 - 壓縮演算法,而且和 CMS 一樣都能夠在應用程式執行過程中併發地進行垃圾回收。

G1 能夠針對每個細分的區域來進行垃圾回收。在選擇進行垃圾回收的區域時,它會優先回收死亡物件較多的區域。這也是 G1 名字的由來。

總結

這篇文章主要講述的是 JVM 中具體的垃圾回收方法,從物件的生存規律,引出回收方法,結合多執行緒的特點,逐步最佳化,最終產生了我們現在所能知道各種垃圾收集器。

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/2894/viewspace-2823952/,如需轉載,請註明出處,否則將追究法律責任。

相關文章