1. 程式人生 > >JVM記憶體分配策略,及垃圾回收演算法

JVM記憶體分配策略,及垃圾回收演算法

本人免費整理了Java高階資料,一共30G,需要自己領取;
傳送門:https://mp.weixin.qq.com/s/JzddfH-7yNudmkjT0IRL8Q

說起垃圾收集(Garbage Collection, GC),想必大家都不陌生,它是JVM實現裡非常重要的一環,JVM成熟的記憶體動態分配與回收技術使Java(當然還有其他執行在JVM上的語言,如Scala等)程式設計師在提升開發效率上獲得了驚人的便利。理解GC,對於理解JVM和Java語言有著非常重要的作用。並且當我們需要排查各種記憶體溢位、記憶體洩漏問題時,當垃圾收集稱為系統達到更高併發量的瓶頸時,只有深入理解GC和記憶體分配,才能對這些“自動化”的技術實施必要的監控和調節。

在Java的執行時資料區中,程式計數器、虛擬機器棧、本地方法棧三個區域都是執行緒私有的,隨執行緒而生,隨執行緒而滅,在方法結束或執行緒結束時,記憶體自然就跟著回收了,不需要過多考慮回收的問題。而Java堆方法區則不一樣,一個介面中的多個實現類需要的記憶體可能不一樣,一個方法中的多個分支需要的記憶體也可能不一樣,我們只有在程式處於執行期間才能知道會建立哪些物件,這部分記憶體的分配和回收都是動態的,垃圾回收器關注的是這部分記憶體,後續討論的“記憶體”分配回收也是指這一塊,尤其需要注意。

GC主要回答了以下三個問題:

  • 哪些記憶體需要回收?
  • 什麼時候回收?
  • 如何回收?

這三個問題的具體解決方案,也就是本文接下來要講解的內容。

物件存活判定演算法

在堆裡存放著Java世界中幾乎所有的物件例項,垃圾收集器在對堆進行回收前,首要的就是確定這些物件中哪些還“存活”著,哪些已經“死去”(即不可能再被任何途徑使用的物件)。

引用計數演算法

引用計數演算法是在JVM中被摒棄的一種物件存活判定演算法,不過它也有一些知名的應用場景(如Python、FlashPlayer),因此在這裡也簡單介紹一下。

用引用計數器判斷物件是否存活的過程是這樣的:給物件中新增一個引用計數器,每當有一個地方引用它時,計數器加1;當引用失效時,計數器減1;任何時刻計數器為0的物件就是不可能再被使用的。

引用計數演算法的實現簡單,判定效率也很高,大部分情況下是一個不錯的演算法。它沒有被JVM採用的原因是它很難解決物件之間迴圈引用的問題

。例如以下例子:

/** * testGC()方法執行後,objA和objB會不會被GC呢? */
public class ReferenceCountingGC {

    public Object instance = null;

    private static final int _1MB = 1024 * 1024;

    /** * 這個成員屬性的唯一意義就是佔點記憶體,以便在能在GC日誌中看清楚是否有回收過 */
    private byte[] bigSize = new byte[2 * _1MB];

    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都有欄位instance,賦值令objA.instance = objB;objB.instance = objA;,除此之外,這兩個物件再無引用。如果JVM採用引用計數演算法來管理記憶體,這兩個物件不可能再被訪問,但是他們互相引用著對方,導致它們引用計數不為0,所以引用計數器無法通知GC收集器回收它們

而事實上執行這段程式碼,objA和objB是可以被回收的,下面一節將介紹JVM實際使用的存活判定演算法。

可達性分析演算法

在主流商用程式語言的實現中,都是通過可達性分析(tracing GC)來判定物件是否存活的。此演算法的基本思路是:通過一系列的稱為“GC Roots”的物件作為起點,從這些節點向下搜尋,搜尋所走過的路徑稱為引用鏈(Reference Chain),當一個物件到GC Roots沒有任何引用鏈相連(用圖論的話來說,就是GC Roots 到這個物件不可達)時,則證明此物件時不可用的。用下圖來加以說明:

上圖中,物件object 5、object 6、object 7雖然互有關聯,但是它們到GC Roots是不可達的,所以它們將會被判定為是可回收的物件。

可以看到,GC Roots在物件圖之外,是特別定義的“起點”,不可能被物件圖內的物件所引用。

準確地說,GC Roots其實不是一組物件,而通常是一組特別管理的指向引用型別物件的指標,這些指標是tracing GC的trace的起點。它們不是物件圖裡的物件,物件也不可能引用到這些“外部”的指標,這也是tracing GC演算法不會出現迴圈引用問題的基本保證。因此也容易得出,只有引用型別的變數才被認為是Roots,值型別的變數永遠不被認為是Roots。只有深刻理解引用型別和值型別的記憶體分配和管理的不同,才能知道為什麼root只能是引用型別。

在Java中,可作為GC Roots的物件包括以下幾種:

