JVM系列之垃圾收集算法

掃描下方二維碼或者微信搜索公衆號菜鳥飛呀飛,即可關注微信公衆號,閱讀更多Spring源碼分析Java併發編程Netty源碼系列MySQL工作原理文章。

微信公衆號

前言

上一篇文章中介紹了標記階段的算法,這篇文章將介紹清除階段的算法。常見的大概有三種算法:標記-清除、複製、標記-壓縮算法,下面將一一介紹這三種算法。

標記-清除(Mark-Sweep)算法

標記-清除算法是最早出現也是最基礎的垃圾回收算法,它分爲兩個步驟:標記階段和清除階段。其中標記階段的作用是標記出哪些對象是存活對象,如何判斷對象是否存活,在上一篇文章垃圾回收之標記算法中已經介紹了。清除階段就是從堆內存的起始位置開始遍歷每一個對象,將未存活的對象所在的內存回收。

注意:這裏的內存回收並不是指直接置空,而是通過維護一個內存空閒列表,將死亡對象所在的內存加入到空閒列表中,內存上的數據並沒有清除。當下次新的對象來申請內存空間時,就從這個空閒列表中找出一塊內存區域,然後將新的對象數據寫入到這塊內存中,假如這塊內存中原先存放過垃圾對象,那麼垃圾對象的數據此時就被覆蓋了。
這也是爲什麼在 windows 電腦下刪除磁盤數據或者格式化磁盤後,數據還能恢復的原因。前提條件是磁盤數據被刪除或者格式化後,還沒有被重新寫入新的數據,否則將無法恢復。

標記-清除算法可以用如下示意圖表示。

標記-清除
標記-清除

標記-清除算法基本上沒啥優點,唯一的優點可能就是實現思路比較簡單,是人們很容易想到的一種算法。

但是它的缺點卻不少,首先是效率不高。在標記階段需要通過所有的根節點(GC Roots)遍歷所有對象,判斷對象是否存活,然後在清除階段也需要從頭到尾遍歷整個堆空間,這相當於遍歷了兩遍堆空間,因此效率不高。

其次標記-清除算法會產生內存碎片,當回收完垃圾對象後,整個堆空間中存在很多小的可用的內存區域,這些小區域不是連續的,因此被稱之爲內存碎片。當爲一個較大的對象分配內存空間時,可能會出現堆內存雖然充足,但是卻沒有一塊完整的空閒內存來存放這個對象,這個時候就會再次觸發 GC 操作,這對應用程序是十分不友好的。

複製算法

爲了解決標記-清除算法導致的內存碎片的問題,複製算法出現了。

複製算法的實現思路是:將一塊內存區域分爲兩個部分,每次使用時只使用其中一部分區域,另一部分區域空着,當發生垃圾回收時,就將存活的對象複製到空着的那部分區域,原先的那塊內存區域一次性的全部清空。複製算法可以用如下示意圖表示。

複製算法
複製算法

複製算法的優點就是效率高,它將標記和清除這兩個過程合二爲一了,在標記過程中如果發現對象是存活對象,就直接將對象複製到空閒區域了,因此它的效率會高於標記-清除算法。另外複製算法在回收完垃圾後不會產生內存碎片。

當然,複製算法的缺點也很明顯,就是浪費了一半的內存空間。另外,因爲複製對象到新的內存區域了,也就是對象的地址變了,因此複製完後,還需要修改對象的引用地址,這個過程中,也需要暫停用戶線程,因此會產生 STW(Stop The World)。

如果垃圾回收的目標區域中,對象大部分都是存活對象,甚至在極端情況下,所有對象都是存活對象,那麼採用複製算法就需要複製所有對象了,這效率肯定是很低了。因此複製算法適合用於每次垃圾回收時,大部分對象都是垃圾對象的區域。例如新生代區域,大部分對象都是”朝生夕滅“的對象,每次對新生代區域進行垃圾回收時,大部分都是可回收的。

事實上,針對新生代區域的垃圾回收器如:Serial、ParNew、Parallel Scavenge,它們都是採用複製算法來進行垃圾回收的。

在 HotSpot 虛擬機中,新生代又被細分爲 Eden 區、Survivor0 區、Survivor1 區(後面簡稱 S0 和 S1),默認情況,Eden:S0:S1=8:1:1,在使用過程中 S0 和 S1 區始終會有一塊區域是空閒的,佔新生代的 10%,而複製算法要求一半的空閒區域,那麼爲什麼針對新生代的垃圾回收算法還能使用複製算法呢?這是因爲新生代中大部分都是垃圾對象(通常佔 98%),每次回收時,存活的對象極少,因此用 Survivor 區域完全能存放下這些存活對象。如果出現了 Survivor 存不下存活對象,別擔心,還有擔保空間的存在。至於什麼是擔保空間,後面在分享 JVM 內存結構的文章中,會詳細介紹。

