原創:碼農參上(微信公眾號ID:CODER_SANJYOU),歡迎分享,轉載請保留出處。

提到Java中的垃圾回收,我相信很多小夥伴和我一樣,第一反應就是面試必問了,你要是沒背過點GC演算法、收集器什麼的知識,出門都不敢說自己背過八股文。說起來還真是有點尷尬,工作中實際用到這方面知識的場景真是不多,並且這東西學起來也很枯燥,但是奈何面試官就是愛問,我們能有什麼辦法呢?

既然已經卷成了這樣,不學也沒有辦法,Hydra犧牲了週末時間,給大家畫了幾張動圖,希望通過這幾張圖,能夠幫助大家對垃圾收集演算法有個更好的理解。廢話不多說,首先還是從基礎問題開始,看看怎麼判斷一個物件是否應該被回收。

判斷物件存活

垃圾回收的根本目的是利用一些演算法進行記憶體的管理,從而有效的利用記憶體空間,在進行垃圾回收前,需要判斷物件的存活情況,在jvm中有兩種判斷物件的存活演算法,下面分別進行介紹。

1、引用計數演算法

在物件中新增一個引用計數器,每當有一個地方引用它時計數器就加 1,當引用失效時計數器減 1。當計數器為0的時候,表示當前物件可以被回收。

這種方法的原理很簡單,判斷起來也很高效,但是存在兩個問題:

  • 堆中物件每一次被引用和引用清除時,都需要進行計數器的加減法操作,會帶來效能損耗
  • 當兩個物件相互引用時,計數器永遠不會0。也就是說,即使這兩個物件不再被程式使用,仍然沒有辦法被回收,通過下面的例子看一下迴圈引用時的計數問題:
public void reference(){
A a = new A();
B b = new B();
a.instance = b;
b.instance = a;
}

引用計數的變化過程如下圖所示:

可以看到,在方法執行完成後,棧中的引用被釋放,但是留下了兩個物件在堆記憶體中迴圈引用,導致了兩個例項最後的引用計數都不為0,最終這兩個物件的記憶體將一直得不到釋放,也正是因為這一缺陷,使引用計數演算法並沒有被實際應用在gc過程中。

2、可達性分析演算法

可達性分析演算法是jvm預設使用的尋找垃圾的演算法,需要注意的是,雖然說的是尋找垃圾,但實際上可達性分析演算法尋找的是仍然存活的物件。至於這樣設計的理由,是因為如果直接尋找沒有被引用的垃圾物件,實現起來相對複雜、耗時也會比較長,反過來標記存活的物件會更加省時。

可達性分析演算法的基本思路就是,以一系列被稱為GC Roots的物件作為起始點,從這些節點開始向下搜尋,搜尋所走過的路徑稱為引用鏈,當一個物件到GC Roots沒有任何引用鏈相連時,證明該物件不再存活,可以作為垃圾被回收。

在java中,可作為GC Roots的物件有以下幾種:

  • 在虛擬機器棧(棧幀的本地變量表)中引用的物件
  • 在方法區中靜態屬性引用的物件
  • 在方法區中常量引用的物件
  • 在本地方法棧中JNI(native方法)引用的物件
  • jvm內部的引用,如基本資料型別對應的Class物件、一些常駐異常物件等,及系統類載入器
  • 被同步鎖synchronized持有的物件引用
  • 反映jvm內部情況的 JMXBeanJVMTI 中註冊的回撥原生代碼快取等
  • 此外還有一些臨時性的GC Roots,這是因為垃圾收集大多采用分代收集區域性回收,考慮到跨代或跨區域引用的物件時,就需要將這部分關聯的物件也新增到GC Roots中以確保準確性

其中比較重要、同時提到的比較多的還是前面4種,其他的簡單瞭解一下即可。在瞭解了jvm是如何尋找垃圾物件之後,我們來看一看不同的垃圾收集演算法的執行過程是怎樣的。

垃圾收集演算法

1、標記-清除演算法

標記清除演算法是一種非常基礎的垃圾收集演算法,當堆中的有效記憶體空間耗盡時,會觸發STW(stop the world),然後分標記清除兩階段來進行垃圾收集工作:

  • 標記:從GC Roots的節點開始進行掃描,對所有存活的物件進行標記,將其記錄為可達物件
  • 清除:對整個堆記憶體空間進行掃描,如果發現某個物件未被標記為可達物件,那麼將其回收

通過下面的圖,簡單的看一下兩階段的執行過程:

但是這種演算法會帶來幾個問題:

  • 在進行GC時會產生STW,停止整個應用程式,造成使用者體驗較差
  • 標記和清除兩個階段的效率都比較低,標記階段需要從根集合進行掃描,清除階段需要對堆內所有的物件進行遍歷
  • 僅對非存活的物件進行處理,清除之後會產生大量不連續的記憶體碎片。導致之後程式在執行時需要分配較大的物件時,無法找到足夠的連續記憶體,會再觸發一次新的垃圾收集動作

