《深入理解Java虛擬機器》第三章讀書筆記(一)——垃圾回收演算法

Cuzzz發表於2023-01-29
參考書籍《深入理解java虛擬機器》周志明著

系列文章目錄和關於我

本文主要介紹垃圾回收理論知識

image-20230129211033301

1.jvm哪些區域需要進行垃圾回收

image-20230125144811003

  • 虛擬機器棧,本地方法棧,程式計數器都是執行緒私有的,隨執行緒而生,隨執行緒而滅。其中棧中的棧幀隨著方法的進入和退出而有條不紊的執行出棧和入棧操作,每一個棧幀需要分配記憶體基本上在類結構確定下來的時候就已知了,因此這幾個區域的記憶體分配時具備確定性的,這個幾個區域不需要考慮如何回收,當方法或者執行緒結束的時候,這些區域自然隨之被回收。

  • 堆是儲存物件的為止,執行緒共享。程式設計師在方法中new一個物件,通常在這個棧幀中的本地變數存在一個reference型別指向堆中一個物件例項資料。這部分是垃圾回收的重災,畢竟java中萬物皆物件

    image-20230129212422313

  • 方法區

    在方法區中,儲存了每個類的資訊(包括類的名稱、方法資訊、欄位資訊)、靜態變數、常量以及編譯器編譯後的程式碼等。java可以使用動態代理生成類,可以在執行的時候載入類到方法區。

    java 8 使用元空間實現方法區,我們可以使用MaxMetaspaceSize這個虛擬機器引數現在元空間大小,對於不會再使用的類,和類載入器以及廢棄的常量可以進行回收。

1.1 回收堆

這部分下面會進行著重介紹,首先我們要判斷一個物件是否為垃圾,然後使用特定演算法進行垃圾收集

1.2 回收方法區

  • 回收常量

    如果一個常量曾經進入到方法區,但是後續再也沒用被引用到,那麼垃圾收集器覺得合適的時候將進行清理

  • 回收類

    回收類的條件十分苛刻,首先必須保證該類的例項物件都以及被回收,其次保證載入類的類載入器也已經被回收該類對應的Class物件沒有在任何位置被引用,無法在任何位置透過反射訪問該類的方法

    正因如此,回收方法區價效比很低,以下是一些控制方法區垃圾回收的虛擬機器引數

    引數 含義
    -Xnoclassgc 是否對型別進行回收
    -XX:TraceClassLoading 列印類載入資訊
    -XX:TraceClassUnLoading 列印類解除安裝資訊
    -verbose:class 列印類載入和解除安裝資訊

    大量使用反射,動態代理,CGLib位元組碼框架,動態生成jsp等技術,通常需要java虛擬機器具備型別解除安裝的能力。避免方法區的溢位(jdk7之前,可以使用-XX:MaxPermSize設定方法區大小,-XX:PermSize設定方法區初始大小,方法區溢位通常日誌資訊為java.lang.OutOfMemoryError:PermGen pace。jdk7之後方法區使用基於本地記憶體的元空間實現可以使用-XX:MaxMetaspaceSize設定元空間最大值,-XX:MetaSpaceSize指定元空間初始大小,-XX:MinMetaSpaceFreeRatio設定在垃圾收集後控制元空間最小剩餘百分比,-XX:MaxMetaSpaceFreeRatio 設定在垃圾收集後控制元空間最大小剩餘百分比)

2.什麼樣的物件是垃圾

不會被使用到的物件就是垃圾,那麼使用什麼方法定位到不會被使用到的物件呢?

2.1.引用計數器法

在物件中新增一個引用計數器,每當一個物件引用了它,計數器的值就加1,當引用失效時就減1,任何時候引用計數器為0的物件就是不可以再使用的。

引用計數器法佔用少許額外記憶體,但是原理簡單,判定效率也很高,大多數情況下都是一個不錯的選擇,但是存在迴圈引用的問題,物件A引用了物件B,物件B引用了物件A,二者不再被其他物件引用,這時候二者計數器都為1,但是已然是垃圾

A a = new A();
B b = new B()
a.f1 = b;
b.f2 = a;

a = null;
b = null;

//這時候發生GC 物件A 和 物件B互相引用彼此,但是可以被回收

Java虛擬機器並非使用 引用計數器法 來判斷物件是否存活。

2.2.可達性分析演算法

透過一些類稱為GC Roots的根物件為起始節點集合,從這些節點起,根據引用關係向下搜尋,搜尋過程所走過的路徑稱為"引用鏈",如果某個物件到GC Roots沒有任何引用鏈相連,即GC Roots到這個物件不可達,那麼證明此物件不能再被使用。

