Java GC算法與內存分配策略

一、垃圾收集的意義

?相對于C++來說,Java預言顯著的特點就是引入了垃圾回收機制,它使得Java程序員在編寫程序的時候不在需要考慮內存管理。由于垃圾回收機制,Java中的對象不再有“作用域”的概念,只有對象的引用才有作用域。垃圾回收機制可以有效的防止內存泄露,有效的使用空閑的內存。
?內存泄露是指該內存空間使用完畢之后未回收,在不涉及復雜數據結構的一般情況下,Java 的內存泄露表現為一個內存對象的生命周期超出了程序需要它的時間長度,我們有時也將其稱為“對象游離”。
?Java 垃圾回收機制要考慮的問題很復雜,本文闡述了其三個核心問題,包括:

  • 那些內存需要回收?(對象是否可以被回收的兩種經典算法: 引用計數法 和 可達性分析算法)
  • 什么時候回收? (堆的新生代、老年代、永久代的垃圾回收時機,MinorGC 和 FullGC)
  • 如何回收?(三種經典垃圾回收算法(標記清除算法、復制算法、標記整理算法)及分代收集算法 和 七種垃圾收集器)

二、如何確定一個對象是否會被回收

2.1 引用計數算法(Reference Counting)

? 引用計數算法是通過判斷對象的引用數量來決定對象是否可以被回收。它的思路是給對象中添加一個引用計數器,每當有一個地方引用它時,計數器值就加1;當引用失效時,計數器值就減1;任何時刻計數器為0的對象就是不可能再被使用的。
?大部分場景下,這個算法都是不錯,效率也比較高;但是Java虛擬機里面沒有選用引用計數算法來管理內存,其中最主要的原因是它很難解決對象之間相互循環引用的問題。

package com.sunny.jdk.gc;

/**
 * <Description> 引用計數算法的缺陷<br>
 *
 * @author Sunny<br>
 * @version 1.0<br>
 * @taskId: <br>
 * @createDate 2018/09/06 14:01 <br>
 * @see com.sunny.jdk.gc <br>
 */
public class ReferenceCountingGC {
    public Object instance = null;

    public static final int _1MB = 1024 * 1024;

    /**
     * 占點內存,以便GC日志觀看
     */
    private byte[] bigSize = new byte[2 * _1MB];

    public static void main(String[] args) {
        testGC();
    }

    public static void testGC() {
        ReferenceCountingGC objA = new ReferenceCountingGC();
        ReferenceCountingGC objB = new ReferenceCountingGC();
        objA.instance = objB;
        objB.instance = objA;

        objA = null;
        objB = null;

        //這里發生GC, objA 和 objB能否被回收?
        System.gc();
    }

}

? 上述代碼最后面兩句將objA和objB賦值為null,也就是說objA和objB指向的對象已經不可能再被訪問,但是由于它們互相引用對方,導致它們的引用計數器都不為 0,那么垃圾收集器就永遠不會回收它們。


引用計數的缺陷

2.2 可達性分析算法(Reachability Analysis)

?判斷對象的引用鏈是否可達。它的思路是:通過一系列的稱為“GC Roots”的對象作為起始點,從這些節點開始向下搜索,搜索所走過的路徑稱為引用鏈(Reference Chain),當一個對象到GC Roots沒有任何引用鏈相連(用圖論的話來說就是從 GC Roots 到這個對象不可達)時,則證明此對象是不可用的,如圖所示:

可達性分析

在Java中,可作為 GC Root 的對象包括以下幾種:

  • 虛擬機棧(棧幀中的局部變量表)中引用的對象;

  • 方法區中類靜態屬性引用的對象;

  • 方法區中常量引用的對象;

  • 本地方法棧中Native方法引用的對象;

三、垃圾回收算法

?垃圾收集算法主要有:標記-清除算法(Mark-Sweep)、復制算法(Copying)、標記-整理算法(Mark-Compact)、分帶收集算法(Generational Collection)。

3.1 標記-清除算法