  • 虛擬機器棧(棧幀中的區域性變量表,Local Variable Table)中引用的物件。
  • 方法區中類靜態屬性引用的物件。
  • 方法區中常量引用的物件。
  • 本地方法棧中JNI(即一般說的Native方法)引用的物件。

看到這裡你可能要問,選擇這些物件的依據是什麼呢?

可以概括得出,可作為GC Roots的節點主要在全域性性的引用執行上下文中。要明確的是,tracing gc必須以當前存活的物件集為Roots,因此必須選取確定存活的引用型別物件。GC管理的區域是Java堆,虛擬機器棧方法區本地方法棧不被GC所管理,因此選用這些區域內引用的物件作為GC Roots,是不會被GC所回收的。其中虛擬機器棧和本地方法棧都是執行緒私有的記憶體區域,只要執行緒沒有終止,就能確保它們中引用的物件的存活。而方法區中類靜態屬性引用的物件是顯然存活的。常量引用的物件在當前可能存活,因此,也可能是GC roots的一部分。

兩次標記與 finalize()方法

即使在可達性分析演算法中不可達的物件,也不是一定會死亡的,它們暫時都處於“緩刑”階段,要真正宣告一個物件“死亡”,至少要經歷兩次標記過程:

如果物件在進行可達性分析後發現沒有與 GC Roots相連線的引用鏈,那它將會被第一次標記並且進行一次篩選,篩選的條件是此物件是否有必要執行finaliza()方法。當物件沒有覆蓋finaliza()方法,或者finaliza()方法已經被虛擬機器呼叫過,虛擬機器將這兩種情況都視為“沒有必要執行”。

如果這個物件被判定為有必要執行finaliza()方法,那麼此物件將會放置在一個叫做 F-Queue 的佇列中,並在稍後由一個虛擬機器自動建立的、低優先順序的Finalizer執行緒去執行它。這裡所謂的“執行”是指虛擬機器會觸發此方法,但並不承諾會等待它執行結束,原因是:如果一個物件在finaliza()方法中執行緩慢,或者發生了死迴圈(更極端的情況),將很可能導致F-Queue 佇列中的其它物件永久處於等待,甚至導致整個記憶體回收系統崩潰。

finaliza()方法是物件逃脫死亡命運的最後一次機會,稍後GC將對F-Queue 佇列中的物件進行第二次小規模的標記。如果物件想在finaliza()方法中成功拯救自己,只要重新與引用鏈上的任何一個物件建立關聯即可,例如把自己(this關鍵字)賦值給某個類變數或者物件的成員變數,這樣在第二次標記時它將被移出“即將回收”的集合;如果物件這時候還沒有逃脫,基本上它就真的被回收了。

值得注意的是,如果程式碼中有兩段一模一樣的程式碼段,執行結果卻是一次逃脫成功,一次失敗。這是因為任何一個物件的finalize()方法都只會被系統呼叫一次,如果物件面臨下一次回收,它的finalize()方法不會再被執行,因此第二次逃脫行動失敗。

需要說明的是,使用finalize()方法來“拯救”物件是不值得提倡的,因為它不是C/C++中的解構函式,而是Java剛誕生時為了使C/C++程式設計師更容易接受它所做的一個妥協。它的執行代價高昂,不確定性大,無法保證各個物件的呼叫順序。finalize() 能做的工作,使用try-finally或者其它方法都更適合、及時,所以筆者建議大家可以忘掉此方法存在。

回收方法區

很多人認為方法區沒有垃圾回收,Java虛擬機器規範中確實說過不要求,而且在方法區中進行垃圾收集的“價效比”較低:在堆中,尤其是新生代,常規應用進行一次垃圾收集可以回收70%~95%的空間,而方法區的效率遠低於此。在JDK 1.8中,JVM摒棄了永久代,用元空間來作為方法區的實現,下面介紹的將是元空間的垃圾回收。

元空間的記憶體管理由元空間虛擬機器來完成。先前,對於類的元資料我們需要不同的垃圾回收器進行處理,現在只需要執行元空間虛擬機器的C++程式碼即可完成。在元空間中,類和其元資料的生命週期其對應的類載入器是相同的。話句話說,只要類載入器存活,其載入的類的元資料也是存活的,因而不會被回收掉。

我們從行文到現在提到的元空間稍微有點不嚴謹。準確的來說,每一個類載入器的儲存區域都稱作一個元空間,所有的元空間合在一起就是我們一直說的元空間。當一個類載入器被垃圾回收器標記為不再存活,其對應的元空間會被回收。在元空間的回收過程中沒有重定位和壓縮等操作。但是元空間內的元資料會進行掃描來確定Java引用。

垃圾收集演算法

本節將介紹幾種垃圾收集演算法的思想及其發展過程,具體的實現將在稍後介紹。

標記-清除(Mark-Sweep)演算法

標記-清除(Mark-Sweep)演算法是最基礎的垃圾收集演算法,後續的收集演算法都是基於它的思路並對其不足進行改進而得到的。顧名思義,演算法分成“標記”、“清除”兩個階段:首先標記出所有需要回收的物件,在標記完成後統一回收所有被標記的物件,標記過程在前一節講述物件標記判定時已經講過了。

標記-清除演算法的不足主要有以下兩點:

  • 空間問題,標記清除之後會產生大量不連續的記憶體碎片,空間碎片太多可能會導致以後在程式執行過程中需要分配較大物件時,無法找到足夠的連續記憶體而不得不觸發另一次垃圾收集動作。
  • 效率問題,因為記憶體碎片的存在,操作會變得更加費時,因為查詢下一個可用空閒塊已不再是一個簡單操作。

標記-清除演算法的執行過程如下圖所示:

複製(Copying)演算法

為了解決標記-清除演算法的效率問題,一種稱為“複製”(Copying)的收集演算法出現了,思想為:它將可用記憶體按容量分成大小相等的兩塊,每次只使用其中的一塊。當這一塊記憶體用完,就將還存活著的物件複製到另一塊上面,然後再把已使用過的記憶體空間一次清理掉。

這樣做使得每次都是對整個半區進行記憶體回收,記憶體分配時也就不用考慮記憶體碎片等複雜情況,只要移動堆頂指標,按順序分配記憶體即可,實現簡單,執行高效。只是這種演算法的代價是將記憶體縮小為原來的一半,代價可能過高了。複製演算法的執行過程如下圖所示:

Minor GC與複製演算法

現在的商業虛擬機器都使用複製演算法來回收新生代。新生代的GC又叫“Minor GC”,IBM公司的專門研究表明:新生代中的物件98%是“朝生夕死”的,所以Minor GC非常頻繁,一般回收速度也比較快,同時“朝生夕死”的特性也使得Minor GC使用複製演算法時不需要按照1:1的比例來劃分新生代記憶體空間。

Minor GC過程

事實上,新生代將記憶體分為一塊較大的Eden空間兩塊較小的Survivor空間(From Survivor和To Survivor)每次Minor GC都使用Eden和From Survivor,當回收時,將Eden和From Survivor中還存活著的物件都一次性地複製到另外一塊To Survivor空間上,最後清理掉Eden和剛使用的Survivor空間。一次Minor GC結束的時候Eden空間和From Survivor空間都是空的,而To Survivor空間裡面儲存著存活的物件。在下次MinorGC的時候,兩個Survivor空間交換他們的標籤,現在是空的“From” Survivor標記成為“To”“To” Survivor標記為“From”。因此,在MinorGC結束的時候,Eden空間是空的,兩個Survivor空間中的一個是空的,而另一個儲存著存活的物件。

HotSpot虛擬機器預設的Eden : Survivor的比例是8 : 1,由於一共有兩塊Survivor,所以每次新生代中可用記憶體空間為整個新生代容量的90%(80%+10%),只有10%的容量會被“浪費”。

分配擔保

上文說的98%的物件可回收只是一般場景下的資料,我們沒有辦法保證每次回收都只有不多於10%的物件存活,當Survivor空間不夠用時,需要依賴老年代記憶體進行分配擔保(Handle Promotion)。如果另外一塊Survivor上沒有足夠空間存放上一次新生代收集下來的存活物件,這些物件將直接通過分配擔保機制進入老年代。

標記-整理(Mark-Compact)演算法

複製演算法在物件存活率較高時要進行較多的複製操作,效率將會變低。更關鍵的是:如果不想浪費50%的空間,就需要有額外的空間進行分配擔保,以應對被使用的記憶體中所有物件都100%存活的極端情況,所以在老年代一般不能直接選用複製演算法

根據老年代的特點,標記-整理(Mark-Compact)演算法被提出來,主要思想為:此演算法的標記過程與標記-清除演算法一樣,但後續步驟不是直接對可回收物件進行清理,而是讓所有存活的物件都向一端移動,然後直接清理掉邊界以外的記憶體。具體示意圖如下所示:

分代收集(Generational Collection)演算法

當前商業虛擬機器的垃圾收集都採用分代收集(Generational Collection)演算法,此演算法相較於前幾種沒有什麼新的特徵,主要思想為:根據物件存活週期的不同將記憶體劃分為幾塊,一般是把Java堆分為新生代和老年代,這樣就可以根據各個年代的特點採用最適合的收集演算法:

  • 新生代 在新生代中,每次垃圾收集時都發現有大批物件死去,只有少量存活,那就選用複製演算法,只需要付出少量存活物件的複製成本就可以完成收集。
  • 老年代 在老年代中,因為物件存活率高、沒有額外空間對它進行分配擔保,就必須使用“標記-清除”“標記-整理”演算法來進行回收。

HotSpot的演算法實現

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

列舉根節點

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

GC停頓(”Stop The World”)

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

準確式GC與OopMap

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

安全點(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需要中斷執行緒的時候,不直接對執行緒操作,僅僅簡單地設定一個標誌,各個執行緒執行時主動去輪詢這個標誌,發現中斷標誌為真時就自己中斷掛起。輪詢標誌的地方和安全點是重合的,另外再加上建立物件需要分配記憶體的地方

安全區域(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收集器(將在下一篇文章中講解)為例,介紹記憶體分配的策略。

物件優先在Eden區分配

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

大物件直接進入老年代

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

虛擬機器提供了一個-XX:PretenureSizeThreshold引數,令大於這個設定值的物件直接在老年代分配。這樣做的目的是避免在Eden區及兩個Survivor區之間發生大量的記憶體複製(新生代採用複製演算法回收記憶體)。

長期存活的物件將進入老年代

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

動態物件年齡判定

為了能更好地適應不同程式的記憶體狀況,虛擬機器並不是永遠地要求物件的年齡必須達到了MaxTenuringThreshold才能晉升老年代,如果在Survivor空間中相同年齡所有物件大小的總和大於Survivor空間的一半,年齡大於或等於該年齡的物件就可以直接進入老年代,無須等到MaxTenuringThreshold中要求的年齡。

空間分配擔保

在發生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的觸發條件。

呼叫System.gc()

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

老年代空間不足

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

空間分配擔保失敗

前文介紹過,使用複製演算法的Minor GC需要老年代的記憶體空間作擔保,如果出現了HandlePromotionFailure擔保失敗,則會觸發Full GC。

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觸發的可能性。

Concurrent Mode Failure

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

小結

本文簡要地介紹了HotSpot虛擬機器如何去發起記憶體回收的問題,也解答了文章開頭提出的三個問題中的前兩個——“哪些記憶體需要回收”和“何時回收”,同時對於第三個問題——“如何回收”,在原理層面作出瞭解答。在下一篇文章中,筆者將通過介紹幾種具體的垃圾收集器,來更深入地回答第三個問題。

 

本人免費整理了Java高階資料,涵蓋了Java、Redis、MongoDB、MySQL、Zookeeper、Spring Cloud、Dubbo高併發分散式等教程,一共30G,需要自己領取。
傳送門:https://mp.weixin.qq.com/s/JzddfH-7yNudmkjT0IRL