1. 程式人生 > >jvm垃圾回收演算法詳解

jvm垃圾回收演算法詳解

在JDK1.8+的版本中,JVM記憶體管理結構有了一定的優化調整。主要是方法區(持久代)取消變成了直接使用元資料區(直接記憶體)的方式,但是整體上JVM的結構並沒有大改,特別是我們最為關心的堆記憶體管理方式並沒有在JDK1.8+的版本中有什麼變化,所以圖中的結構整體上是沒有什麼不準確的,之所以將方法區以及持久代標註出來,主要還是為了起到對比認識的作用,大家知道就可以了。

關於持久代元資料區的使用問題,目前可以理解就是使用的實體記憶體,理論上是不受JVM自動記憶體回收機制管理的,如果不設定引數大小預設最大使用限制就是作業系統可用實體記憶體的大小,設定了-XX:MetaspaceSize引數的話,JVM就會在使用實體記憶體空間時自己進行限制。

至於直接記憶體與實體記憶體到底是不是一回事,我認為對於我們理解上沒有區別,只是概念的區別,另外就是對這塊記憶體使用細節上的區別,如果不受JVM的自動回收管理,那麼怎麼管理呢?說到底還是JVM本身在直接使用實體記憶體或者說是直接記憶體(用時直接“malloc”實體記憶體區域,而不再是JVM程序啟動時初始化的記憶體區域),還有一種概念叫native memory,說實話我暫時還不理解他們到底有啥區別,如果大家對這些概念有更好的認識,也可以給我留言哦!之所以對這幾個問題做一些筆墨的說明,主要是在之前的文章中大家對此提出了疑問,所以正好在這節的內容中進行下闡述。

回到今天的主題,我們知道JAVA最大的優點就是可以實現自動記憶體管理,這極大的便利了JAVA程式設計師,降低了使用成本。但這也使得平時我們在使用JAVA程式設計時不太關注JVM到底是怎樣進行記憶體回收的,只有在需要實際對JVM進行系統性能調優,這裡的場景可能是在系統面臨極致效能優化要求時,我們才發現需要對JAVA的整體記憶體結構以及記憶體回收機制要有一定的認識和了解才行。

我們大致對整個垃圾回收系統進行了標註,這裡主要涉及回收策略、回收演算法、垃圾回收器這幾個部分。形象一點表述,就是JVM需要知道那些記憶體可以被回收,要有一套識別機制,在知道那些記憶體可以回收以後具體採用什麼樣的回收方式,這就需要設計一些回收演算法,而具體的垃圾回收器就是根據不同記憶體區域的使用特點,採用相應地回收策略和演算法的具體實現了。

我們標註了不同垃圾回收器所適用的特定記憶體區域,對於JVM垃圾回收這塊的優化,就是我們需要在瞭解這些垃圾回收演算法、垃圾回收器特點後能夠根據自己應用的場景選擇合適的垃圾收集器,以及各區域垃圾收集器的搭配關係。下面我們就從這幾個方面給大家介紹,JVM的垃圾回收相關的知識點。

回收策略

我們知道,JVM進行記憶體回收的主要目的是為了回收不再使用的記憶體,因為在進行JAVA程式編寫時,我們只有new的操作,而不需要收工釋放不再使用的空間,如果這些空閒記憶體不能及時被回收,很快我們的JVM記憶體空間就會洩露(新申請記憶體空間的操作失敗,導致程式報錯),所以回收不再使用的記憶體的目的則是為了及時釋放空間,騰籠換鳥,以防止記憶體洩漏。

那麼問題來了,JAVA程式申請了那麼多的記憶體空間,那些記憶體才能被認定是不再使用的記憶體呢?搞錯了,如果把正在被程式使用的記憶體給釋放了,程式邏輯就空指標異常了!

我們知道在JVM中記憶體分配的基本粒度主要是物件、基本型別。而基本型別的使用主要是包括在物件中的區域性變數,所以回收物件所佔用的記憶體是JAVA垃圾回收的主要目標。

那麼如何判斷物件是處於可回收狀態的呢?在主流的JVM中是採用“可達性分析演算法”來進行判斷的。

這個演算法的基本思路就是通過一系列的稱為“GC Roots”的物件作為起始點,並從這些節點開始往下進行搜尋,搜尋走過的路徑我們稱之為引用鏈(Reference Chain),當一個物件到GC Roots沒有任何引用鏈相連時,我們就稱之為物件引用不可達,則證明這個物件是不可用的,就可以暫時判定這個物件為可回收物件。示意圖如下:

在圖中雖然Obj F與Obj J之間互相有關聯但是它們到GC Roots是不可達的,所以將會被判定為可回收物件。既然如此,什麼樣的物件可以作為GC Roots物件呢?

在JAVA中可以被作為GC Roots的物件主要是:虛擬機器棧-棧幀中的本地變量表所引用的物件、方法區(<JDK1.8)中類靜態屬性所引用的物件/常量屬性所引用的物件、本地方法棧中引用的物件。

這裡還需要注意一個小的細節,就是被判定為物件不可達的物件也並非會被立刻回收,在學習JAVA語法是我們應該學習過finalize()方法,如果物件重寫了finalize方法,並重新把this關鍵字賦值給了某個類變數或物件的成員變數的話,該物件就會被"救活",具體過程可參考上圖所示,只是這種方式並不鼓勵大家使用,瞭解下就行。

在關於如何判定物件是否屬於不再使用的記憶體時,還有個通常會被大家錯誤認為是JVM使用的方式-“引用計數法”,事實上引用計數法的實現比較簡單,判定效率也比較高,在Python語言中就使用了這種演算法進行記憶體管理,但是它有一個比較難解決的物件之間迴圈引用的問題,所以在JAVA虛擬機器裡並沒有選用“引用計數法”來管理記憶體。這個問題很多人都會搞錯,包括有很多年開發經驗的程式設計師,需要大家注意下!

回收演算法

在JVM中主要的垃圾收集演算法有:標記-清除、標記-清除-壓縮(簡稱“標記-整理”)、標記-複製-清除(簡稱“複製”)、分代收集演算法。這幾種收集演算法互相配合,針對不同的記憶體區域採取對應的收集演算法實現(這裡具體是由相應的垃圾收集器實現)。

下面我們就分別來看下這幾種收集演算法的特點:

1)、標記-清除

標記-清除演算法是最為基礎的一種收集演算法,演算法分為:“標記”和“清除”兩個階段。首先標記出所有需要回收的物件(標記的過程就是上面介紹過的根節點可達演算法),在標記完後統一回收所有被標記物件佔用的記憶體空間。

示意圖如下:

這種收集演算法的優點是簡單直接,不會影響JVM程序的正常執行。而其缺點也是非常明顯,首先,這樣的回收方式會產生大量不連續的記憶體碎片,不利於後續連續記憶體的分配;其次,這種方式的效率也不高。

2)、標記-複製-清除

這種演算法的思路是將可用的記憶體空間按容量劃分為大小相等的兩塊,每次只使用其中一塊。當這一塊使用完了,就將還存活著的物件複製到另外一塊上面(移動堆頂指標,按順序分配記憶體),然後再把已使用過的記憶體空間一次清理掉。

示意圖如下:

這種收集方式比較好的解決了效率和記憶體碎片的問題,但是會浪費掉一般的記憶體空間。目前此種演算法主要用於新生代回收(文頂的圖中有標註)。

因為新生代的中98%的物件都是很快就需要被回收的物件,這一點大家在程式設計時可以體會到,所以並不需要1:1的比例來劃分記憶體空間,在新生代中JVM是按照“8:1:1”的比例(文頂圖中有標註)來將整個新生代記憶體劃分為一塊較大的Eden區和兩塊較小的Survivor區(S0、S1)。

每次使用Eden區和其中一個Survivor區,當發生回收時將Eden區和Survivor區中還存活的物件一次性複製到另一塊Survivor區上,最後清理掉Eden區和剛才使用過的Survivor區。理想情況下,每次新生代中的可用空間是整個新生代容量的90%(80%+10%),只會有10%的記憶體會被浪費。實際情況中,如果另外一個10%的Survivor區無法裝下所有還存活的物件時,就會將這些物件直接放入老年代空間中(這塊在後面的分代回收演算法會說到,這裡先了解下)。

3)、標記-清除-壓縮

如果在物件存活率較高的情況下,仍然採用複製演算法的話,因為要進行較多的複製操作,效率就會變得很低,而且如果不想浪費50%的記憶體空間的話,就還需要額外的空間進行分配擔保,以應對存活物件超額的情況。顯然老年代不能採用2)中的複製演算法。

根據老年代的特點,標記-清除-壓縮(簡稱標記-整理)演算法應運而生,這種演算法的標記過程仍然與“標記-清除”演算法一樣,只是後續的步驟不再是直接清除可以回收的物件,而是將所有存活的物件都向一端移動後,再直接清理掉端邊界以外的記憶體。

示意圖如下:

4)、分代回收演算法

實際上在講解複製演算法時已經涉及到了分代回收的內容,這種演算法根據物件存活週期的不同將記憶體劃分為幾塊,Java中主要是新生代、年老代。這樣就可以根據各個年代的特點,採用合適的收集演算法了,在文頂的圖中已經標示,新生代採用了複製演算法,而老年代採用了整理演算法,這裡就不再贅述。