?“標記-清除”算法是最基礎的算法,它分為“標記”和清除兩個階段:首先標記出所有需要回收的對象,在標記完成后統一回收所有被標記的對象
?缺點: 一個是效率問題,標記和清除兩個過程的效率都不高;另一個是空間問題,標記清除之后會產生大量不連續的內存碎片,空間碎片太多可能會導致以后再程序運行過程中需要分配較大對象時,無法找到足夠的連續內存而不得不提前觸發另一次垃圾收集動作。

標記清除算法

3.2 復制算法

?復制算法的原理:它將可用內存按容量劃分為大小相等的兩塊,每次只使用其中的一塊。當這一塊的內存用完了,就將還存活著的對象復制到另外一塊上面,然后再把已使用過的內存空間一次清理掉。這種算法適用于對象存活率低的場景,比如新生代。這樣使得每次都是對整個半區進行內存回收,內存分配時也就不用考慮內存碎片等復雜情況,只要移動堆頂指針,按順序分配內存即可,實現簡單,運行高效。該算法示意圖如下所示:

復制算法

?將現有的內存空間分為兩快,每次只使用其中一塊,在垃圾回收時將正在使用的內存中的存活對象復制到未被使用的內存塊中,之后,清除正在使用的內存塊中的所有對象,交換兩個內存的角色,完成垃圾回收。如果系統中的垃圾對象很多,復制算法需要復制的存活對象數量并不會太大。因此在真正需要垃圾回收的時刻,復制算法的效率是很高的。又由于對象在垃圾回收過程中統一被復制到新的內存空間中,因此,可確保回收后的內存空間是沒有碎片的。復制算法的高效性是建立在存活對象少、垃圾對象多的前提下的。這種情況在年輕代經常發生,但是在老年代更常見的情況是大部分對象都是存活對象。如果依然使用復制算法,由于存活的對象較多,復制的成本也將很高。該算法的缺點是將系統內存折半。

?Java 的新生代串行垃圾回收器中使用了復制算法的思想。新生代分為 eden 空間、from 空間、to 空間 3 個部分。其中 from 空間和 to 空間可以視為用于復制的兩塊大小相同、地位相等,且可進行角色互換的空間塊。from 和 to 空間也稱為 survivor 空間,即幸存者空間,用于存放未被回收的對象。在垃圾回收時,eden 空間中的存活對象會被復制到未使用的 survivor 空間中 (假設是 to),正在使用的 survivor 空間 (假設是 from) 中的年輕對象也會被復制到 to 空間中 (大對象,或者老年對象會直接進入老年帶,如果 to 空間已滿,則對象也會直接進入老年代)。此時,eden 空間和 from 空間中的剩余對象就是垃圾對象,可以直接清空,to 空間則存放此次回收后的存活對象。這種改進的復制算法既保證了空間的連續性,又避免了大量的內存空間浪費。

3.3 標記-整理算法

?復制收集算法在對象存活率較高時就要進行較多的復制操作,效率將會變低。如果不想浪費50%的空間,就需要有額外的空間進行分配擔保,以應對被使用的內存中所有對象都100%存活的極端情況,所以老年代不能直接選用這種算法。
?標記整理算法,標記過程仍然與“標記-清除”算法一樣,但是后續步驟不是直接對可回收對象進行清理,而是讓所有存活的對象都向一端移動,然后直接清理掉端邊界以外的內存,“標記-整理”算法的示意圖如下:

標記-整理

3.4 分代收集算法

?對于一個大型的系統,當創建的對象和方法變量比較多時,堆內存中的對象也會比較多,如果逐一分析對象是否該回收,那么勢必造成效率低下。分代收集算法是基于這樣一個事實:不同的對象的生命周期(存活情況)是不一樣的,而不同生命周期的對象位于堆中不同的區域,因此對堆內存不同區域采用不同的策略進行回收可以提高 JVM 的執行效率。當代商用虛擬機使用的都是分代收集算法:新生代對象存活率低,就采用復制算法;老年代存活率高,就用標記清除算法或者標記整理算法。Java堆內存一般可以分為新生代、老年代和永久代三個模塊,如下圖所示:

分代收集算法

具體分代收集算法可以參考:分代收集算法


四、 HotSpot的算法實現

