天天看點

深入了解JVM——記憶體回收與GC算法

目錄

哪些記憶體需要回收

一、判斷對象是否存活

什麼時候回收

如何回收

1. 标記 - 清除算法

2.标記-複制算法

3. 标記 - 整理算法

GC的曆史比Java久遠,1960年誕生于MIT的Lisp是第一門真正使用記憶體動态配置設定和垃圾收集技術的語言。當Lisp還在胚胎時期時,人們就在思考GC需要完成的3件事情:

  • 哪些記憶體需要回收?
  • 什麼時候回收?
  • 如何回收?

記憶體區域中的程式計數器、虛拟機棧、本地方法棧這3個區域随着線程而生,線程而滅;棧中的棧幀随着方法的進入和退出而有條不紊地執行着出棧和入棧的操作,每個棧幀中配置設定多少記憶體基本是在類結構确定下來時就已知的。在這幾個區域不需要過多考慮回收的問題,因為方法結束或者線程結束時,記憶體自然就跟着回收了。

而Java堆和方法區則不同,一個接口中的多個實作類需要的記憶體可能不同,一個方法中的多個分支需要的記憶體也可能不一樣,我們隻有在程式處于運作期間時才能知道會建立哪些對象,這部分記憶體的配置設定和回收都是動态的,GC關注的也是這部分記憶體。

哪些記憶體需要回收

在堆中存放着 Java 中幾乎所有對象的執行個體,那麼已經"死去"(沒有引用,不可能再被使用)的對象當然是需要回收的。

一、判斷對象是否存活

1. 引用計數法

  • 原理:給對象添加一個引用計數器,每當有地方引用時計數器加 1,引用失效時減 1。當該對象引用為 0 時,判定對象失效
  • 優點:實作簡單,判定效率高
  • 缺點:很難解決對象之間循環引用的問題

2. 可達性分析法

目前主流的商用程式語言(Java、 C#, 上溯至前面提到的古老的Lisp) 的記憶體管理子系統, 都是通過可達性分析(Reachability Analysis) 算法來判定對象是否存活的。 這個算法的基本思路就是通過一系列稱為“GC Roots”的根對象作為起始節點集, 從這些節點開始, 根據引用關系向下搜尋, 搜尋過程所走過的路徑稱為“引用鍊”(Reference Chain) , 如果某個對象到GC Roots間沒有任何引用鍊相連,或者用圖論的話來說就是從GC Roots到這個對象不可達時, 則證明此對象是不可能再被使用的。

深入了解JVM——記憶體回收與GC算法

Java 中可作為 GC Roots 的對象:

  • 虛拟機棧(棧幀中的本地變量表)中引用的對象;
  • 方法區中類靜态屬性引用的對象;
  • 方法區中常量引用的對象;
  • 本地方法棧中 JNI(Native 方法)引用的對象。
  • 所有被同步鎖(synchronized關鍵字) 持有的對象
  • Java虛拟機内部的引用, 如基本資料類型對應的Class對象, 一些常駐的異常對象(比如

    NullPointExcepiton、 OutOfMemoryError) 等, 還有系統類加載器。

什麼時候回收

當 JVM 經過可達性分析法篩選出實效對象時,并不是馬上清除,而是進行标記并判斷是否回收:

  1. 判斷對象是否覆寫了 finalize() 方法,任何一個對象的finalize()方法都隻會被系統自動調用一次, 如果對象面臨下一次回收, 它的finalize()方法不會被再次執行。
  2. 執行 F-Queue 隊列中的 finalize() 方法

    由虛拟機自動建立一個優先級較低的線程去執行 F-Queue 中的 finalize() 方法,這裡的執行隻是觸發這些方法并不保證會等待它執行完畢。如果 finalize() 方法作了耗時操作,虛拟機會停止執行并将該對象清除。 

  3. 對象銷毀或重生

    在 finalize() 方法中,将 this 指派給某一個引用,那麼該對象就重生了。如果沒有引用,該對象會被回收。

方法區的記憶體回收

Java 虛拟機規範中說不需要方法區實作垃圾收集,因為方法區中存放的都是一些生命周期較長的類資訊、常量、靜态變量。方法區就像是堆的老年代,每次垃圾回收隻有少量垃圾被清除。方法區的垃圾收集主要回收兩部分内容: 廢棄的常量和不再使用的類型。

  • 廢棄的常量:

    目前系統中沒有任何對象引用常量池中的該常量,則是廢棄常量

  • 廢棄的類:

    該類所有執行個體都被回收;

    加載該類的 ClassLoader 已經被回收;

    該類對應的 Class 對象沒有引用,也無法通過反射通路該類的方法。

如何回收

通過上文了解到垃圾收集、記憶體回收的主要區域是 Java 堆,JVM 回收的對象是那些沒有引用的對象、常量、類等。要注意的是 JVM 篩選出需要清除的對象時并不是馬上進行回收,而是進行标記并判斷是否覆寫 finalize() 方法,然後再依據一定規則進行 GC。

目前商業虛拟機的垃圾收集器, 大多數都遵循了“分代收集”(Generational Collection)的理論進行設計, 分代收集名為理論, 實質是一套符合大多數程式運作實際情況的經驗法則, 它建立在兩個分代假說之上:

1) 弱分代假說(Weak Generational Hypothesis) : 絕大多數對象都是朝生夕滅的。

2) 強分代假說(Strong Generational Hypothesis) : 熬過越多次垃圾收集過程的對象就越難以消亡。