image-20230129215040268

可以作為GC Roots的物件有以下這些:

  • 虛擬機器棧(棧中的本地變數表)中引用的物件,譬如各個執行緒被呼叫的方法中使用的引數,區域性變數,臨時變數
  • 方法區中的類的靜態屬性引用的物件,譬如java類的引用型別靜態變數
  • 方法區中的常量引用物件,比如字串常量池裡的引用
  • 本地方法棧中JNI(native 方法)引用的物件
  • java虛擬機器中內部引用,如基本資料型別對應的class物件,常駐異常物件(NullPointerException,OutOfMemorryError)以及系統類載入器
  • 所有被synchronized 持有的物件

2.3 強引用,軟引用,弱引用,虛引用

  • 強引用

    指程式碼間的引用賦值,Object a = new Object(),這種關係,只要存在強引用關係,那麼垃圾回收器就無法回收被引用的物件

  • 軟引用

    使用SoftReference類實現的引用關係,只被軟引用關聯的物件在,在系統將發生記憶體溢位異常前,會將這些物件列入垃圾回收的範圍進行第二次回收,如果回收後還沒有足夠的記憶體,才會丟擲記憶體溢位異常。下面這個程式碼不斷向集合中加入物件,但是不會發生OOM。另外配合ReferenceQueue可以實現基於軟引用的快取,在mybatis中的SoftCache存在類似的使用

    public static void main(String[] args) {
        ArrayList<Object> objects = new ArrayList<>();
    
         while (true) {
                 objects.add(new SoftReference<>(new byte[1024*1024],q));
            }
    }
    
  • 弱引用

    用來描述非必須的物件,使用WeakReference實現這種關係,它的強度比軟引用更弱一些,關聯的物件只能生存到下一次垃圾收集為止,當垃圾收集器開始工作,無論記憶體是否足夠,都會回收只被弱引用關聯的物件。

    同樣配合ReferenceQueue 可以實現弱引用快取,在WeakHashMap,mybatis中的WeakCache,guava的快取中均有類似使用

  • 虛引用

    使用PhantomReference實現這種引用關係,是最弱的一種關係,虛引用的存在絲毫不影響物件的回收,也無法根據虛引用來獲取一個物件例項,為物件設定虛引用的唯一目的就是能在物件被回收的時候收到一個系統通知(搭配ReferenceQueue實現)

2.4.finalize方法

一個物件確保需要回收,需要進行兩次篩選

  1. 第一次篩選:可達性分析發現物件和GC Roots不具備聯絡
  2. 第二次篩選:待物件執行完finalize方法,後對F-Queue中物件進行第二次篩選,依舊和GC Roots無聯絡的物件將被回收

經歷第一次篩選後,如果物件沒有必要執行finalize方法(物件沒有覆蓋finalize方法,或者該物件的finalize方法已經被虛擬機器呼叫過)那麼不會將物件放置在F-Queue。被放置到F-Queue佇列中的物件,稍後有虛擬機器自動建立的,低優先順序的Finalizer執行緒去執行finalize方法,但是並不一定保證等待執行完成(因為不能由於物件本身finalize方法執行時間長,而導致垃圾不能及時回收造成oom),稍後收集器將對F-Queue中的物件進行第二次小規模標記,如果任然沒有和GC Roots建立聯絡,那麼將被回收。(finalize方法只會被呼叫一次)

3.如何回收

3.1分代收集理論