?前面兩大節主要從理論上介紹了對象存活判定算法和垃圾收集算法,而在HotSpot虛擬機上實現這些算法時,必須對算法的執行效率有嚴格的考量,才能保證虛擬機高效運行。

4.1枚舉根節點

?從可達性分析中從GC Roots節點找引用鏈這個操作為例,可作為GC Roots的節點主要在全局性的引用(例如常量或類靜態屬性)與執行上下文(例如棧幀中的局部變量表)中,現在很多應用僅僅方法區就有數百兆,如果要逐個檢查這里面的引用,那么必然會消耗很多時間。

4.2 GC停頓("Stop The World")

?另外,可達性分析工作必須在一個能確保一致性的快照中進行——這里一致性的意思是指在整個分析期間整個執行系統看起來就像被凍結在某個時間點上,不可以出現分析過程中對象引用關系還在不斷變化的情況,這是保證分析結果準確性的基礎。這點是導致GC進行時必須停頓所有Java執行線程(Sun將這件事情稱為Stop The World)的其中一個重要原因,即使是在號稱(幾乎)不會發生停頓的CMS收集器中,枚舉根節點時也是必須要停頓的。

4.3 準確式GC與OopMap

?由于目前的主流Java虛擬機使用的都是準確式GC(即使用準確式內存管理,虛擬機可用知道內存中某個位置的數據具體是什么類型),所以當執行系統停頓下來后,并不需要一個不漏地檢查完所有執行上下文和全局的引用位置,虛擬機應當是有辦法直接得知哪些地方存放著對象引用。在HotSpot的實現中,是使用一組稱為OopMap的數據結構來達到這個目的的,在類加載完成的時候,HotSpot就把對象內什么偏移量上是什么類型的數據計算出來,在JIT編譯過程中,也會在特定的位置記錄下棧和寄存器中哪些位置是引用。這樣,GC在掃描時就可以直接得知這些信息了。

4.4 安全點(Safepoint)——進行GC時程序停頓的位置

?在OopMap的協助下,HotSpot可以快速且準確地完成GC Roots枚舉,但一個很現實的問題隨之而來:可能導致引用關系變化,或者說OopMap內容變化的指令非常多,如果為每一條指令都生成對應的OopMap,那將會需要大量的額外空間,這樣GC的空間成本將會變得很高。

?為此,HotSpot選擇不為每條指令都生成OopMap,而是只在“特定的位置”記錄這些信息,這些位置便被稱為安全點(Safepoint)。也就是說,程序執行時并非在所有地方都能停頓下來開始GC,只有在到達安全點時才能暫停。Safepoint的選定既不能太少以致于讓GC等待時間太長,也不能過于頻繁以致于過分增大運行時的負荷。所以,安全點的選定基本上是以程序“是否具有讓程序長時間執行的特征”為標準進行選定的——因為每條指令執行的時間都非常短暫,程序不太可能因為指令流長度太長這個原因而過長時間運行,“長時間執行”的最明顯特征就是指令序列復用,例如方法調用循環跳轉異常跳轉等,所以具有這些功能的指令才會產生Safepoint。

?對于Sefepoint,另一個需要考慮的問題是如何在GC發生時讓所有線程(這里不包括執行JNI調用的線程)都“跑”到最近的安全點上再停頓下來。這里有兩種方案可供選擇:

  • 搶先式中斷(Preemptive Suspension) 搶先式中斷不需要線程的執行代碼主動去配合,在GC發生時,首先把所有線程全部中斷,如果發現有線程中斷的地方不在安全點上,就恢復線程,讓它“跑”到安全點上。現在幾乎沒有虛擬機實現采用搶先式中斷來暫停線程從而響應GC事件。
  • 主動式中斷(Voluntary Suspension): 主動式中斷的思想是當GC需要中斷線程的時候,不直接對線程操作,僅僅簡單地設置一個標志,各個線程執行時主動去輪詢這個標志,發現中斷標志為真時就自己中斷掛起。輪詢標志的地方和安全點是重合的**,另外**再加上創建對象需要分配內存的地方

4.5 安全區域(Safe Region)

