JVM 記憶體分代、垃圾回收漫談

咕咚發表於2017-04-25

關於 JVM 記憶體模型以及垃圾回收的文章網上很多,自己以前也看過很多,但是卻從來也沒有系統的去了解學習過,這次正巧看到一本講解 JVM 的好書 – 周志明老師的《深入理解 Java 虛擬機器》,然後就花了點時間,認真系統的學習了一遍,儘管還沒有看完,但是已經愛耐不住,覺得要寫點東西出來,寫的過程是一個思考融匯的過程,也是一個知識昇華的過程。

這篇主要簡單分享一下關於 JVM 記憶體模型、記憶體溢位、記憶體分代、以及垃圾回收演算法的相關知識。當然在原書中,這幾部分作者都花了不少篇幅去講解。如果這篇文章讓你對相關知識產生了興趣而意猶未盡,推薦去閱讀原書。

JVM 記憶體區域

都知道 JVM 的記憶體區域分為5個部分,如果有疑惑,可以參看之前的一篇文章 - JVM 記憶體區域介紹

這裡也簡單羅列一下 JVM 的五部分

  • 程式計數器這是一塊較小的記憶體空間,它的作用可以看做是當前執行緒所執行的位元組碼的行號指示器,執行緒私有。
  • Java 虛擬機器棧它是 Java方法執行的記憶體模型,每一個方法被呼叫到執行完成的過程,就對應著一個棧幀在虛擬機器棧中從入棧到出棧的過程,執行緒私有。
  • 本地方法棧跟虛擬機器棧類似,不過本地方法棧用於執行本地方法,執行緒私有。
  • Java 堆該區域存在的唯一目的就是存放物件,幾乎應用中所有的物件例項都在這裡分配記憶體,所有執行緒共享。
  • 方法區它用於儲存已被虛擬機器載入的類資訊、常量、靜態變數、即時編譯器編譯後的程式碼等資料,所有執行緒共享。

有關 OOM

都知道,任何一個應用在啟動後,作業系統分配給它的記憶體一定是有限的,所以如何合理有效的管理記憶體,就變得尤為重要。

而從上節可知,我們一般討論的物件記憶體分配均發生在 Java 堆上。所以這裡說的記憶體管理大部分情況下即指對 Java 堆記憶體。而程式計數器、虛擬機器棧他們隨著執行緒生而生,亡而亡,所以他們記憶體相對比較好管理,出現的問題也比較少。

一個應用啟動後,不停執行,不停的執行命令,建立物件,而這些物件,大都存放在堆記憶體區域。這部分割槽域的大小是有限的,而需要生成的物件是無限的,當某一次建立物件時發現堆記憶體實在沒有空間可用來建立物件的時候,JVM 就會爆出 OutOfMemoryError 異常(後文統稱 OOM),程式就會掛掉。

上面只是說明了一下表象。其實 OOM 遠不是上面說的那麼簡單。如果要理解 OOM,這裡還有一些其他知識需要說明。

  • OOM 發生前其實 JVM 會進行記憶體的垃圾回收(GC)。
  • 垃圾回收有多種不同的實現演算法。
  • 為了更好的管理記憶體,堆記憶體進行了分代。
  • 堆記憶體的新生代和老年代的垃圾回收演算法不一致。

其實,這裡的知識需要綜合理解,你才會對 OOM 有一個全面的認識。

記憶體分代

一個應用啟動,作業系統會給他分配一個初始的記憶體大小,由上可知,這部分記憶體大部分應該屬於堆記憶體,JVM 為了更好地利用管理這部分記憶體,對該區域做了劃分。一部分成為新生代,另一部分稱為老年代。

一開始物件的建立都發生在新生代,隨著物件的不斷建立,如果新生代沒有空間建立新物件,將會發生 GC ,這時的 GC 稱之為 Minor GC,位於新生代的物件每經過一次 Minor GC 後,如果這個物件沒有被回收,則為自己的標記數加1,這個標記數用於標識這個物件經歷了多少次的 Minor GC,對於 Sun 的 Hotspot 虛擬機器,如果這個次數超過 15 ,該物件才會被移動到老年代。

隨著時間的推移,如果老年代也沒有足夠的空間容納物件,老年代也會試著發起 GC,這時的 GC 被稱為 Full GC。

相比 Minor GC,Full GC 發生的次數比較少,但是每發生一次 Full GC,整個堆記憶體區域都需要執行一次垃圾回收,這對程式效能造成的影響比 Minor GC 大很多。所以我們應該儘量避免或者減少 Full GC 的發生。

