詳解JVM垃圾收集器和內存分配策略

JVM的內存結構模型由方法區、堆、虛擬機棧、本地方法區和程序計數器五個部分組成。虛擬機棧、本地方法和程序計數器是線程私有的,隨着方法或線程的結束,對應的內存也被回收了,而Java虛擬機規範也指出可不對方法區(jdk8叫元空間)做垃圾收集,因而可以說垃圾收集主要關注的是堆空間。

對象是否需要被回收

內存回收前需要先確定那些對象已經“死亡”(沒有被其他對象引用)。判斷對象的存活方式有2中,分別是“引用計數算法”和“可達性分析算法”。

引用計數算法的原理跟它的名字一樣明瞭,通過在對象中維護一個計數器判斷對象是否被使用。當有一個地方引用它,則計數器+1,當引用失效,則計數器-1,任何時刻計數器值爲0的對象則是不可被使用的。該算法實現簡單,效率也高,但無法處理對象之間的相互循環引用的問題。

public class ReferenceCounterGC {
    public Object instance = null;
    private static final int SIZE= 1024 * 1024;
    //佔點內存
    private byte[] socket = new byte[2 * SIZE];
    public static void main(String[] args) {
        ReferenceCounterGC objA = new ReferenceCounterGC();
        ReferenceCounterGC objB = new ReferenceCounterGC();
        objA.instance = objB;
        objB.instance = objA;
        objA = null;
        objB = null;
        System.gc();  //手動GC
    }
}

通過GC日誌發現,內存還是被回收了,可見JVM用的並非引用計數算法。

可達性分析算法,也叫根搜索算法。通過一些“GCRoots”對象做爲起點,向下搜索對象,走過的路徑稱爲“引用鏈”。當一個對象到達“GC Roots”沒有任何引用鏈的時候,表明該對象不可用,可被回收。JVM規定可作爲“GC Roots”對象的包括:虛擬機棧中的引用對象、方法區中類靜態屬性引用的對象,方法區中常量引用的對象,Native方法引用的對象。

對象逃脫

但事實上當一個對象不存在引用鏈時候,也不一定就會被回收。判斷對象真正“死亡”還需要經過標記。

第一次標記會進行篩選,判斷對象是否有必要執行finalize(),若對象沒有覆蓋finalize()或者finalize()方法已經被調用過,則被視作沒有必要,對象會被直接回收。若有必要執行,該對象會被放置在一個F-Queue隊列裏,由一條低優先級的線程去執行finalize(),再回收內存。若此時對象獲得新的引用鏈,則可逃脫被清除的命運,不過運行代價高,不確定性比較大,所以一般不推薦這種方法拯救對象。

關於引用

垃圾收集算法判定對象的存活其實是跟“引用”有關,一個對象存在被引用和沒有被引用這兩種狀態。若對象沒有存在引用狀態,此時JVM的內存還很充足,是可以考慮保留對象在內存之中,等到內存不足時再回收。jdk通過定義不同等級的引用對這類“食之無味,棄之可惜”的對象進行劃分。

不同等級的引用包含:強引用、軟引用、弱引用、虛引用(也叫幻像引用),關於他們的介紹網上資料很多,這裏就不再累述了,主要區別在於對象存活生命週期的不同。

內存分配策略

Java自動內存管理歸結到底就兩點:給對象分配內存以及回收對象的內存。內存區域分爲新生代和老年代,新生代包括1個Eden區和2個Survivor。運行的時候可以通過添加對應參數分配指定內存空間,舉個例子:

-verbose:gc -Xms20m -Xmx20m -Xmn10m -XX:+PrintGCDetails -XX:SurvivorRatio=8

這裏限制了Java堆大小爲20M,且不可擴展,10M分配給新生代,剩餘10M給老年代,Eden區和Survivor的空間比例是8:1。但整個新生代可用的內存空間其實只有9M,因爲新生代採用的是複製算法,需要保留一個Survivor作爲輪換。

對象優先分配在Eden,大對象(很長的字符串以及數組)直接進入老年代。新生代連續的內存空間不足以存儲對象時會觸發Minor GC,老年代同理則會出發Full GC。出現Full GC會至少伴隨一次Minor GC且速度比Minor GC慢10倍,因而應該儘量避免Full GC,減少Minor GC。但Minor GC時也是有可能會引發Full GC,因爲存在內存空間分配擔保。

新生代中的對象熬過一次Minor GC,年齡就會+1,當年齡大於一定程度(默認是15)就會進入老年代,可通過-XX:MaxTenuringThreshold設置。但也有特殊情況,當Surivor空間中相同年齡所有對象大小總和大於Surivor空間的一半,年齡大於等於該年齡的對象可以直接進入老年代,無須受到限制。

虛擬機也提供了參數 -XX:PretenureSizeThreshold,設置老年代分配的閾值,當對象大於該值則直接分配在老年代,可避免在Eden區和兩個Survivor區之間發生大量的內存複製。

幾款垃圾收集器
1. Serial收集器

