Java虛擬機器詳解(三)------垃圾回收

YSOcean發表於2019-07-15

  如果對C++這門語言熟悉的人,再來看Java,就會發現這兩者對垃圾(記憶體)回收的策略有很大的不同。

  C++:垃圾回收很重要,我們必須要自己來回收!!!

  Java:垃圾回收很重要,我們必須交給系統來幫我們完成!!!

  我想這也能看出這兩門語言設計者的心態吧,總之,Java和C++之間有一堵由記憶體動態分佈和垃圾回收技術所圍成的高牆,牆外面的人想進去,牆裡面的人想出來。

  本篇部落格我們就來詳細介紹Java的垃圾回收策略。

1、為什麼要進行垃圾回收

  我們知道Java是一門物件導向的語言,在一個系統執行中,會伴隨著很多物件的建立,而這些物件一旦建立了就佔據了一定的記憶體,在上一篇部落格Java執行時記憶體結構中,我們介紹過建立的物件是儲存在堆中的,當物件使用完畢之後,不對其進行清理,那麼會一直佔據記憶體空間,很明顯記憶體空間是有限的,如果不回收這些無用的物件佔據的記憶體,那麼新建立的物件申請不了記憶體空間,系統就會丟擲異常而無法執行,所以必須要經常進行記憶體的回收,也就是垃圾收集。

2、為什麼要了解垃圾回收

  文章開頭,我們就說Java的垃圾回收是系統自動進行的,不需要我們程式設計師手動處理,那麼我們為什麼還要了解垃圾回收呢,?

  其實這也是一個程式設計師進階的過程,生產專案在執行過程中,很可能會存在記憶體溢位、記憶體洩露等問題,出現了這些問題,我們應該怎麼排查?以及在生產伺服器有限的資源上如何更好的分配Java執行時記憶體區域,提高系統執行效率等,我們必須知其然知其所以然。

  PS:本篇部落格只是介紹Java垃圾回收機制,關於排查記憶體洩漏、溢位,執行時記憶體區域引數調優等會在後面進行介紹。

3、回收哪部分割槽域記憶體

  還是結合上一篇部落格Java執行時記憶體結構,我們介紹了Java執行時的記憶體結構,其中程式計數器、虛擬機器棧、本地方法棧這三個區域是執行緒私有的,隨執行緒而生,隨執行緒而滅,棧中的棧幀隨著方法的進入和退出而有條不紊的執行著入棧和出棧操作,這幾個區域的記憶體分配和回收都具備確定性,在方法結束或執行緒結束時,記憶體也就跟著回收了,所以不需要我們考慮。

  那麼現在就剩下Java堆方法區了,這兩塊區域在編譯期間我們並不能完全確定建立多少個物件,有些是在執行時期建立的物件,所以Java記憶體回收機制主要是作用在這兩塊區域。

4、如何判斷物件為垃圾物件

  通過上面介紹了,我們瞭解了為什麼要進行垃圾回收以及回收哪部分的垃圾,那麼接下來我們怎麼去區分哪些物件為垃圾呢?

  換句話來說,我們如何判斷哪些物件還“活著”,哪些物件已經“死了”,那些“死了”的物件佔據的記憶體就是我們要進行回收的。

