JVM系列第8講:JVM 垃圾回收機制
在第 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 的垃圾回收都將清除了,從一開始什麼是垃圾,到之後如何判斷垃圾,到如何回收垃圾,到垃圾回收的兩個重要思想:分代思想、分割槽思想。通過這麼一個脈絡,我們瞭解了垃圾回收的整體概括。在下面的章節中,我們將深入介紹這其中的細節。
參考資料
- GC Root 包括哪些:Help - Eclipse Platform
JVM系列目錄
- JVM系列開篇:為什麼要學虛擬機器?
- JVM系列第1講:Java 語言的前世今生
- JVM系列第2講:Java 虛擬機器的歷史
- JVM系列第3講:到底什麼是虛擬機器?
- JVM系列第4講:從原始碼到機器碼,發生了什麼?
- JVM系列第5講:位元組碼檔案結構
- JVM系列第6講:Java 虛擬機器記憶體結構
- JVM系列第7講:JVM 類載入機制
- JVM系列第8講:JVM 垃圾回收機制
如果只是看,其實無法真正學會知識的。為了幫助大家更好地學習,我建了一個虛擬機器群,專門討論學習 Java 虛擬機器方面的內容,每週針對我所發文章進行討論答疑。如果你有興趣,關注「Java技術精選」公眾號,通過右下角選單「入群交流」加我好友,小助手會拉你入群。