同時,在堆記憶體區域,發生最多的 GC 情形就是新生代的 Minor GC 了,因為所有的物件會優先去新生代開闢空間,所以這塊的記憶體變化會很快,只有記憶體不夠用,就會發生 GC,但是一般的 Minor GC 執行比 Full GC 快很多。為什麼呢?因為新生代和老年代的垃圾回收演算法不一樣。

垃圾回收演算法

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

這是最基礎的收集演算法,如它的名字一樣,演算法分為“標記”和“清除”兩個階段:

首先標記出所有需要回收的物件,在標記完成後統一回收掉所有被標記的物件。

之所以說它是最基礎的收集演算法,是因為後續的收集演算法都是基於這種思路並對其缺點進行改進而得到的。

它的主要缺點有兩個:一個是效率問題,標記和清除過程的效率都不高;另外一個是空間問題,標記清除之後會產生大量不連續的記憶體碎片,空間碎片太多可能會導致,當程式在以後的執行過程中需要分配較大物件時無法找到足夠的連續記憶體而不得不提前觸發另一次垃圾收集動作。

複製演算法(Copying)

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

這樣使得每次都是對其中的一塊進行記憶體回收,記憶體分配時也就不用考慮記憶體碎片等複雜情況,只要移動堆頂指標,按順序分配記憶體即可,實現簡單,執行高效。只是這種演算法的代價是將記憶體縮小為原來的一半,未免太高了一點。

但是這種演算法的效率相當高,所以,現在的商業虛擬機器都採用這種收集演算法來回收新生代。為什麼新生代可以使用複製演算法呢?

IBM 有專門研究表明,新生代中的物件 98% 都是朝生夕死,所以就不需要按照1:1的比例來劃分記憶體空間。這裡鑑於此,新生代採用瞭如下的劃分策略。

現在把新生代再劃分為三部分,一塊較大的 Eden(伊甸園) 和兩塊較小的 Survivor(倖存者) 區域。

當回收時,將 Eden 和 Survivor 中還存活著的物件一次性地拷貝到另外一塊Survivor空間上,最後清理掉Eden和剛才用過的Survivor的空間。HotSpot 虛擬機器預設Eden和Survivor的大小比例是8∶1,也就是每次新生代中可用記憶體空間為整個新生代容量的90%(80%+10%),只有10%的記憶體是會被“浪費”的。

這樣清理完成後,原來的 Survivor 就空了,並一直保持為空,直到下次 Minor GC 時,它再作為存活物件的盛放地。兩個 Survivor 就這樣輪流當做 GC 過程中新生代存活物件的中轉站。

但是,如果使用複製演算法的記憶體區域有大量的存活物件時,複製演算法就會變得捉襟見肘,這時需要更大的 Survivor 區用於盛放那些存活物件,甚至可能需要 1:1的比例。所以針對堆記憶體區域的老年代,就有了下面的演算法。

標記-整理演算法

標記過程仍然與“標記-清除”演算法一樣,但後續步驟不是直接對可回收物件進行清理,而是讓所有存活的物件都向一端移動,然後直接清理掉端邊界以外的記憶體。這種方法避免了碎片的產生,同時也不需要一塊額外的記憶體空間,對於老年代會比較合適。

但是相比複製演算法,雖然該演算法佔用的記憶體空間少,但是耗費的垃圾回收時間會比複製演算法久,所以上面也說了

我們應該儘量避免或者減少 Full GC 的發生。

這兩種演算法用精煉的語言描述就是

  • 複製演算法:用空間換時間
  • 標記-整理演算法:用時間換空間

一句話 魚與熊掌不可兼得,但是針對新生代和老年代,他們都是最佳的選擇。

總結

簡單梳理一下文中講到的一些知識點

  • 為了更好的管理堆記憶體,該區域分為新生代和老年代。
  • 新生代發生垃圾回收要比老年代頻繁。
  • 新生代發生的垃圾回收成為 Minor GC;老年代發生的 GC 成為 Full GC。
  • 新生代使用複製演算法進行垃圾回收;老年代使用標記-整理演算法
  • 為了更高效管理新生代的記憶體,按照複製演算法,結合 IBM 的研究論證,新生代分為三塊,一塊比較大的 Eden 區和兩塊比較小的 Survivor 區,比例為 8:1:1

參考

《深入理解 Java 虛擬機器》- 周志明老師

相關文章