①、引用計數演算法

  這種演算法是這樣的:給每一個建立的物件增加一個引用計數器,每當有一個地方引用它時,這個計數器就加1;而當引用失效時,這個計數器就減1。當這個引用計數器值為0時,也就是說這個物件沒有任何地方在使用它了,那麼這就是一個無效的物件,便可以進行垃圾回收了。

  這種演算法實現簡單,而且效率也很高。但是Java沒有采用該演算法來進行垃圾回收,因為這種演算法無法解決物件之間的迴圈引用問題。

  下面我們就來構造一個迴圈引用的例子:

  首先,有一個 Person 類,這個類有兩個自引用屬性,分別表示其父親,兒子。

 1 package com.ys.algorithmproject.leetcode.demo.JVM;
 2 
 3 /**
 4  * Create by YSOcean
 5  */
 6 public class Person {
 7 
 8     private Byte[] _1MB = null;
 9 
10     public Person() {
11         /**
12          * 這個成員屬性的作用純粹就是佔據一定記憶體,以便在日誌中檢視是否被回收
13          */
14         _1MB = new Byte[1*1024*1024];
15     }
16 
17 
18 
19     private Person father;
20     private Person son;
21 
22     public Person getFather() {
23         return father;
24     }
25 
26     public void setFather(Person father) {
27         this.father = father;
28     }
29 
30     public Person getSon() {
31         return son;
32     }
33 
34     public void setSon(Person son) {
35         this.son = son;
36     }
37 }
View Code

  接著,我們通過Person類構造兩個物件,分別是父親,兒子,如下:

 1 public static void main(String[] args) {
 2 
 3     Person father = new Person();
 4     Person son = new Person();
 5     father.setSon(son);
 6     father.setFather(father);
 7 
 8     father = null;
 9     son = null;
10     
11     /**
12      * 呼叫此方法表示希望進行一次垃圾回收。但是它不能保證垃圾回收一定會進行,
13      * 而且具體什麼時候進行是取決於具體的虛擬機器的,不同的虛擬機器有不同的對策。
14      */
15     System.gc();
16 }

  首先,從第3-6行程式碼,其執行時記憶體結構圖如下:

   

  father物件和son物件,其引用計數第一個是棧記憶體指向,第二個就是其屬性互相引用對方,所有引用計數器都是2。

  接著我們看第8,9行程式碼,分別將這兩個物件置為null,也就是去掉了棧記憶體指向。

  

  這時候其實這兩個物件只是自己互相引用了,沒有別的地方在引用它們,引用計數器為1,那麼這兩個物件按照引用計數演算法實現的虛擬機器就不會回收,可想而知,這是我們不能接受的。

  所以Java虛擬機器都沒有使用該演算法來判斷物件是否存活,我們可以通過增加列印虛擬機器引數來驗證。

  我們將上面的man函式,增加如下Java虛擬機器引數,用來列印gc資訊。

-verbose:gc

  在IDEA編輯器中,新增方式如下:

  

   執行結果如下:

  

  我們看到12201K->1088K(125952K)的輸出,表示垃圾收集GC前有12201K,回收後剩下1088K,堆的總量為125952K,回收的記憶體為12201K-1088K = 11113K。

  換句話說,上面的例子Java虛擬機器是有進行垃圾回收的,所以,這也間接佐證了Java虛擬機器並不是採用的引用計數法來判斷物件是否是垃圾。

  PS:這些引數資訊詳解也會在後面部落格進行詳細介紹。