?Safepoint機制保證了程序執行時,在不太長的時間內就會遇到可進入GC的Safepoint。但是,程序“不執行”的時候(如線程處于Sleep狀態或Blocked狀態),這時線程無法響應JVM的中斷請求,“走到”安全的地方去中斷掛起,這時候就需要安全區域(Safe Region)來解決。

?安全區域是指在一段代碼片段之中,引用關系不會發生變化。在這個區域中的任意地方開始GC都是安全的。我們也可以把Safe Region看做是被擴展了的Safepoint。

?在線程執行到Safe Region中的代碼時,首先標識自己已經進入了Safe Region,那樣,當在這段時間里JVM要發起GC時,就不用管標識自己為Safe Region狀態的線程了。在線程要離開Safe Region時,它要檢查系統是否已經完成了根節點枚舉(或者是整個GC過程),如果完成了,那線程就繼續執行,否則它就必須等待直到收到可以安全離開Safe Region的信號為止。

五、內存分配策略

?Java的自動內存管理最終可以歸結為自動化地解決了兩個問題:

  • 給對象分配內存
  • 回收分配給對象的內存

?對象的內存分配通常是在堆上分配(除此以外還有可能經過JIT編譯后被拆散為標量類型并間接地棧上分配),對象主要分配在新生代的Eden區上,如果啟動了本地線程分配緩沖,將按線程優先在TLAB上分配。少數情況下也可能會直接分配在老年代中,分配的規則并不是固定的,實際取決于垃圾收集器的具體組合以及虛擬機中與內存相關的參數的設置。至于內存回收策略,在上文已經描述得很詳盡了。

?下面以使用Serial/Serial Old收集器(將在下一篇文章中講解)為例,介紹內存分配的策略。

5.1 對象優先在Eden區分配

?大多數情況下,對象在新生代的Eden區中分配。當Eden區沒有足夠空間進行分配時,虛擬機將發起一次Minor GC。

5.2 大對象直接進入老年代

?所謂的大對象是指,需要大量連續內存空間的Java對象,最典型的大對象就是很長的字符串以及數組。大對象對虛擬機的內存分配來說是一個壞消息(尤其是遇到朝生夕滅的“短命大對象”,寫程序時應避免),經常出現大對象容易導致內存還有不少空間時就提前觸發GC以獲取足夠的連續空間來安置它們

?虛擬機提供了一個-XX:PretenureSizeThreshold參數,令大于這個設置值的對象直接在老年代分配。這樣做的目的是避免在Eden區及兩個Survivor區之間發生大量的內存復制(新生代采用復制算法回收內存)。

5.2 長期存活的對象將進入老年代

?既然虛擬機采用了分代收集的思想來管理內存,那么內存回收時就必須能識別哪些對象應放在新生代,哪些對象應放在老年代中。為了做到這點,虛擬機給每個對象定義了一個對象年齡(Age)計數器如果對象在Eden出生并經過第一次Minor GC后仍然存活,并且能被Survivor容納的話,將被移動到Survivor空間中,并且對象年齡設為1。對象在Survivor區中每“熬過”一次Minor GC,年齡就增加1歲,當它的年齡增加到一定程度(默認為15歲),就將會被晉升到老年代中。對象晉升老年代的年齡閾值,可以通過參數-XX:MaxTenuringThreshold設置。

5.3 動態對象年齡判定

?為了能更好地適應不同程序的內存狀況,虛擬機并不是永遠地要求對象的年齡必須達到了MaxTenuringThreshold才能晉升老年代,如果在Survivor空間中相同年齡所有對象大小的總和大于Survivor空間的一半,年齡大于或等于該年齡的對象就可以直接進入老年代,無須等到MaxTenuringThreshold中要求的年齡。

5.4 空間分配擔保

?在發生Minor GC之前,虛擬機會先檢查老年代最大可用的連續空間是否大于新生代所有對象總空間,如果這個條件成立,那么Minor GC可以確保是安全的。如果不成立,則虛擬機會查看HandlePromotionFailure設置值是否允許擔保失敗。如果允許,那么會繼續檢查老年代最大可用的連續空間是否大于歷次晉升到老年代對象的平均大小,如果大于,將嘗試著進行一次Minor GC,盡管這次Minor GC是有風險的;如果小于,或者HandlePromotionFailure設置不允許冒險,那這時也要改為進行一次Full GC

