1. 程式人生 > 程式設計 >《深入理解Java虛擬機器器》(四):垃圾收集演演算法以及記憶體分配策略

《深入理解Java虛擬機器器》(四):垃圾收集演演算法以及記憶體分配策略

==============

讀書筆記系列

==============

接下來我們就要聊到最常見的問題了,垃圾收集演演算法,以及記憶體分配策略。

圖1. 常見的垃圾收集演演算法

圖2. Java 堆的分割槽及其比例

圖3. 記憶體分配策略

垃圾收集演演算法

1. 標記 - 清除演演算法

“標記-清除”(Mark-Swap)演演算法是最基礎的收集演演算法,後續的收集演演算法都是基於這種思路並對其不足進行改進而來的。顧名思義,演演算法分為“標記”和“清除”兩個階段:首先標記處所有需要回收的物件,即“死去”的物件,在標記完成之後統一回收所有被標記的物件。它的不足主要有兩個:一個是效率問題,標記和清除兩個過程的效率都不高;另一個是空間問題,標記清除之後會產生大量不連續的記憶體碎片,空間碎片太多可能會導致以後在程式執行過程中需要分配較大物件時,無法找到足夠連續記憶體而不得不提前觸發另一次垃圾收集動作。

圖1.1 標記清除演演算法示意圖

2. 複製演演算法

為瞭解決效率問題,“複製”(Copying)收集演演算法出現了。它將可用記憶體按容量劃分為大小相等的兩塊,每次只使用其中的一塊;當這一塊記憶體用完了,就將還存活的物件複製到另一塊上面,然後再把已使用過的記憶體空間一次清理掉。這樣使得每次都是對整個搬去進行記憶體回收,記憶體分配時也就不用考慮記憶體碎片等複雜情況,只要移動堆頂指標,按順序分配記憶體即可,實現簡單,執行高效。只是這種演演算法的代價是將記憶體縮小為了原來的一半,未免太高了一點。

圖1.2 複製演演算法示意圖

現在的商業虛擬機器器都採用這種收集演演算法來回收新生代。在新生代中的物件 98% 是“朝生夕死”的,所以並不需要 1 : 1 的比例來劃分記憶體空間,而是將記憶體分為一塊較大的 Eden 空間和兩塊較小的 Survivor 空間 ,每次使用 Eden 和其中一塊 Survivor 。當回收時,將 Eden 和 和 Survivor 中還存活著的物件一次性複製到另外一塊 Survivor 空間上,最後清理掉 Eden 和剛才用過的 Survivor 空間。HotSpot 虛擬機器器預設 Eden 和 Survivor 的大小比例是 8 : 1 。當 Survivor 空間不夠用時,需要依賴其他記憶體(這裡指老年代)進行分配和擔保(Handle Promotion)。

3. 標記-整理演演算法

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

於是,“標記 - 整理”(Mark-Compact)演演算法出現了,標記過程仍與“標記 - 清除”演演算法一樣,但後續步驟不是直接對可回收物件進行清理,而是讓所有存活的物件都向一端移動,然後直接清理掉端邊界以外的記憶體。

圖1.3 標記-整理演演算法示意圖

4. 分代收集演演算法

當前商業虛擬機器器的垃圾收集都採用“分代收集”(Generational Collection)演演算法,這種演演算法沒有新思想,只是根據物件存活週期的不同將記憶體劃分為幾塊。一般是把 Java 堆分為新生代和老年代:新生代中,每次垃圾收集時都發現有大批物件死去,只有少量存活,那就選用複製演演算法,只需付出少量存活物件的複製成本就可以完成收集;而老年代中因為物件存活率高,沒有額外空間對它進行分配擔保,就必須使用“標記 - 清除”或者“標記 - 整理”演演算法來進行回收。

記憶體分配與回收策略

Java 技術體系中所提倡的自動記憶體管理最終可以歸結為自動化地解決了兩個問題:給物件分配記憶體以及回收分配給物件的記憶體。

物件的記憶體分配,往大方向講,就是在堆上分配(但也可能經過 JIT 編譯後被拆散為標量型別並間接地棧上分配),物件主要分配在新生代的 Eden 區上,如果啟動了本地執行緒分配緩衝,將按執行緒優先在 TLAB(Thread Local Allocation Buffer) 上分配。少數情況下也可能會直接分配在老年代中,分配規則並不是百分百固定的,其細節取決於當前使用的是哪一種垃圾收集器組合,還有虛擬機器器中與記憶體相關的引數設定。

1. 物件優先在 Eden 區分配

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

注意:Minor GC 和 Full GC 有什麼不一樣嗎?
- 新生代 GC(Minor GC):指發生在新生代的垃圾收集動作,因為 Java 物件大多都具備朝生夕滅的特性,所以 Minor GC 非常頻繁,一般回收速度也比較快。
- 老年代 GC(Major GC / Full GC):指發生在老年代的 GC,出現了 Major GC,經常會伴隨至少一次的 Minor GC(但非絕對的)。Major GC 的速度一般會比 Minor GC 慢 10 倍以上。
複製程式碼

2. 大物件直接進入老年代

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

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

虛擬機器器為了實現分代管理物件,便給每個物件定義了一個物件年齡(Age)計數器。如果物件在 Eden 出生並經過一次 Minor GC 後仍然存活,並且能被 Survivor 容納的話,將被移動到 Survivor 空間中,並且物件年齡設為 1 。物件在 Survivor 區中每“熬過”一次 Minor GC,年齡就會增加 1 歲,當它的年齡增加到一定程度(預設為 15 歲),就將會被晉升到老年代中。物件晉升老年代的閾值,可以通過引數 -XX:MaxTenuringThreshold 設定。

4. 動態物件年齡判定

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

總結

本篇筆記簡單列了一下我們老生常談的幾種垃圾收集演演算法和記憶體分配策略,到此我們便清楚了 JVM 的自動記憶體管理的過程,即如何給物件分配記憶體以及回收分配給物件的記憶體。