標記-壓縮(Mark-Compact)算法

前面提到的標記-清除算法會產生內存碎片,複製算法會造成一半的內存區域浪費,且不適合回收大部分對象都是存活對象的區域,爲了解決這兩個算法的缺點,標記-壓縮算法出現了。

標記-壓縮算法的實現思路是:先根據標記算法判斷每個對象是否是存活對象,然後再將存活的對象全部壓縮到內存的一端,最後將邊界外的內存區域全部清空。示意圖如下。

標記壓縮
標記壓縮

標記-壓縮算法和標記-清除算法比較相似,但是區別是:標記-壓縮算法多了一個步驟,就是內存碎片的整理。標記-壓縮算法回收垃圾後,不需要維護一個單獨的空閒列表來標識可用內存,而只需要維護一個空閒地址的起始指針即可,這比維護一個空閒列表所耗費的開銷小很多。當需要爲新的對象分配內存地址時,只需要移動該指針即可。

標記-壓縮的優點是不會產生內存碎片,同時也消除了複製算法中浪費一半內存區域的缺點。但是標記-壓縮算法的缺點也很明顯,它比標記-清除算法多一個整理內存空間的步驟,因此效率更低。同時標記-壓縮算法在整理內存過程中,還會涉及到移動對象的過程,因此在此期間會暫停用戶線程,修改變量的引用地址,也會造成 STW 的現象。

對比

最後,用一個表格,從三個方面來對比一下標記-清除、複製、標記-壓縮算法。

標記-清除 複製 標記-壓縮
效率 中等 最快 最慢
內存開銷 小(但會產生內存碎片) 浪費一半內存(無內存碎片) 小(無內存碎片)
移動對象 不需要 需要 需要

這三種垃圾回收算法,各有優缺點,沒有誰是完美的。通常在實際使用過程中,都是搭配使用。後面會有文章介紹 7 種具體經典的垃圾回收器時,會進行具體舉例。

分代收集算法

分代收集算法和前面介紹的三種算法不一樣,它實際上並不是一種算法,它是基於這樣一個事實:不同的對象,它的生命週期是不一樣的,爲了提高垃圾回收的效率,可以針對不同生命週期的對象採取不同的垃圾回收方式。通常在 Java 中,會將對象分爲新生代和老年代,在垃圾回收時,分別採用不同的回收算法對它們進行回收。

目前在 Java 中的大部分垃圾回收,均是採用分代回收算法。

增量收集算法

在上述介紹的垃圾收集算法中,在垃圾回收階段,用戶線程都將處於 STW 狀態,如果停頓時間過長,將對應用程序十分不友好,嚴重影響用戶體驗和系統穩定性。因此增量收集算法出現了。

增量收集算法的核心思路是:如果一次性進行垃圾回收時造成的停頓時間過長,那麼就讓垃圾回收線程和用戶線程交替執行。垃圾回收線程先執行一段時間,只收集一小塊區域,然後切換到用戶線程,如此反覆,直至垃圾回收完成。如果在單核 CPU 上,這種交替執行的現象被稱之爲併發。

總的來說,增量收集算法底層使用的仍然是前面介紹的基礎算法:標記-清除或者複製算法。增量收集算法通過妥善處理垃圾收集線程和用戶線程之間的衝突,讓垃圾標記階段和清除或者複製可以分開執行。

增量收集算法雖然可以降低系統的停頓時間,但是由於線程間上下文的頻繁切換,額外給 CPU 造成了壓力,最終會導致系統的吞吐量下降。

分區算法

一般情況下,相同條件下,堆空間越大,GC 所需要的時間就越長,那麼每次 GC 造成的 STW 時間就越長。爲了更好地控制 GC 產生的停頓時間,可以將一大塊內存區域劃分爲許許多多小的內存區域,每次在進行垃圾回收時,根據期望的停頓時間,一次只回收若干個小區域,而不是整個堆空間,從而減少了一次 GC 所產生的停頓時間。

分代收集算法的思路是根據對象的生命週期不同將堆空間劃分爲兩個不同區域,而分區算法的思路是將整個堆空間劃分成連續的若干個小區域。每個小區域都是獨立使用,獨立回收,這種算法的好處是可以控制一次回收多個小區域。

目前採用分區算法的垃圾收集器的代表爲 G1 收集器。

總結

本文主要介紹了三種基礎算法:標記-清除算法、複製算法、標記-壓縮算法,接着又介紹了另外三種算法:分代收集算法、增量收集算法、分區算法,嚴格來講,這三種算法,我個人認爲這更是 3 種思想,它們底層使用的仍然是前面介紹的三種基礎算法。

在實際的垃圾回收器中,大部分都是基於這三種思想以及算法來進行工作的。

參考

  • 周志明《深入理解 JavaJVM 虛擬機》第三版

微信公衆號

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章