大部分垃圾收集器都是使用分代收集的策略實現堆的垃圾回收。

  • 為什麼需要分代

    分代存在兩個重要的假說促使了,垃圾收集器進行分代收集。

    1. 弱分代假說

      大部分物件都是朝生夕滅的

      像我們再方法中區域性變數引用的物件,方法結束就會死去,因為再也沒用任何引用可以指向它了。如:

      void m(){
          Object o = new Object();
      }
      //這裡的o m方法結束,new出的object物件將不會被任何引用指向
      //這種物件在java這種是佔絕大多數的
      
    2. 強分代假說

      熬過越多垃圾收集的物件越是難以消亡

      可以熬過多次垃圾收集,說明和GC Roots關聯非常緊密,後續肯定更加難被收集,類似我們定義的 static final 屬性指向的物件

    在兩個假說促使收集器將堆劃分出不同的區域,然後根據物件的年齡(年齡指熬過gc的次數)分配到不同的區域儲存。如是便有了新生代和老年代

    image-20230129220455668

    新生代物件和老年代存亡特徵存在差異,促使這兩部分使用不同的演算法進行收集(標記清除,標記整理,標記複製,見後續講解)

    • 分代收集解決跨代引用的問題

    分代收集也具備缺點——跨代引用,老年代和新生代物件存在關聯。這時候加入只想進行 新生代的gc難道需要掃描所有老年代的物件,確保新生代物件是否存在跨代引用麼?(老年代物件全部遍歷是一個非常耗時的操作)。

    為了解決這個問題,提出了第三個假說:跨代引用假說:存在跨代引用的物件相對於同代引用的物件是很少的。因為如果存在跨代引用的兩個物件往往是趨向一起消亡的,比如老年代引用了新生代,由於老年代物件不容易被回收,促使這個新生代物件也不易被回收,進而新生代物件熬過多次gc最終進入老年代,消除了跨代引用。

    根據這個假說,指導了我們不應該為了少量跨代引用物件去掃描所有老年代物件,只需要在新生代建立一個全域性的資料結構:記憶集將老年代分為多個小塊,標識哪一塊的老年代記憶體存在跨代引用,當發生新生代gc的時候,將包含跨代引用的小塊記憶體中的物件加入到GC Roots中進行掃描。使用少量空間減少了掃描整個老年代的開銷。

    • Minor GC,Major GC,Full GC

      • Minor GC

        新生代收集,針對新生代(又稱young gc)

      • Major GC

        老年代GC,針對老年代(又稱old gc)

      • Full GC

        回收整個堆和方法區的垃圾收集

3.2 標記清除

演算法分為標記和清除兩個步驟,十分好理解,首先標記所有需要回收的物件,在標記完成後,統一回收掉被標記的物件。也可以反過來,標記不需要回收的物件,然後回收沒被標記的物件。

標記清除的缺點

  • 效率不穩定

    需要被標記的物件越多,標記和回收過程越耗時。

  • 容易造成記憶體碎片

    標記清除後容易產生大量記憶體碎片,導致後續執行的時候無法為大物件分配連續記憶體,而被迫再次進行GC。

3.3 標記複製

將可用記憶體分為兩部分,每次只使用其中一部分,當這一部分A被使用完後,標記出存活的物件複製到另外一塊B上面,然後將A全部清理,後續繼續使用B,B使用完後標記B然後將可用物件複製到A上,迴圈往復。由於需要複製的物件是存活的物件,這部分物件根據"弱分代假說"是較少的部分,每次都是對一半的區域進行回收,所以可避免記憶體碎片的產生。但是缺點也很明顯:"過於浪費空間,每次只能使用一半"

java新生代並沒有採用上面的演算法,而是使用改進後的演算法:即將新生代分為 一個Eden兩個Survivor(from區和to區),三者的比值是8:1:1,發生新生代垃圾收集的時候,將EdenSurvivor from中仍然存活的物件,一次性複製到Survivor to,然後清理掉Eden 和 Survivor from,然後下次使用EdenSurvivor to分配記憶體給新物件,然後標記複製,迴圈往復。

這種演算法的優點是,浪費的空間比較少,只浪費10%的空間,獲得複製然後直接刪除Eden 和一個Survivor記憶體的效率。

缺點是:如果一個Eden和Survivor標記後存活的物件,大於另外一個Survivor空間的小,這時候需要使用逃生門——當Survivor容納不下一個Minor GC後存活的物件的時候,需要依賴另外的空間(老年代)進行擔保,讓這部分物件直接進行老年代

3.4 標記整理

標記複製演算法,在物件存活率高的時候需要進行較多複製操作,效率將會降低。如果不使用一半使用,一半浪費的傳統標記複製演算法,那麼需要老年代需要擔保,所以老年代一般不使用這種演算法(沒人為老年代擔保)。

標記整理演算法,標記過程和標記清除演算法一樣,但是後續步驟不是清理所有存活物件,而是讓所有存活物件向記憶體空間的一側進行移動,然後清理邊界外的物件

這種演算法的缺點是:如果存活的物件比較多,需要更新指向這些物件的引用為移動後的地址,這是一個比較耗時的操作,必須停止所有使用者執行緒才能進行——"Stop the world"。但是如果不進行移動的話,將造成大量記憶體碎片,必須使用更復雜的結構(空閒連結串列)來解決記憶體分配問題。因此存在另外一種和稀泥的方式——先使用標記清理,然後碎片化程度無法容忍的時候,再使用標記整理收集一次。

對於基於分代收集理論的垃圾回收器,下圖成立。

image-20230129235056616

後面將繼續總結,hotspot虛擬機器如何實現這些演算法,以及常見的經典垃圾收集器

相關文章