?前面提到過,新生代使用復制收集算法,但為了內存利用率,只使用其中一個Survivor空間來作為輪換備份,因此當出現大量對象在Minor GC后仍然存活的情況(最極端的情況就是內存回收后新生代中所有對象都存活),就需要老年代進行分配擔保,把Survivor無法容納的對象直接進入老年代。與生活中的貸款擔保類似,老年代要進行這樣的擔保,前提是老年代本身還有容納這些對象的剩余空間,一共有多少對象會活下來在實際完成內存回收之前是無法明確知道的,所以只好取之前每一次回收晉升到老年代對象容量的平均大小值作為經驗值,與老年代的剩余空間進行比較,決定是否進行Full GC來讓老年代騰出更多空間。

?取平均值進行比較其實仍然是一種動態概率的手段,也就是說,如果某次Minor GC存活后的對象突增,遠遠高于平均值的話,依然會導致擔保失敗(Handle Promotion Failure)。如果出現了HandlePromotionFailure失敗,那就只好在失敗后重新發起一次Full GC。雖然擔保失敗時繞的圈子是最大的,但大部分情況下都還是會將HandlePromotionFailure開關打開,避免Full GC過于頻繁。

六、 Full GC的觸發條件

?對于Minor GC,其觸發條件非常簡單,當Eden區空間滿時,就將觸發一次Minor GC。而Full GC則相對復雜,因此本節我們主要介紹Full GC的觸發條件。

6.1 調用System.gc()

?此方法的調用是建議JVM進行Full GC,雖然只是建議而非一定,但很多情況下它會觸發 Full GC,從而增加Full GC的頻率,也即增加了間歇性停頓的次數。因此強烈建議能不使用此方法就不要使用,讓虛擬機自己去管理它的內存,可通過-XX:+ DisableExplicitGC來禁止RMI調用System.gc()。

6.2 老年代空間不足

?老年代空間不足的常見場景為前文所講的大對象直接進入老年代長期存活的對象進入老年代等,當執行Full GC后空間仍然不足,則拋出如下錯誤: Java.lang.OutOfMemoryError: Java heap space為避免以上兩種狀況引起的Full GC,調優時應盡量做到讓對象在Minor GC階段被回收、讓對象在新生代多存活一段時間及不要創建過大的對象及數組。

6.3 空間分配擔保失敗

?前文介紹過,使用復制算法的Minor GC需要老年代的內存空間作擔保,如果出現了HandlePromotionFailure擔保失敗,則會觸發Full GC。

6.4 JDK 1.7及以前的永久代空間不足

?在JDK 1.7及以前,HotSpot虛擬機中的方法區是用永久代實現的,永久代中存放的為一些class的信息、常量、靜態變量等數據,當系統中要加載的類、反射的類和調用的方法較多時,Permanet Generation可能會被占滿,在未配置為采用CMS GC的情況下也會執行Full GC。如果經過Full GC仍然回收不了,那么JVM會拋出如下錯誤信息: java.lang.OutOfMemoryError: PermGen space 為避免PermGen占滿造成Full GC現象,可采用的方法為增大PermGen空間或轉為使用CMS GC。

?在JDK 1.8中用元空間替換了永久代作為方法區的實現,元空間是本地內存,因此減少了一種Full GC觸發的可能性。

6.5 Concurrent Mode Failure

?執行CMS GC的過程中同時有對象要放入老年代,而此時老年代空間不足(有時候“空間不足”是CMS GC時當前的浮動垃圾過多導致暫時性的空間不足觸發Full GC),便會報Concurrent Mode Failure錯誤,并觸發Full GC。

小結

?本文簡要地介紹了HotSpot虛擬機如何去發起內存回收的問題,也解答了文章開頭提出的三個問題中的前兩個——“哪些內存需要回收”和“何時回收”,同時對于第三個問題——“如何回收”,在原理層面作出了解答。


參考

  • 《深入理解Java虛擬機——JVM高級特性與最佳實踐》-周志明
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。