精華推薦 | 【JVM深層系列】「GC底層調優專題」一文帶你徹底加強夯實底層原理之GC垃圾回收技術的分析指南(GC原理透析)
theme: Chinese-red
前提介紹
很多小夥伴,都跟我反饋,説自己總是對JVM這一塊的學習和認識不夠紮實也不夠成熟,因為JVM的一些特性以及運作機制總是混淆以及不確定,導致面試和工作實戰中出現了很多的紕漏和短板,解決廣大小夥伴痛點,我寫了本篇文章,希望可以幫助大家夯實基礎和鍛造JVM技術功底。
什麼是垃圾收集(GC)
在JVM領域中GC(Garbage Collection)翻譯為 “垃圾收集“,Garbage Collector翻譯為 “垃圾收集器”。
分代模型(Generational Model)
我們都知道在JVM中,執行垃圾收集需要停止整個應用(STW)。對象越多則收集所有垃圾消耗的時間就越長。程序中的大多數可回收的內存可歸為兩類:
- 大部分對象很快就不再使用
- 還有一部分不會立即無用,但也不會持續(太)長時間
這形成了分代數據模型。基於這一結構, 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)算法。而是採用移動對象的方式來實現最小化內存碎片。老年代空間的清理算法通常是建立在不同的基礎上的。原則上,會執行以下這些步驟:
- 通過標誌位(marked bit),標記所有通過 GC roots 可達的對象.
- 刪除所有不可達對象
- 整理老年代空間中的內容,方法是將所有的存活對象複製,從老年代空間開始的地方,依次存放。
通過上面的描述可知, 老年代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 GC和Full GC差不多,其實具體分析,FullGC的範圍是面向於整體的Heap堆內存。
GC的優點和缺點(GC Benefits/Cost)
好處
- 提高系統的可靠性和穩定性
- 內存管理與程序設計的解耦
- 調試內存錯誤所花費的時間更少
- 懸掛程序點/內存泄漏不會發生
注意: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實際上忽略了老年代,主要面向的對象範圍有兩部分組成:
-
主要是面向於老年代到年輕代的所引用的對象範圍,例如,它會將從老年代指向年輕代的引用都被認為是GC Root,(而從年輕代指向老年代的引用在標記階段全部被忽略)。
-
主要面向的是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算法分析)
- 【世界盃黑技術資訊】「技術探索系列」一文解讀一下“卡塔爾世界盃”的先進技術之半自動越位技術SAOT 比賽用球Al Rihla
- 【JVM故障問題排查心得】「內存診斷系列」Xmx和Xms的大小是小於Docker容器以及Pod的大小的,為啥還是會出現OOMKilled?
- 【Dubbo3終極特性】「流量治理體系」一文教你如何搭建Dubbo3的控制枱服務Dubbo-Admin
- 精華推薦 | 【JVM深層系列】「GC底層調優專題」一文帶你徹底加強夯實底層原理之GC垃圾回收技術的分析指南(GC原理透析)
- 【深入淺出SpringCloud原理及實戰】「SpringCloud-Alibaba系列」微服務模式搭建系統基礎架構實戰指南及版本規劃踩坑分析