1. 程式人生 > 其它 >Java的垃圾回收機制及演算法

Java的垃圾回收機制及演算法

寫在前面:

該系列文章,主要是為了深入學習Java完成的一條鏈,推薦閱讀的整體順序為:Java的記憶體模型(根源),一個java檔案被執行的歷程一個Java類的載入Java的垃圾回收機制及演算法Linux(六):系統運維常用命令   Java程式執行狀態的監控(實用,定位Java程式問題)

Java的垃圾回收機制

前面的已經說過關於 Java在記憶體中的記憶體劃分(Java的記憶體模型),一個java檔案執行的流程、一個Java類如何被載入,本著有始有終的原則,下面該說一下 如何 “ 死亡 ”。

Java的編譯流程已經提過了,其實更多的就是類+物件+記憶體,就是將程式設計師抽象的類檔案,載入到記憶體區,形成一個個的物件,物件可以極端的理解為就是記憶體中的一部分,記憶體劃分好塊之後,物件都在堆中建立,其中一個物件,便是堆區中的一塊記憶體。

隨著程式的執行,各種套娃的邏輯,new的使用,資料的轉存等等,都會讓堆內的物件遞增,這就涉及一個問題,堆內的記憶體是有限的,物件如何只是不停的增加,早晚會爆炸,也就是常說的堆疊溢位;最經典就是C++,習慣寫C++的小夥伴肯定知道,每次建立物件,都要想著析構,因為C++是不會自己清除無效物件的,它的記憶體空間就會不停的增加,只有研發人員自發的去釋放掉,這樣就要求寫C++的研發要時刻牢記記憶體的釋放。

但是Java就不需要,我們只需要無腦的new new new ,套娃套娃套娃,原因就是Java有一套自動的物件銷燬機制,也叫垃圾回收機制,這也是學習JVM,以及各種面試都會問到的點。

通常要聊Java的垃圾回收機制,需要搞清除三個問題:

  1. 哪些記憶體會被回收清理
  2. 怎樣回收清理
  3. 什麼時候會被回收清理

哪些記憶體(物件)會被回收清理

之前說過,在java中,萬物皆物件,而物件存在於堆記憶體中,要說哪些物件會被清除,其實可以理解為,哪些物件已經“死了”。也就是說哪些物件已經不再被程式需要,不再被呼叫,這裡判斷物件是否需要被回收,一般是兩種演算法:引用計數器演算法和可達性演算法

引用計數器法

原理其實很簡單,給執行的物件新增一個引用計數器,每當有一個地方引用它時,計數器+1;當引用失效時,計數器就-1,任何時刻計數器為0的物件,就視作不可能再被使用。這一種方式,實現簡單,邏輯也清晰,大部分的情況下,它都可以達到很好的效果,儘管這樣,計數器演算法還是存在但是的,但是它無法解決迴圈引用的場景,這也是主流Java虛擬機器沒有選用這一演算法的原因。

可達性分析法

此演算法的核心思想:通過一系列稱為“GC Roots”的物件作為起始點,從這些節點開始向下搜尋,搜尋走過的路徑稱為“引用鏈”,當一個物件到 GC Roots 沒有任何的引用鏈相連時(從 GC Roots 到這個物件不可達)時,證明此物件不可用。

想要了解什麼是可達性物件GC Roots,需要先了解一下java的記憶體模型,java的記憶體模型

 

總體來說一般存在於:虛擬機器棧、java方法區、本地方法區的物件都是可達的,也就是GCRoots物件

1、方法區靜態屬性引用的物件 全域性物件的一種,Class物件本身很難被回收,回收的條件非常苛刻,只要Class物件不被回收,靜態成員就不能被回收。

2、方法區常量池引用的物件 也屬於全域性物件,例如字串常量池,常量本身初始化後不會再改變,因此作為GC Roots也是合理的。

3、方法棧中棧幀本地變量表引用的物件 屬於執行上下文中的物件,執行緒在執行方法時,會將方法打包成一個棧幀入棧執行,方法裡用到的區域性變數會存放到棧幀的本地變量表中。只要方法還在執行,還沒出棧,就意味這本地變量表的物件還會被訪問,GC就不應該回收,所以這一類物件也可作為GC Roots。

4、JNI本地方法棧中引用的物件 和上一條本質相同,無非是一個是Java方法棧中的變數引用,一個是native方法(C、C++)方法棧中的變數引用。

5、被同步鎖持有的物件 被synchronized鎖住的物件也是絕對不能回收的,當前有執行緒持有物件鎖呢,GC如果回收了物件,鎖不就失效了嘛。

可達性分析就是JVM首先列舉根節點,找到一些為了保證程式能正常執行所必須要存活的物件,然後以這些物件為根,根據引用關係開始向下搜尋,存在直接或間接引用鏈的物件就存活,不存在引用鏈的物件就回收。注:注意的是被判定為不可達的物件不一定就會成為可回收物件。被判定為不可達的物件要成為可回收物件必須至少經歷兩次標記過程,如果在這兩次標記過程中仍然沒有逃脫成為可回收物件的可能性,則基本上就真的成為可回收物件了。