Serial是最基本、單線程的收集器。所謂單線程,並不僅指使用一個CPU和一條線程去執行垃圾收集工作,也是指執行垃圾收集工作時候會暫停其他所有的線程,直到收集工作結束,即所謂的STW(stop the world),新生代使用複製算法,老年代使用標記-整理算法。

2. ParNew收集器

ParNew收集器是Serial收集器的多線程版,除了使用多條線程進行垃圾收集外,其他都跟Serial一樣。默認開啓的收集線程數跟CPU的數量相同,因此單CPU的情況下ParNew沒有比Serial收集器的效果好。特點是目前只有它能與CMS收集器配合使用

3. Parallel Scavenge收集器

Parallel Scavenge是一個新生代的收集器,也是使用複製算法,同時也是並行的多線程收集器。該收集器的主要目標是達到可控的吞吐量,吞吐量 = 代碼運行時間 / (代碼運行時間 + 垃圾收集時間)。Parallel Scavenge提供了 -XX:MaxGPauseMills和-XX:GCTimeRation兩個參數用來控制吞吐量。

Parallel Scavenge收集器也有一個老年代版本的收集器“Parallel Old”,使用多線程的“標記-整理”算法,

4. CMS收集器

CMS以降低系統停頓時間爲目標,基於“標記-清楚”算法實現的一款收集器。運作過程主要包含4步,初始標記、併發標記、重新標記、併發清除。初始標記和重新標記依然需要STW。初始標記是標記GC Root直接關聯的對象,速度很快。重新標記則是爲了修正併發標記階段產生變動的那一部分對象標記記錄。而耗時較長的併發標記和併發清除可跟用戶線程一起併發執行。

CMS默認開啓的回收線程數是(CPU數量+3)/4,因而CMS相對來說對CPU的資源會比較敏感。當CPU的數量較少時,JVM還會產生“增量式併發收集器”,使得回收線程和用戶線程之間是搶佔關係,避免回收線程一家獨大,獨佔資源。不過這種收集器好像效果一般,已不推薦使用。

除此之外,CMS也存在着無法清除浮動垃圾(併發清除階段用戶線程又產生的垃圾),存在空間碎片過多的問題,會導致老年代對象分配的時候有可能連續的內存空間不足而觸發Full GC。

5. G1收集器

G1收集器可看做是CMS收集器的改良版,保留了CMS的優良特性,解決了CMS的弊端。G1充分利用CPU、多核環境縮短STW的時間;保留了以往收集器分代的概念;基於“標記-整理”算法實現,運作期間不會產生內存空間,因而解決了內存空間碎片導致提前觸發GC的問題;可預測的停頓,運行指定一定時間內垃圾收集消耗的時間。

G1收集器運作包括4個階段,初始標記、併發標記、最終標記、刷選回收。前面三步跟CMS收集器類似:初始標記標記GC Root能直接關聯的對象;併發標記是根據GC Root進行可達性分析,找出存活的對象,這個階段耗時長,但可跟用戶程序併發執行;最終標記是修正併發標記過程中發生變化的對象;刷選回收則是根據用戶指定的停頓時間等參數制定執行回收計劃。

6. ZGC收集器

jdk11添加的全新垃圾收集器,據說能支持TB級別的內存容量,還沒仔細瞭解,懺愧,略過…

理解GC日誌

最後以JDK1.8版本爲例子說一說如何理解GC日誌:

//這裏是GC類型,Full GC表明這次GC發生了STW
//方括號內6115K->728K(9216K),含義:GC前該區域使用情況->GC後該區域使用情況(該區域總容量)
//方括號外6115K->736K(19456K),含義:GC前Java堆已使用容量->GC後Java堆已使用容量(Java堆總容量)
//0.0039696 secs表示該區域GC佔用時間,單位是秒
[GC (System.gc()) [PSYoungGen: 6115K->728K(9216K)] 6115K->736K(19456K), 0.0039696 secs] [Times: user=0.01 sys=0.00, real=0.00 secs] 
[Full GC (System.gc()) [PSYoungGen: 728K->0K(9216K)] [ParOldGen: 8K->623K(10240K)] 736K->623K(19456K), [Metaspace: 3426K->3426K(1056768K)], 0.0083509 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] 
Heap
//新生代,包括eden和survivor的大小和使用情況
PSYoungGen      total 9216K, used 166K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
eden space 8192K, 2% used [0x00000000ff600000,0x00000000ff629998,0x00000000ffe00000)
from space 1024K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x00000000fff00000)
to   space 1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)
//老年代內存使用信息
ParOldGen       total 10240K, used 623K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
object space 10240K, 6% used [0x00000000fec00000,0x00000000fec9bcc0,0x00000000ff600000)
//元空間使用信息
Metaspace       used 3441K, capacity 4496K, committed 4864K, reserved 1056768K
class space    used 375K, capacity 388K, committed 512K, reserved 1048576K

以上便是對JVM垃圾收集器與內存分配策略做的一個總結,學而時習之,不亦樂乎。很高興你能看到了這裏,如果覺得文章不錯或對內容有疑問,歡迎留言/私信/點贊交流。

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