此外,jvm並不是真正的把垃圾物件進行了遍歷,把內部的資料都刪除了,而是把垃圾物件的首地址和尾地址進行了儲存,等到再次分配記憶體時,直接去地址列表中分配,通過這一措施提高了一些標記清除演算法的效率。

2、複製演算法

複製演算法主要被應用於新生代,它將記憶體分為大小相同的兩塊,每次只使用其中的一塊。在任意時間點,所有動態分配的物件都只能分配在其中一個記憶體空間,而另外一個記憶體空間則是空閒的。複製演算法可以分為兩步:

  • 當其中一塊記憶體的有效記憶體空間耗盡後,jvm會停止應用程式執行,開啟複製演算法的gc執行緒,將還存活的物件複製到另一塊空閒的記憶體空間。複製後的物件會嚴格按照記憶體地址依次排列,同時gc執行緒會更新存活物件的記憶體引用地址,指向新的記憶體地址
  • 在複製完成後,再把使用過的空間一次性清理掉,這樣就完成了使用的記憶體空間和空閒記憶體空間的對調,使每次的記憶體回收都是對記憶體空間的一半進行回收

通過下面的圖來看一下複製演算法的執行過程:

複製演算法的的優點是彌補了標記清除演算法中,會出現記憶體碎片的缺點,但是它也同樣存在一些問題:

  • 只使用了一半的記憶體,所以記憶體的利用率較低,造成了浪費
  • 如果物件的存活率很高,那麼需要將很多物件複製一遍,並且更新它們的應用地址,這一過程花費的時間會非常的長

從上面的缺點可以看出,如果需要使用複製演算法,那麼有一個前提就是要求物件的存活率要比較低才可以,因此,複製演算法更多的被用於物件“朝生暮死”發生更多的新生代中。

3、標記-整理演算法

標記整理演算法和標記清除演算法非常的類似,主要被應用於老年代中。可分為以下兩步:

  • 標記:和標記清除演算法一樣,先進行物件的標記,通過GC Roots節點掃描存活物件進行標記
  • 整理:將所有存活物件往一端空閒空間移動,按照記憶體地址依次排序,並更新對應引用的指標,然後清理末端記憶體地址以外的全部記憶體空間

標記整理演算法的執行過程如下圖所示:

可以看到,標記整理演算法對前面的兩種演算法進行了改進,一定程度上彌補了它們的缺點:

  • 相對於標記清除演算法,彌補了出現記憶體空間碎片的缺點
  • 相對於複製演算法,彌補了浪費一半記憶體空間的缺點

但是同樣,標記整理演算法也有它的缺點,一方面它要標記所有存活物件,另一方面還添加了物件的移動操作以及更新引用地址的操作,因此標記整理演算法具有更高的使用成本。

4、分代收集演算法

實際上,java中的垃圾回收器並不是只使用的一種垃圾收集演算法,當前大多采用的都是分代收集演算法。jvm一般根據物件存活週期的不同,將記憶體分為幾塊,一般是把堆記憶體分為新生代和老年代,再根據各個年代的特點選擇最佳的垃圾收集演算法。主要思想如下:

  • 新生代中,每次收集都會有大量物件死去,所以可以選擇複製演算法,只需要複製少量物件以及更改引用,就可以完成垃圾收集
  • 老年代中,物件存活率比較高,使用複製演算法不能很好的提高效能和效率。另外,沒有額外的空間對它進行分配擔保,因此選擇標記清除標記整理演算法進行垃圾收集

通過圖來簡單看一下各種演算法的主要應用區域:

至於為什麼在某一區域選擇某種演算法,還是和三種演算法的特點息息相關的,再從3個維度進行一下對比:

  • 執行效率:從演算法的時間複雜度來看,複製演算法最優,標記清除次之,標記整理最低
  • 記憶體利用率:標記整理演算法和標記清除演算法較高,複製演算法最差
  • 記憶體整齊程度:複製演算法和標記整理演算法較整齊,標記清除演算法最差

儘管具有很多差異,但是除了都需要進行標記外,還有一個相同點,就是在gc執行緒開始工作時,都需要STW暫停所有工作執行緒。

總結

本文中,我們先介紹了垃圾收集的基本問題,什麼樣的物件可以作為垃圾被回收?jvm中通過可達性分析演算法解決了這一關鍵問題,並在它的基礎上衍生出了多種常用的垃圾收集演算法,不同演算法具有各自的優缺點,根據其特點被應用於各個年代。

雖然這篇文章嘮嘮叨叨了這麼多,不過這些都還是基礎的知識,如果想要徹底的掌握jvm中的垃圾收集,後續還有垃圾收集器、記憶體分配等很多的知識需要理解,不過我們今天就介紹到這裡啦,希望通過這一篇圖解,能夠幫助大家更好的理解垃圾收集演算法。

最後,提前祝大家國慶小長假愉快,我們下篇見~

作者簡介,碼農參上(CODER_SANJYOU),一個熱愛分享的公眾號,有趣、深入、直接,與你聊聊技術。個人微信DrHydra9,歡迎新增好友,進一步交流。