怎樣回收清理

使用發現演算法垃圾被標記後,接下來就是如何去清除,常見的清除演算法有:標記清除法、複製清除、標記整理演算法、分代收集;其中當前主流使用的是分代收集

標記清除演算法(Mark Sweep)

該演算法很簡單,使用通過可達性分析分析方法標記出垃圾,然後直接回收掉垃圾區域。簡單粗暴,即標記刪除的物件,對其進行記憶體回收;它的一個顯著問題是一段時間後,記憶體會出現大量碎片,導致雖然碎片總和很大,但無法滿足一個大物件的記憶體申請,從而導致 OOM,而過多的記憶體碎片(需要類似連結串列的資料結構維護),也會導致標記和清除的操作成本高,效率低下。

 

標記整理演算法(Generation Collection)

標記整理法,知識在標記清除的基礎上,追加了碎片的散落問題,在清除之後進行了碎片的整理,但副作用是增了了GC的時間。

複製演算法(Copying)

為了解決標記清除演算法的效率問題,有人提出了複製演算法。它將可用記憶體一分為二,每次只用一塊,當這一塊記憶體不夠用時,便觸發 GC,將當前存活物件複製(Copy)到另一塊上,以此往復。這種演算法高效的原因在於分配記憶體時只需要將指標後移,不需要維護連結串列等。但它最大的問題是對記憶體的浪費,使用率只有 50%。

但這種演算法在一種情況下會很高效:Java 物件的存活時間極短。據 IBM 研究,Java 物件高達 98% 是朝生夕死的,這也意味著每次 GC 可以回收大部分的記憶體,需要複製的資料量也很小,這樣它的執行效率就會很高。

 

分代收集(Generation Collection)

在實際的Java程式中,大部分的物件存活週期都較短,基本上建立完,緊接著處理完資料就被丟棄了,大部分 Java 物件是朝生夕死的,所以我們將記憶體按照 Java 生存時間分為 新生代(Young)老年代(Old),前者存放短命僧,後者存放長壽佛,當然長壽佛也是由短命僧升級上來的。然後針對兩者可以採用不同的回收演算法,比如對於新生代採用複製演算法會比較高效,而對老年代可以採用標記-清除或者標記-整理演算法。這種演算法也是最常用的。

JVM Heap 分代後的劃分一般如下所示,新生代一般會分為 Eden、Survivor0、Survivor1區,便於使用複製演算法。

將記憶體分代後的 GC 過程一般類似下圖所示:

    1. 物件一般都是先在 Eden區建立

    2. Eden區滿,觸發 Young GC,此時將 Eden中還存活的物件複製到 S0中,並清空 Eden區後繼續為新的物件分配記憶體

    3. Eden區再次滿後,觸發又一次的 Young GC,此時會將 EdenS0中存活的物件複製到 S1中,然後清空EdenS0後繼續為新的物件分配記憶體

    4. 每經過一次 Young GC,存活下來的物件都會將自己存活次數加1,當達到一定次數後,會隨著一次 Young GC 晉升到 Old

    5. Old區也會在合適的時機進行自己的 GC

垃圾收集器執行機制

有了垃圾回收演算法,那麼其具體的實現就是垃圾收集器,也是我們實際使用中會具體用到的。現代的垃圾收集機制基本都是分代收集演算法;新生代和老生代也有不同的回收器。

YoungOld區有不同的垃圾收集器,實際使用時會搭配使用,也就是上圖中兩兩連線的收集器是可以搭配使用的。這些垃圾收集器按照執行原理大概可以分為如下幾類:

  • Serial GC,序列,單執行緒的收集器,執行 GC 時需要停止所有的使用者執行緒,且只有一個 GC 執行緒

  • Parallel GC,並行,多執行緒的收集器,是 Serial 的多執行緒版,執行時也需要停止所有使用者執行緒,但同時執行多個 GC 執行緒,所以效率高一些

  • Concurrent GC,併發,多執行緒收集器,GC 分多階段執行,部分階段允許使用者執行緒與 GC 執行緒同時執行,這也就是併發的意思。

什麼時候會被回收清理

  1. Young 區的GC 都是在 Eden 區滿時觸發

  2. Serial Old 和 Parallel Old 在 Old 區是在 Young GC 時預測Old 區是否可以為 young 區 promote 到 old 區 的 object 分配空間,如果不可用則觸發 Old GC。這個也可以理解為是 Old區滿時。

  3. CMS GC 是在 Old 區大小超過一定比例後觸發,而不是 Old 區滿。這個原因在於 CMS GC 是併發的演算法,也就是說在 GC 執行緒收集垃圾的時候,使用者執行緒也在執行,因此需要預留一些 Heap 空間給使用者執行緒使用,防止由於無法分配空間而導致 Full GC 發生。