JVM入門教程第8講:JVM 垃圾回收機制

愛學習de小烏龜 發佈 2022-08-20T02:24:51.962389+00:00

在第 6 講中我們說到 Java 虛擬機的內存結構,提到了這部分的規範其實是由《Java 虛擬機規範》指定的,每個 Java 虛擬機可能都有不同的實現。其實涉及到 Java 虛擬機的內存,就不得不談到 Java 虛擬機的垃圾回收機制。

在第 6 講中我們說到 Java 虛擬機的內存結構,提到了這部分的規範其實是由《Java 虛擬機規範》指定的,每個 Java 虛擬機可能都有不同的實現。其實涉及到 Java 虛擬機的內存,就不得不談到 Java 虛擬機的垃圾回收機制。因為內存總是有限的,我們需要一個機制來不斷地回收廢棄的內存,從而實現內存的循環利用,這樣程序才能正常地運轉下去。

比起 Java 虛擬機的內存結構有《Java 虛擬機規範》規定,垃圾回收機制並沒有具體的規範約束。所以很多時候不同的虛擬機有不同的實現方式,下面所說的垃圾回收都是以 HotSpot 虛擬機為例。

到底誰是垃圾?

要進行垃圾回收,最為重要的一個問題是:判斷誰是垃圾?

聯想其日常生活中,如果一個東西經常沒被使用,那麼這個對象可以說就是垃圾。在 Java 中也是如此,如果一個對象不可能再被引用,那麼這個對象就是垃圾,應該被回收。

根據這個思想,我們很容易想到使用引用計數的方法來判斷垃圾。在一個對象被引用時加一,被去除引用時減一,這樣我們就可以通過判斷引用計數是否為零來判斷一個對象是否為垃圾。這種方法我們一般稱之為「引用計數法」。

上面的這種方法雖然簡單,但是其存在一個致命的問題,那就是循環引用。

A 引用了 B,B 引用了 C,C 引用了 A,它們各自的引用計數都為 1。但是它們三個對象卻從未被其他對象引用,只有它們自身互相引用。從垃圾的判斷思想來看,它們三個確實是不被其他對象引用的,但是此時它們的引用計數卻不為零。這就是引用計數法存在的循環引用問題。

而現今的 Java 虛擬機判斷垃圾對象使用的是:GC Root Tracing 算法。其大概的過程是這樣:從 GC Root 出發,所有可達的對象都是存活的對象,而所有不可達的對象都是垃圾。

可以看到這裡最重要的就是 GC Root 這個集合了,其實 GC Root 就是一組活躍引用的集合。但是這個集合又與一般的對象集合不太一樣,這些集合是經過特意篩選出來的,通常包括:

  • 所有當前被加載的 Java 類
  • Java 類的引用類型靜態變量
  • Java類的運行時常量池裡的引用類型常量
  • VM的一些靜態數據結構里指向GC堆里的對象的引用
  • 等等

簡單地說,GC Root 就是經過精心挑選的一組活躍引用,這些引用是肯定存活的。那麼通過這些引用延伸到的對象,自然也是存活的。

如何進行垃圾回收?

到這裡,我們了解了什麼是垃圾以及 JVM 是如何判斷垃圾對象的。那麼識別出垃圾對象之後,JVM 是如何進行垃圾回收的呢?這就是我們下面要講的內容:如何進行垃圾回收?

垃圾回收算法簡單地說有三種算法:標記清除算法、複製算法、標記壓縮算法。

標記清除算法。從名字可以看到其分為兩個階段:標記階段和清除階段。一種可行的實現方式是,在標記階段,標記所有由 GC Root 觸發的可達對象。此時,所有未被標記的對象就是垃圾對象。之後在清除階段,清除所有未被標記的對象。標記清除算法最大的問題就是空間碎片問題。如果空間碎片過多,則會導致內存空間的不連續。雖說大對象也可以分配在不連續的空間中,但是效率要低於連續的內存空間。

