2、垃圾收集與內存分配策略(1)(JVM筆記)

一、概述

在之前介紹了Java內存運行時區域的各個部分,其中程序計數器、虛擬機棧、本地方法棧三個區域和線程的生命周期一致。棧中的棧幀隨著方法的將納入和退出有條不紊的執行著出棧和入棧操作。每一個棧幀中分配多少內存基本上是在類結構確定下來時就已知的(盡管在運行期會由JIT編譯器進行一些優化,但此處不考慮),因此這幾個區域的內存分配和回收都具備確定性,在這幾個區域內就不需要過多考慮回收的問題,因為方法結束或線程結束時,內存自然就跟著回收了。而Java堆和方法區則不一樣,一個接口中的多個實現類需要的內存可能不一樣,一個方法中的多個分支需要的內存也可能不一樣,只有在程序處于運行期間才能知道會創建哪些對象,這部分內存的分配和回收都是動態的,垃圾收集器所關注的是這部分內存。

二、對象已死嗎

在堆里存放著Java世界中幾乎所有的對象實例,垃圾收集器在對堆進行回收前,第一件事情就是要確定這些對象之中哪些還“存活”,哪些已經“死去”。

2.1 引用計數算法

引用計數算法就是給對象中添加一個引用計數器,每當有一個地方引用它時,計數器值就加一;當引用失效時,計數器就減一;任何時刻計數器為零的對象就是不可能再被使用的。這種算法在很多地方都用,但是至少主流的Java虛擬機里面沒有選用此算法來管理內存,其中最主要的原因是它很難解決對象之間相互循環引用的問題。

public class ReferenceCountingGC{

    public Object instance = null;
    private static final int _1MB = 1024 * 1024;
    
    private byte[] bigSize = new byte[2 * _1MB];
    
    public static void testGC(){
        ReferenceCountingGC objA = new ReferenceCountingGC();
        ReferenceCountingGC objB = new ReferenceCountingGC();
        objA.instance = objB;
        objB.instance = objA;
        
        objA = null;
        objB = null;
        
        //假設在這行發出GC, objA和objB是否能被回收?
        System.gc();
    }
}

說明:互相被引用,則GC是不會回收的(若使用引用計數算法)。

2.2 可達性分析算法

在主流的商用程序語言的主流實現中,都是稱通過可達性分析來判斷對象是否存活的。這個算法的基本思路就是通過一些列的稱為“GC Roots”的對象作為起點,從這個節點開始向下搜索,搜索所走過的路徑稱為引用鏈(Reference Chain),當一個對象到GC Roots沒有任何引用鏈相連時,則證明此對象是不可用的。如圖所示,對象object5、object6、object7雖然互相有關聯,但是它們到GC Roots是不可達的,所以將會被判定為是可回收的對象。

1

Java語言中,可作為GC Roots的對象包括下面幾種:

  • 虛擬機棧(棧幀中的本地變量表)中引用的對象
  • 方法區中類靜態屬性引用的對象
  • 方法區中常量引用的對象
  • 本地方法棧中JNI(即一般說的Native方法)引用的對象

2.3 再談引用

無論使用什么方法,判斷對象是否存活都與“引用”有關。在JDK 1.2 以前,Java中的引用的定義很傳統:如果reference類型的數據中存儲的數值代表的是另外一塊內存的起始地址,就稱這塊內存代表著一個引用。這種定義太過狹隘,我們希望能描述這樣一類對象:當內存空間還足夠時,則能保留在內存之中;如果內存空間在進行垃圾收集后還是非常緊張,則可以拋棄這些對象

JDK 1.2之后,Java對引用的概念進行了擴充,將引用分為強引用(Strong Reference)、軟引用(Soft Reference)、弱引用(Weak Reference)、虛引用(Phantom Reference)四種,引用強度依次逐漸減弱。

  • 強引用就是指在程序代碼中普遍存在的,類似“Object obj = new Object()”這類的引用,只要強引用還存在,垃圾收集器永遠不會回收掉被引用的對象。

  • 軟引用是用來描述一些還有用但并非必需的對象。對于軟引用關聯著的對象,在系統將要發生內存溢出異常之前,將會把這些對象列進回收范圍之中進行第二次回收。如果這次回收還沒有足夠的內存,才會拋出內存溢出異常。在JDK 1.2之后,提供了SoftReference類來實現軟引用。

  • 弱引用頁式用來描述非必需對象的,但是它的強度比軟引用更弱一些,被弱引用關聯的對象只能生存到下一次垃圾收集發生之前。當垃圾收集器工作時,無論當前內存是否足夠,都會回收掉只被弱引用關聯的對象。在JDK 1.2之后,提供了WeakReference類來實現軟引用。

  • 虛引用也稱為幽靈引用或幻影引用,是最弱的一種引用關系。一個對象是否有虛引用的存在,完全不會對其生存時間構成影響,也無法通過虛引用來取得一個對象實例。為一個對象設置虛引用關聯的唯一目的就是能在這個對象被收集器回收時收到一個系統通知。在JDK 1.2之后,提供了PhantomReference類來實現軟引用。

2.4 生存還是死亡

