精華推薦 | 【JVM深層系列】「GC底層調優系列」一文帶你徹底加強夯實底層原理之GC垃圾回收技術的分析指南(GC原理透析)

洛神灬殤發表於2023-01-23

前提介紹

很多小夥伴,都跟我反饋,說自己總是對JVM這一塊的學習和認識不夠紮實也不夠成熟,因為JVM的一些特性以及運作機制總是混淆以及不確定,導致面試和工作實戰中出現了很多的紕漏和短板,解決廣大小夥伴痛點,我寫了本篇文章,希望可以幫助大家夯實基礎和鍛造JVM技術功底。

什麼是垃圾收集(GC)

在JVM領域中GC(Garbage Collection)翻譯為 “垃圾收集“,Garbage Collector翻譯為 “垃圾收集器”

分代模型(Generational Model)

我們都知道在JVM中,執行垃圾收集需要停止整個應用(STW)。物件越多則收集所有垃圾消耗的時間就越長。程式中的大多數可回收的記憶體可歸為兩類:

  1. 大部分物件很快就不再使用
  2. 還有一部分不會立即無用,但也不會持續(太)長時間

這形成了分代資料模型。基於這一結構, VM中的記憶體被分為年輕代(Young Generation)和老年代(Old Generation),老年代有時候也稱為年老區(Tenured)。如下所示。

從上圖可以看出拆分為這樣兩個可清理的單獨區域,允許採用不同的演算法來大幅提高GC的效能。

分代模型出現問題

在不同分代中的物件可能會互相引用, 在收集某一個分代時就會成為 “事實上的” GC root。當然,要著重強調的是,分代假設並不適用於所有程式。

分代模型適合場景

GC演算法專門針對“總體生命週期較短”,“總體生命週期較長” 這類特徵的物件來進行最佳化, JVM對收集那種存活時間半長不長的物件就顯得非常尷尬了,如下圖物件分佈。

堆記憶體中的記憶體池劃分也是類似的。不太容易理解的地方在於各個記憶體池中的垃圾收集是如何執行的。

新生代(Eden,伊甸園)

Eden是記憶體中的一個區域, 用來分配新建立的物件。通常會有多個執行緒同時建立多個物件,所以Eden區被劃分為多個執行緒本地分配緩衝區(Thread Local Allocation Buffer, 簡稱TLAB)。透過這種緩衝區劃分,大部分物件直接由JVM 在對應執行緒的TLAB中分配, 避免與其他執行緒的同步操作。

如果 TLAB 中沒有足夠的記憶體空間, 就會在共享Eden區(shared Eden space)之中分配。如果共享Eden區也沒有足夠的空間, 就會觸發一次 年輕代GC 來釋放記憶體空間。如果GC之後 Eden 區依然沒有足夠的空閒記憶體區域, 則物件就會被分配到老年代空間(Old Generation)。

當Eden區進行垃圾收集時,GC將所有從root可達的物件過一遍, 並標記為存活物件。

物件間可能會有跨代的引用,所以需要一種方法來標記從其他分代中指向Eden的所有引用。這樣做又會遭遇各個分代之間一遍又一遍的引用。JVM在實現時採用了卡片標記(card-marking)。

卡片標記

JVM只需要記住Eden區中 “髒”物件的粗略位置,可能有老年代的物件引用指向這部分割槽間。

存活區(Survivor Spaces)

Eden區的旁邊是兩個存活區, 稱為 from 空間和 to 空間。需要著重強調的的是, 任意時刻總有一個存活區是空的(empty)。

空的那個存活區用於在下一次年輕代GC時存放收集的物件。年輕代中所有的存活物件(包括Edenq區和非空的那個 “from” 存活區)都會被複制到 ”to“ 存活區。GC過程完成後, ”to“ 區有物件,而 ‘from’ 區裡沒有物件。兩者的角色進行正好切換 。

存活的物件會在兩個存活區之間複製多次,直到某些物件的存活時間達到一定的閥值。分代理論假設, 存活超過一定時間的物件很可能會繼續存活更長時間。

這類“ 年老” 的物件因此被提升(promoted )到老年代。提升的時候, 存活區的物件不再是複製到另一個存活區,而是遷移到老年代, 並在老年代一直駐留, 直到變為不可達物件。