②、根搜尋演算法

  我們這裡直接給出結論:在主流的商用程式中(Java,C#),都是使用根搜尋演算法(GC Roots Tracing)來判定物件是否存活。 

  該演算法思路:通過一系列名為“GC Roots” 的物件作為終點,當一個物件到GC Roots 之間無法通過引用到達時,那麼該物件便可以進行回收了。

  

  上圖Object1,Object2,Object3,Object4到GC Roots是可達的,所以不會被作為垃圾回收。

  

  上圖Object1,Object2,Object3這三個物件互相引用,但是到 GC Roots不可達,所以都會被垃圾回收掉。

  那麼有哪些物件可以作為 GC Roots 呢?

  在Java語言中,有如下4中物件可以作為 GC Roots:

1 1、虛擬機器棧(棧幀中的本地變數表)中引用的物件
2 2、方法區中的靜態變數屬性引用的物件
3 3、方法區中常量引用的物件
4 4、本地方法棧中(JNI)(即一般說的Native方法)的引用的物件

5、如何進行垃圾回收

  垃圾回收涉及到大量的程式細節,而且各個平臺的虛擬機器操作記憶體的方式也不一樣,但是他們進行垃圾回收的演算法是通用的,所以這裡我們也只介紹幾種通用演算法。

①、標記-清除演算法

  演算法實現:分為標記-清除兩個階段,首先根據上面的根搜尋演算法標記出所有需要回收的物件,在標記完成後,然後在統一回收掉所有被標記的物件。

  缺點

  1、效率低:標記和清除這兩個過程的效率都不高。

  2、容易產生記憶體碎片:因為記憶體的申請通常不是連續的,那麼清除一些物件後,那麼就會產生大量不連續的記憶體碎片,而碎片太多時,當有個大物件需要分配記憶體時,便會造成沒有足夠的連續記憶體分配而提前觸發垃圾回收,甚至直接丟擲OutOfMemoryExecption。

  

②、複製演算法

  為了解決標記-清除演算法的兩個缺點,複製演算法誕生了。

  演算法實現:將可用記憶體按容量劃分為大小相等的兩塊區域,每次只使用其中一塊,當這一塊的記憶體用完了,就將還活著的物件複製到另一塊區域上,然後再把已使用過的記憶體空間一次性清理掉。

  優點:每次都是隻對其中一塊記憶體進行回收,不用考慮記憶體碎片的問題,而且分配記憶體時,只需要移動堆頂指標,按順序進行分配即可,簡單高效。

  缺點:將記憶體分為兩塊,但是每次只能使用一塊,也就是說,機器的一半記憶體是閒置的,這資源浪費有點嚴重。並且如果物件存活率較高,每次都需要複製大量的物件,效率也會變得很低。

  

③、標記-整理演算法

  上面我們說過複製演算法會浪費一半的記憶體,並且物件存活率較高時,會有過多的複製操作,效率低下。

  如果物件存活率很高,基本上不會進行垃圾回收時,標記-整理演算法誕生了。

  演算法實現:首先標記出所有存活的物件,然後讓所有存活物件向一端進行移動,最後直接清理到端邊界以外的記憶體。

  侷限性:只有物件存活率很高的情況下,使用該演算法才會效率較高。

  

④、分代收集演算法

   當前商業虛擬機器都是採用此演算法,但是其實這不是什麼新的演算法,而是上面幾種演算法的合集。

  演算法實現:根據物件的存活週期不同將記憶體分為幾塊,然後不同的區域採用不同的回收演算法。

    1、對於存活週期較短,每次都有大批物件死亡,只有少量存活的區域,採用複製演算法,因為只需要付出少量存活物件的複製成本即可完成收集;

    2、對於存活週期較長,沒有額外空間進行分配擔保的區域,採用標記-整理演算法,或者標記-清除演算法。

  比如,對於 HotSpot 虛擬機器,它將堆空間分為如下兩塊區域:

  

  堆有新生代和老年代兩塊區域組成,而新生代區域又分為三個部分,分別是 Eden,From Surivor,To Survivor ,比例是8:1:1。

  新生代採用複製演算法,每次使用一塊Eden區和一塊Survivor區,當進行垃圾回收時,將Eden和一塊Survivor區域的所有存活物件複製到另一塊Survivor區域,然後清理到剛存放物件的區域,依次迴圈。

  老年代採用標記-清除或者標記-整理演算法,根據使用的垃圾回收器來進行判斷。

  至於為什麼要這樣,這是由於記憶體分配的機制導致的,新生代存的基本上都是朝生夕死的物件,而老年代存放的都是存活率很高的物件。關於記憶體分配下篇部落格我們會詳細進行介紹。

6、何時進行垃圾回收

  理清了什麼是垃圾,怎麼回收垃圾,最後一點就是Java虛擬機器何時進行垃圾回收呢?

  程式設計師可以呼叫 System.gc()方法,手動回收,但是呼叫此方法表示希望進行一次垃圾回收。但是它不能保證垃圾回收一定會進行,而且具體什麼時候進行是取決於具體的虛擬機器的,不同的虛擬機器有不同的對策。

  其次虛擬機器會自行根據當前記憶體大小,判斷何時進行垃圾回收,比如前面所說的,新生代滿了,新產生的物件無法分配記憶體時,便會觸發垃圾回收機制。

  這裡需要說明的是宣告一個物件死亡,至少要經歷兩次標記,前面我們說過,如果物件與GC Roots 不可達,那麼此物件會被第一次標記並進行一次篩選,篩選的條件是此物件是否有必要執行 finalize() 方法,當物件沒有覆蓋 finalize()方法,或者該方法已經執行了一次,那麼虛擬機器都將視為沒有必要執行finalize()方法。

  如果這個物件有必要執行 finalize() 方法,那麼該物件將會被放置在一個有虛擬機器自動建立、低優先順序,名為 F-Queue 佇列中,GC會對F-Queue進行第二次標記,如果物件在finalize() 方法中成功拯救了自己(比如重新與GC Roots建立連線),那麼第二次標記時,就會將該物件移除即將回收的集合,否則就會被回收。

相關文章