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

前提介紹

很多小夥伴,都跟我反饋,說自己總是對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算法分析)

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