複製算法。複製算法的核心思想是將原有的內存空間分為兩塊,每次只使用一塊,在垃圾回收時,將正在使用的內存中的存活對象複製到未使用的內存塊中。之後清除正在使用的內存塊中的所有對象,之後交換兩個內存塊的角色,完成垃圾回收。該算法的缺點是要將內存空間折半,極大地浪費了內存空間。

標記壓縮算法。標記壓縮算法可以說是標記清除算法的優化版,其同樣需要經歷兩個階段,分別是:標記結算、壓縮階段。在標記階段,從 GC Root 引用集合觸發去標記所有對象。在壓縮階段,其則是將所有存活的對象壓縮在內存的一邊,之後清理邊界外的所有空間。

對比一下這三種算法,可以發現他們都有各自的優點和缺點。

標記清除算法雖然會產生內存碎片,但是不需要移動太多對象,比較適合在存活對象比較多的情況。而複製算法雖然需要將內存空間折半,並且需要移動存活對象,但是其清理後不會有空間碎片,比較適合存活對象比較少的情況。而標記壓縮算法,則是標記清除算法的優化版,減少了空間碎片。

分代思想

試想一下,如果我們單獨採用任何一種算法,那麼最終的垃圾回收效率都不會很好。其實 JVM 虛擬機的建造者們也是這麼想的,因此在實際的垃圾回收算法中採用了分代算法。

所謂分代算法,就是根據 JVM 內存的不同內存區域,採用不同的垃圾回收算法。例如對於存活對象少的新生代區域,比較適合採用複製算法。這樣只需要複製少量對象,便可完成垃圾回收,並且還不會有內存碎片。而對於老年代這種存活對象多的區域,比較適合採用標記壓縮算法或標記清除算法,這樣不需要移動太多的內存對象。

試想一下,如果沒有採用分代算法,而在老年代中使用複製算法。在極端情況下,老年代對象的存活率可以達到100%,那麼我們就需要複製這麼多個對象到另外一個內存區域,這個工作量是非常龐大的。

在這裡我們再深入地聊一聊新生代里採取的垃圾回收算法。如我們上面所說,新生代的特點是存活對象少,適合採用複製算法。而複製算法的一種最簡單實現便是折半內存使用,另一半備用。但實際上我們知道,在實際的 JVM 新生代劃分中,卻不是採用等分為兩塊內存的形式。而是分為:Eden 區域、from 區域、to 區域 這三個區域。那麼為什麼 JVM 最終要採用這種形式,而不用 50% 等分為兩個內存塊的方式?

要解答這個問題,我們就需要先深入了解新生代對象的特點。根據IBM公司的研究表明,在新生代中的對象 98% 是朝生夕死的,所以並不需要按照1:1的比例來劃分內存空間。所以在HotSpot虛擬機中,JVM 將內存劃分為一塊較大的Eden空間和兩塊較小的Survivor空間,其大小占比是8:1:1。當回收時,將Eden和Survivor中還存活的對象一次性複製到另外一塊Survivor空間上,最後清理掉Eden和剛才用過的Eden空間。

通過這種方式,內存的空間利用率達到了90%,只有10%的空間是浪費掉了。而如果通過均分為兩塊內存,則其內存利用率只有 50%,兩者利用率相差了將近一倍。

分區思想

分代思想按照對象的生命周期長短將其分為了兩個部分(新生代、老年代),但 JVM 中其實還有一個分區思想,即將整個堆空間劃分成連續的不同小區間。

每一個小區間都獨立使用,獨立回收,這種算法的好處是可以控制一次回收多少個區間,可以較好地控制 GC 時間。

到這裡我們基本上把 JVM 的垃圾回收都將清除了,從一開始什麼是垃圾,到之後如何判斷垃圾,到如何回收垃圾,到垃圾回收的兩個重要思想:分代思想、分區思想。通過這麼一個脈絡,我們了解了垃圾回收的整體概括。在下面的章節中,我們將深入介紹這其中的細節。



關鍵字: