1. 程式人生 > >《深入理解Java虛擬機器》個人讀書總結——垃圾收集/回收演算法

《深入理解Java虛擬機器》個人讀書總結——垃圾收集/回收演算法

說起垃圾回收,我估計很多初級java開發(包括之前的我)想到的是這個JVM會幫我管理的啊,我們不太需要去考慮這種事情。但是,當需要排查各種記憶體溢位、記憶體洩漏問題時,當垃圾收整合為系統達到更高併發量的瓶頸的時候,我們就有必要對垃圾回收GC進行了解了。思考GC需要完成的3件事情:
1.哪些記憶體需要回收
2.什麼時候回收
3.如何回收

哪些記憶體需要回收

首先我們要考慮的是哪些記憶體需要回收,在介紹Java的記憶體區域的時候,我們知道棧中的棧幀所分配的記憶體基本上是在類結構確定下來是就已知的。所以程式計數器、虛擬機器棧、本地方法棧這3個區域的記憶體分配和回收都具備確定性,它們隨著執行緒而生,隨著執行緒而滅。我們可以認為這一部分記憶體是靜態的。而Java堆和方法區的記憶體分配,我們只有在程式執行時才知道要建立多少物件,從而分配多少記憶體。這一部分記憶體我們可以認為是動態的。垃圾收集器所關注的是這部分記憶體。 那回收什麼樣的記憶體?當然是回收那些創建出來的物件都沒人用的物件所佔用的記憶體空間,因此在對這部分記憶體回收之前,第一件事就是需要確認這些物件是死是活(不可能再被任何途徑使用的物件即認為是死)

引用計數演算法

引用計數法的基本思想是:給物件中新增一個引用計數器,每當有一個地方引用它時,計數器值就加1,;當引用失效時,計數器值就減1;任何時刻計數器為0的物件就是不可能再被使用的。
客觀地說,這個判斷方法實現簡單,判定效率也很高,也有些應用案例,但在主流的Java虛擬機器裡並沒有選擇此方法來管理記憶體,其中最主要的原因是它很難解決物件之間迴圈引用的問題。

可達性分析演算法

目前主流的Java虛擬機器都採用可達性分析來判定物件是否存活的。這個演算法的基本思路就是通過一系列的稱為“GC Roots”的物件作為起始點,從這些節點開始向下搜尋,搜尋所走過的路徑稱為引用鏈,當一個物件到GC Roots沒有任何引用鏈相連時,則證明此物件是不可用的。如圖所示:
這裡寫圖片描述


物件5.6.7雖然它們之間有關聯,但是它們與GC Roots是不可達的,所以判定它們是可回收的物件。
在Java語言中,可作為GC Roots的物件包括下面幾種:
1.虛擬機器棧(棧幀中的本地變量表)中引用的物件。
2.方法區中類靜態屬性引用的物件。
3.方法區中常量引用的物件。
4.本地方法棧中JNI(即一般說的Native方法)引用的物件。
總結上面所說,都牽扯到一個詞“引用”。在JDK1.2之前,對引用的定義就是說如果reference型別的資料總儲存的數值代表的是另外一塊記憶體的起始地址,就稱這塊記憶體代表著一個引用。但這樣的定義使得我們的物件就只有被引用和沒有被引用這兩種狀態。然而我們還是想要說想表示更多的狀態,如當記憶體還夠時我們還不想刪除的那些物件。在JDK1.2之後。Java對引用的概念進行了擴充,將引用分為強引用、軟引用、弱引用、虛引用4種,引用強度依次減弱。
1.強引用就是指在程式程式碼之中普遍存在的,類似“Object obj = new Object()”這類的引用,只有強引用還存在,垃圾收集器永遠不會回收掉被引用的物件。
2.軟引用也是用來描述一些有用但並非必要的物件。對於軟引用關聯著的物件,在系統將要發生記憶體溢位異常之前,將會把這些物件列進回收範圍之中進行第二次回收。如果這次回收還沒有足夠的記憶體,才會丟擲記憶體溢位異常。
3.弱引用也是用來描述非必要物件的,但是它的強度比軟引用更弱一些,被弱引用關聯的物件只能生存到下一次垃圾收集發生之前。但垃圾收集工作時,無論當前記憶體是否足夠,都會回收掉只內弱引用關聯的物件。
4.虛引用也稱為幽靈引用或者幻影引用,它是最弱的一種引用關係。一個物件是否有虛引用的存在,完全不會對其生存時間構成影響,也無法通過虛引用來取得一個物件例項。為一個物件設定虛引用關聯的唯一目的就是能在這個物件被收集器回收時收到一個系統通知。在JDK1.2之後,提供了PhantomReference類來實現虛引用。

要不要馬上“死”?

儘管通過以上的判定方法可以找到不可達的物件,但是要真正宣告一個物件死亡,至少要經歷兩次標記過程:第一次標記是在可達性分析後發現沒有與GC Roots相連線的引用鏈並同時進行一次篩選,如果篩選出此物件沒有必要執行finalize方法(當前物件沒有覆蓋finalize方法或此方法已經被執行過了,注意:finalize方法在整個物件生命週期只會被呼叫一次而已)。沒有必要執行finalize方法的物件確認已經死亡。如果有必要執行finalize方法的物件則會將此物件放入一個叫做F-Queue的佇列之中,並稍後有一個虛擬機器自動建立的、低優先順序的Finalizer執行緒去執行它。這也是物件最後的自救時刻,它可在finalize方法把自己(this)賦值給某個類變數或者物件的成員變數來自救不在這次垃圾回收掉,這是第二次標記。如果第二次標記不成功,那就真的回收了。

方法區回收

上面說的方法都是在堆記憶體中進行記憶體管理的。方法區的記憶體,即我們常說的永久代(在Java8裡也已經沒有永久代這個概念了)。雖然在Java虛擬機器規範中並沒有要求在方法區也要實現垃圾收集。的確,在這裡收集記憶體的效率太低1了。在這部分記憶體要回收的主要是廢棄常量和無用的類。常量是不是廢棄的很好判斷,但類要同時滿足以下3個條件才能算是“無用的類”:
1.該類的所有例項都已經被回收,也就是Java堆裡面不存在該類的任何例項。
2.載入該類的ClassLoader已經被回收。
3.該類對應的java.lang.Class物件沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法。
在這裡,對類的回收不像物件一樣,沒用了就回收,類回不回收HotSpot虛擬機器提供了-Xnoclassgc引數進行控制。

什麼時候回收

我們之前說過,從記憶體回收的角度來看,由於現在的收集器基本都採用分代收集演算法,所以Java堆中可以被細分為:新生代和老年代;再細緻一點的有Eden空間、From Survivor空間、To Survivor空間等。 年輕代是所有新物件產生的地方。當年輕代記憶體空間被用完時,這時就會觸發垃圾回收。這個垃圾回收叫做Minor GC。
老年代記憶體裡包含了長期存活的物件和經過多次Minor GC後依然存活下來的物件。通常會在老年代記憶體被佔滿時進行垃圾回收。老年代的垃圾收集叫做Major GC。這裡注意,發生在老年代的垃圾收集也有人叫Full GC,這兩個術語目前還沒有正式的定義。但我更傾向於說永久代的垃圾收集是Full GC。

如何回收

垃圾收集演算法幫我們更有效率地回收記憶體

標記-清除演算法

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

複製演算法

為了解決效率問題,可以將記憶體按容量大小劃分為大小相等的兩塊,每次只使用其中的一塊。當這一塊的記憶體用完了,就將還存活著的物件複製到另外一塊上面,然後再把已經使用過的記憶體空間一次性清理掉,這就是“複製”演算法。在這裡還要說說新生代的記憶體空間比例,IBM的研究表明新生代中有98%的物件都是短命的,所以並不需要1:1來劃分記憶體空間,而是將記憶體分為較大的一塊Eden空間和兩塊較小的Survivor空間(HotSpot虛擬機器比例是8:1),每次使用Eden和其中一塊Survivor,留一塊Survivor用來複制兩個空間中還存活的物件,然後直接清空那兩個空間。還有就是我們還不能保證每次那個剩下的那個Survivor空間都有足夠的空間複製存活的物件,因此還需要依賴其他記憶體(這裡指老年代)進行分配擔保,如果空間不足,這些物件將通過分配擔保機制進入老年代。“複製”演算法的執行過程如圖所示
這裡寫圖片描述

標記-整理演算法

複製演算法在物件存活率較高時就要進行較多的複製操作,效率將會變低。特別在老年代,老年代一般不能直接選用這種演算法,因此有人提出了另外一種“標記-整理”演算法,標記過程仍然和“標記-清除”演算法一樣,但後續步驟不是直接對可回收物件進行整理,而是讓所有存活的物件向一端移動,然後直接清理掉邊界以外的記憶體。“標記-整理”演算法的執行過程如圖所示
這裡寫圖片描述

分代收集除演算法

當前商業虛擬機器的垃圾收集都採用“分代收集”演算法。一般將Java堆分成新生代和老年代,然後根絕各個年代的特點採用適當的手機演算法。在新生代採用“複製”演算法,在老年代採用“標記-清理”或“標記-整理”演算法。
致謝:以上的配圖都來自百度圖片,感謝原作者。