分代收集并非隻是簡單劃分一下記憶體區域那麼容易, 它至少存在一個明顯的困難: 對象不是孤立的, 對象之間會存在跨代引用。為了解決這個問題, 就需要對分代收集理論添加第三條經驗則:

3) 跨代引用假說(Intergenerational Reference Hypothesis) : 跨代引用相對于同代引用來說僅占極少數。

  • 部分收集(Partial GC) : 指目标不是完整收集整個Java堆的垃圾收集, 其中又分為:
  • 新生代收集(Minor GC/Young GC) : 指目标隻是新生代的垃圾收集。
  • 老年代收集(Major GC/Old GC) : 指目标隻是老年代的垃圾收集。 目前隻有CMS收集器會有單獨收集老年代的行為。 
  • 混合收集(Mixed GC) : 指目标是收集整個新生代以及部分老年代的垃圾收集。 目前隻有G1收集器會有這種行為。
  • 整堆收集(Full GC) : 收集整個Java堆和方法區的垃圾收集。

1. 标記 - 清除算法

最基礎的收集算法是"标記 - 清除"算法,之是以說它是最基礎的是因為它邏輯簡單、使用簡便,而且後續的收集算法大多基于這種算法的不足而優化的。如它的名字一樣, 算法分為“标記”和“清除”兩個階段: 首先标記出所有需要回收的對象, 在标記完成後, 統一回收掉所有被标記的對象, 也可以反過來, 标記存活的對象, 統一回收所有未被标記的對象。

它的主要缺點有兩個:

  • 第一個是執行效率不穩定, 如果Java堆中包含大量對 象, 而且其中大部分是需要被回收的, 這時必須進行大量标記和清除的動作, 導緻标記和清除兩個過程的執行效率都随對象數量增長而降低;
  • 第二個是記憶體空間的碎片化問題, 标記、 清除之後會産生大量不連續的記憶體碎片, 空間碎片太多可能會導緻當以後在程式運作過程中需要配置設定較大對象時無法找到足夠的連續記憶體而不得不提前觸發另一次垃圾收集動作。

标記-清除算法的執行過程如圖所示

深入了解JVM——記憶體回收與GC算法

2.标記-複制算法

标記-複制算法常被簡稱為複制算法。 為了解決标記-清除算法面對大量可回收對象時執行效率低

的問題, 1969年Fenichel提出了一種稱為“半區複制”(Semispace Copying) 的垃圾收集算法, 它将可用記憶體按容量劃分為大小相等的兩塊, 每次隻使用其中的一塊。 當這一塊的記憶體用完了, 就将還存活着的對象複制到另外一塊上面, 然後再把已使用過的記憶體空間一次清理掉。 如果記憶體中多數對象都是存活的, 這種算法将會産生大量的記憶體間複制的開銷, 但對于多數對象都是可回收的情況, 算法需要複制的就是占少數的存活對象, 而且每次都是針對整個半區進行記憶體回收, 配置設定記憶體時也就不用考慮有空間碎片的複雜情況, 隻要移動堆頂指針, 按順序配置設定即可。

深入了解JVM——記憶體回收與GC算法
  • 優點:簡答高效,記憶體相對整齊
  • 缺點:

    1.将記憶體分為一半,代價略高。

    2.如果對象存活率高,需要複制的對象比較多,産生效率問題。

  • 優化:

    在新生代中,由于大量的對象都是"朝生夕死",也就是說一次垃圾收集後存活對象較少,是以我們可以把記憶體劃分為三塊:Eden、Survior1、Survior2,大小比例為 8:1:1。配置設定記憶體時隻使用 Eden + Survior1,當這裡的記憶體将滿時,JVM 會出發一次 MinorGC,清除掉廢棄對象,并将存活對象複制到另一塊 Survior2 中。那麼接下來就使用 Eden + Survior2 進行記憶體配置設定。

    通過這種方式隻需浪費 10% 的記憶體空間即可實作複制清除算法,同時避免了記憶體碎片的問題。

3. 标記 - 整理算法

深入了解JVM——記憶體回收與GC算法
  • 原理:标記過程與 "标記 - 清除" 算法相同,但後續不是直接對可回收對象進行清理,而是讓所有存活對象都向一端移動,然後直接清理掉一端邊界外的記憶體。

 标記-清除算法與标記-整理算法的本質差異在于前者是一種非移動式的回收算法,而後者是移動

式的。 是否移動回收後的存活對象是一項優缺點并存的風險決策:

 如果移動存活對象, 尤其是在老年代這種每次回收都有大量對象存活區域, 移動存活對象并更新

所有引用這些對象的地方将會是一種極為負重的操作, 而且這種對象移動操作必須全程暫停使用者應用程式才能進行, 這就更加讓使用者不得不小心翼翼地權衡其弊端了, 像這樣的停頓被最初的虛拟機設計者形象地描述為“Stop The World”。

但如果跟标記-清除算法那樣完全不考慮移動和整理存活對象的話, 彌散于堆中的存活對象導緻的

空間碎片化問題就隻能依賴更為複雜的記憶體配置設定器和記憶體通路器來解決。 譬如通過“分區空閑配置設定連結清單”來解決記憶體配置設定問題(計算機硬碟存儲大檔案就不要求實體連續的磁盤空間, 能夠在碎片化的硬碟上存儲和通路就是通過硬碟分區表實作的)。記憶體的通路是使用者程式最頻繁的操作,甚至都沒有之一,假如在這個環節上增加了額外的負擔,勢必會直接影響應用程式的吞吐量。