即使在可達性分析算法中不可達的對象,也并非是“非死不可”的,這時候它們暫時處于“緩刑”階段,要真正宣告一個對象死亡,至少要經歷兩次標記過程:

  • 如果對象在進行可達性分析后發現有不可達對象,那它將會被第一次標記并且進行一次篩選,篩選的條件是此對象是否有必要執行fanalize()方法。當對象沒有覆蓋finalize()方法,或者finalize()方法已經被虛擬機調用過,虛擬機將這兩種情況都視為“沒有必要執行”。

  • 如果這個對象被判定為有必要執行finalize()方法,那么這個對象將會放置在一個叫做F-Queue的隊列之中,并在稍后由一個虛擬機自動建立的、低優先級的Finalizer線程去執行。所謂的執行是指虛擬機會觸發這個方法,但是不保證會等待它運行結束。因為如果對象在finalize()中執行緩慢或發生死循環,將導致F-Queue隊列其他對象一直處于等待,甚至導致整個內存回收系統奔潰。finalize()方法是對象逃脫死亡命運的最后一次機會,稍后GC將對F-Queue中的對象進行第二次小規模的標記,如果對象要在finalize()中成功拯救自己——只要重新與引用鏈上的任何一個對象建立關聯即可,如把自己(this)賦給某個變量或對象的成員變量,那在第二次標記時它將被移除“即將回收”的集合;如果對象這時候還沒有逃脫,那基本上它就真的被回收了。

  • 一般不鼓勵使用finalize()方法來拯救對象,或者建議忘掉此方法。

2.5 回收方法區

  • 在方法區中進行垃圾收集的“性價比”一般較低:在堆中,尤其在新生代中,常規應用進行一次垃圾收集一般可以回收70%~95%的空間,而永久代的垃圾收集效率遠低于此。

  • 永久代的垃圾收集主要回收兩部分內容:廢棄常量和無用的類。回收廢棄常量與回收堆中的對象類似。在發生內存回收時,如果一個常量在任何地方都沒有被引用,則就會被回收,常量池中的其他類(接口)、方法、字段的符號引用也與此類似。

  • 判定一個常量是否是“廢棄常量”比較簡單,而要判定一個類是否是“無用的類”的條件在要苛刻許多。需要同時滿足是三個條件:

    • 該類所有的實例都已經被回收,也就是堆中不存在該類的任何實例
    • 加載該類的ClassLoader已經被回收
    • 該類對應的java.lang.Class對象沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法。
  • 虛擬機可以對滿足上述三個條件的無用類進行回收,但并不是和對象一樣,不使用了就必然會回收。是否對類進行回收,HotSpot虛擬機提供了-Xnoclassgc參數進行控制,還可以使用-verbose:class以及-XX:+TraceClassLoading、-XX:+TraceClassUnLoading查看類加載和卸載信息。

三、垃圾收集算法

3.1 標記-清除算法

算法分為“標記”和“清除”兩個階段:首先標記出所有需要回收的對象,在標記完成后統一回收所有被標記的對象,它的標記過程其實在前一節中已經說明。之所以說它是最基礎的收集算法,是因為后續的收集算法都是基于此算法思路并對其不足進行改進而得到的。

主要不足有兩個:一個效率問題,標記和清除兩個過程的效率都不高;另一個是空間問題,標記清除之后會產生大量不連續的內存碎片,空間碎片太多可能會導致以后在程序運行過程中需要分配較大對象時,無法找到足夠的連續內存而不得不提前觸發另一次垃圾回收動作。此算法的執行過程如下:


2

3.2 復制算法

為了解決效率問題,一種稱為“復制”的收集算法出現了,它將可用內存按容量劃分為大小相等的兩塊,每次只使用其中一塊。當這一塊內存用完了,就將還存活著的對象復制到另一塊上面,然后再把已使用過的內存空間一次性清理掉。這樣使得每次都是對整個半區進行內存回收,內存分配時也就不用考慮內存碎片等復雜情況,只要移動堆頂指針,按順序分配內存即可,實現簡單,運行高效。只是這種算法的代價是將內存縮小為原來的一半,未免太高了,執行過程如下:


3

現在的商業虛擬機都采用這種收集算法來回收新生代,IBM公司研究標明,新生代中的對象98%都是“朝生夕死”的,所以并不需要按照1:1的比例來劃分內存空間,而是將內存分為一塊較大的Eden空間和兩塊較小的Survivor空間,每次使用Eden和其中一塊Servivor。當回收時,將EdenSurvivor中還存活的對象一次性復制到另一塊Survivor空間上,最后清理掉Eden和剛才用過的Survivor空間。HotSpot虛擬機默認EdenSurvivor的大小比例是8:1,也就是每次新生代中可用內存空間為整個新生代容量的90%,只有10%會被浪費。當然這不是絕對的,沒有辦法保證每次回收都只有不多于10%的對象存活,當Survivor空間不夠用時,需要依賴其他內存(這里指老年代)進行分配擔保。

也就是說,如果另一塊Survivor空間沒有足夠空間存放上一次新生代收集下來的存活對象,這些對象將直接通過分配擔保機制進入老年代。

3.3 標記—整理算法

復制收集算法在對象存活率較高時就要進行較多的復制操作,效率將會變低。而且,如果不想浪費50%的空間,就需要有額外的空間進行分配擔保,以應對被使用的內存中所有對象讀100%存活的極端情況,所以在老年代一般不能直接選用這種算法。

根據老年代的特點,有人提出了另一種“標記-整理”算法,標記過程仍然與“標記-清除”算法一樣,但后續步驟不是直接對可回收對象進行清理,而是讓所有存活的對象都向一端移動,然后直接清理掉端邊界以外的內存。如圖所示。


4

3.4 分代收集算法

當前商業虛擬機的垃圾收集都采用“分代收集”算法。這種算法并沒有什么新的思想,只是根據對象存活周期的不同將內存劃分為幾塊。一般是把Java堆分為新生代和老年代,這樣就可以根據各個年代的特點采用最適當的收集算法。在新生代中,每次垃圾收集時都發現有大批對象死去,那就選用復制算法。而老年代中因為對象存活率高、沒有額外空間對它們進行分配擔保,就必須使用“標記-清理”或者“標記-整理”算法來進行回收。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容