此外GC會跟蹤記錄每個存活區物件存活的次數,每次分代GC完成後,存活物件的年齡就會+1。當年齡超過提升閾值(tenuring threshold),就會被提升到老年代區域。

MaxTenuringThreshold的判定

具體的提升閾值由JVM動態調整,但也可以用引數 -XX:+MaxTenuringThreshold來指定上限。如果設定 -XX:+MaxTenuringThreshold=0 , 則GC時存活物件不在存活區之間複製,直接提升到老年代。現代 JVM 中這個閾值預設設定為15個GC週期。這也是HotSpot中的最大值。

老年代(Old Generation)

老年代記憶體空間一般情況下,裡面的物件是垃圾的機率也更小。

老年代GC發生的頻率比年輕代小很多。同時, 因為預期老年代中的物件大部分是存活的, 所以不再使用標記和複製(Mark and Copy)演算法。而是採用移動物件的方式來實現最小化記憶體碎片。老年代空間的清理演算法通常是建立在不同的基礎上的。原則上,會執行以下這些步驟:

  1. 透過標誌位(marked bit),標記所有透過 GC roots 可達的物件.
  2. 刪除所有不可達物件
  3. 整理老年代空間中的內容,方法是將所有的存活物件複製,從老年代空間開始的地方,依次存放。

透過上面的描述可知, 老年代GC必須明確地進行整理,以避免記憶體碎片過多。

永久代(PermGen)

Java8之前有一個特殊的空間,稱為“永久代”(Permanent Generation)。

它儲存後設資料(metadata)的地方,比如 class 資訊等。此外,這個區域中也儲存有其他的資料和資訊, 包括內部化的字串(internalized strings)等等。

後設資料區(Metaspace)

Java 8直接刪除了永久代(Permanent Generation),改用Metaspace。將靜態變數和字串常量都放到其中。像類定義(class definitions)之類的資訊會被載入到Metaspace 中。

後設資料區位於本地記憶體(native memory),不再影響到普通的Java物件。預設情況下, Metaspace的大小隻受限於Java程式可用的本地記憶體。

常見的垃圾回收思想的誤區

在我們的日常生活中垃圾收集主要就是找到垃圾並進行清理,這與我們JVM的運作機制恰恰相反,JVM中的垃圾收集器跟蹤和標記所有正在使用的物件,並把其餘部分的物件當做垃圾物件。

所以這裡一定要區分清楚,我們這裡的標記:是指標記可用物件,而不是垃圾物件。常常會有人吧這兩者理解錯誤和混亂。

記住這一點以後,我們再深入講解記憶體自動回收的原理,探究JVM中垃圾收集的具體實現。先從基礎開始, 介紹垃圾收集的一般特徵、核心概念以及實現演算法。

常見的垃圾回收型別

垃圾回收型別主要是透過回收的範圍進行界定和劃分。具體的JVM回收區域如下圖所示。

Java8之前

Java8之後

垃圾收集(Garbage Collection)通常分為:Minor GC - Major GC - Full GC 。接下來介紹這些事件及其區別,然後你會發現這些區別也不是特別清晰。

  • Minor GC:年輕代垃圾回收機制,屬於輕量級GC,主要面向於年輕代區域的垃圾物件進行回收。
  • Major GC:老年代垃圾回收機制,屬於重量級GC,主要面向於老年代區域的垃圾物件進行回收。
  • Full GC:完全化GC,屬於全量極GC,大致角度而言Major GCFull GC差不多,其實具體分析,FullGC的範圍是面向於整體的Heap堆記憶體。

GC的優點和缺點(GC Benefits/Cost)

好處

  1. 提高系統的可靠性和穩定性
  2. 記憶體管理與程式設計的解耦
  3. 除錯記憶體錯誤所花費的時間更少
  4. 懸掛程式點/記憶體洩漏不會發生

注意:Java程式沒有記憶體洩漏;“不意味著物件儲存地址”更準確)

壞處

  • GC暫停的時間長度
  • CPU/記憶體利用率

Minor GC

年輕代記憶體的垃圾收集稱為Minor GC。那什麼時候會觸發MinorG以及出發MinorGC得我條件是什麼?

觸發MinorGC的時機

當JVM無法為新物件分配Eden區的記憶體空間時/達到了Eden存放閾值的時候會觸發 Minor GC,所以新物件分配頻率越高,Minor GC的頻率就越高。並且Minor GC每次都會引起全線停頓(stop-the-world ),暫停所有的應用執行緒,對大多數程式而言,暫停時長基本上是可以忽略不計的。

MinorGC回收的瓶頸

Eden區的物件基本上都是垃圾,也不怎麼複製到Survior區/老年代。如果情況不是這樣, 大部分新建立的物件不能被垃圾回收清理掉,則 Minor GC的停頓就會持續更長的時間。

MinorGC回收的範圍

Minor GC實際上忽略了老年代,主要面向的物件範圍有兩部分組成:

  1. 主要是面向於老年代到年輕代的所引用的物件範圍,例如,它會將從老年代指向年輕代的引用都被認為是GC Root,(而從年輕代指向老年代的引用在標記階段全部被忽略)

  2. 主要面向的是Survior區之間的相互引用,此種場景的生命週期較短,屬於年輕代之內的物件之間的引用關係。

所以,Minor GC的定義很簡單、清理的就是年輕代,如下圖所示。

Major GC vs Full GC

從上面我們知道了Minor GC清理的是年輕代空間(Young space),相應的其他區域也有對應的回收機制和策略。

  • Major GC清理的是老年代空間(Old space),MajorGC是由Minor GC觸發的,所以很多情況下這兩者是不可分離的,G1這樣的垃圾收集演算法執行的是部分割槽域垃圾回收。

  • Full GC清理的是整個堆,包括年輕代和老年代空間。

Minor GC、MajorGC和FullGC執行效果

大部分情況下,發生在年輕代的Minor GC次數會很多,會引起STW,也就是全域性化暫停執行業務執行緒的行為,但是時間很短(幾乎可以忽略不計)。而Major GC和Full GC也會造成全域性化暫停的效果。所以一般情況下儘可能減少MajorGC和FullGC是什麼必要的,但是也不能“一棒子打死一船人”。必要的時候還是需要觸發少量幾次Major GC以及FullGC,進而釋放一些RSS常駐記憶體。

垃圾收集(GC)的原理

自動記憶體管理(Automated Memory Management)

如果要顯式地宣告什麼時候需要進行記憶體管理,實現自動進行收集垃圾,那樣就太方便了,開發者不再耗費腦細胞去考慮要在何處進行記憶體清理。執行時環境會自動算出哪些記憶體不再使用,並將其釋放,歷史上第一款垃圾收集器是1959年為Lisp語言開發的。

引用計數(Reference Counting)

共享指標方式的引用計數法, 可以應用到所有物件。許多語言都採用這種方法,包括 Perl、Python 和 PHP 等。下圖很好地展示了這種方式:

上圖中所展示的GC ROOTS,表示程式正在使用的物件。主要(這裡指的不是全部)集中在於當前正在執行的方法中的區域性變數或者是靜態變數等。在這裡主要我指的是Java。

  • 藍色的圓圈表示可以引用到的物件,裡面的數字就是被引用計數器
  • 灰色的圓圈是各個作用域都不再引用的物件,可以被認為是垃圾,隨時會被垃圾收集器清理。
迴圈引用(detached cycle)的問題

引用計數器無法針對於迴圈引用這種場景進行正確的處理和探測。任何作用域中都沒有引用指向這些物件,但由於迴圈引用, 導致引用計數一直大於零,如下圖所示。

  • 紅色線路和紅色圓圈物件實際上屬於垃圾引用以及垃圾物件,但由於引用計數的侷限,所以存在記憶體洩漏,永遠都無法進行回收該區域的物件記憶體。
迴圈引用(detached cycle)的解決方案

比如說可以針對於一些這種迴圈模式進行加入到 “弱引用”(‘weak’ references)的體系中,所以即使無法進行解決迴圈引用計數的場景,也可以透過弱引用實現記憶體回收。

精華推薦 | 【JVM深層系列】「GC底層調優系列」一文帶你徹底加強夯實底層原理之GC垃圾回收技術的分析指南(GC演算法分析)

相關文章