[深入理解Java虛擬機器]垃圾回收演算法

Duancf發表於2024-07-18

說起垃圾收集(Garbage Collection,下文簡稱GC),有不少人把這項技術當作Java語言的伴生產物。事實上,垃圾收集的歷史遠遠比Java久遠,在1960年誕生於麻省理工學院的Lisp是第一門開始使用記憶體動態分配和垃圾收集技術的語言。當Lisp還在胚胎時期時,其作者John McCarthy就思考過垃圾收集需要完成的三件事情:

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

經過半個世紀的發展,今天的記憶體動態分配與記憶體回收技術已經相當成熟,一切看起來都進入了“自動化”時代,那為什麼我們還要去了解垃圾收集和記憶體分配?答案很簡單:當需要排查各種記憶體溢位、記憶體洩漏問題時,當垃圾收整合為系統達到更高併發量的瓶頸時,我們就必須對這些“自動化”的技術實施必要的監控和調節。
把時間從大半個世紀以前撥回到現在,舞臺也回到我們熟悉的Java語言。第2章介紹了Java記憶體執行時區域的各個部分,其中程式計數器、虛擬機器棧、本地方法棧3個區域隨執行緒而生,隨執行緒而滅,棧中的棧幀隨著方法的進入和退出而有條不紊地執行著出棧和入棧操作。每一個棧幀中分配多少記憶體基本上是在類結構確定下來時就已知的(儘管在執行期會由即時編譯器進行一些最佳化,但在基於概念模型的討論裡,大體上可以認為是編譯期可知的),因此這幾個區域的記憶體分配和回收都具備確定性,在這幾個區域內就不需要過多考慮如何回收的問題,當方法結束或者執行緒結束時,記憶體自然就跟隨著回收了。
Java堆和方法區這兩個區域則有著很顯著的不確定性:一個介面的多個實現類需要的記憶體可能會不一樣,一個方法所執行的不同條件分支所需要的記憶體也可能不一樣,只有處於執行期間,我們才能知道程式究竟會建立哪些物件,建立多少個物件,這部分記憶體的分配和回收是動態的。垃圾收集器所關注的正是這部分記憶體該如何管理,本文後續討論中的記憶體分配與回收也僅僅特指這一部分記憶體

物件已死?

在堆裡面存放著Java世界中幾乎所有的物件例項,垃圾收集器在對堆進行回收前,第一件事情就是要確定這些物件之中哪些還“存活”著,哪些已經“死去”(“死去”即不可能再被任何途徑使用的物件)了。

引用計數演算法

很多教科書判斷物件是否存活的演算法是這樣的:在物件中新增一個引用計數器,每當有一個地方引用它時,計數器值就加一;當引用失效時,計數器值就減一;任何時刻計數器為零的物件就是不可能再被使用的。

筆者面試過很多應屆生和一些有多年工作經驗的開發人員,他們對於這個問題給予的都是這個答案。

客觀地說,引用計數演算法(Reference Counting)雖然佔用了一些額外的記憶體空間來進行計數,但它的原理簡單,判定效率也很高,在大多數情況下它都是一個不錯的演算法。也有一些比較著名的應用
案例,例如微軟COM(Component Object Model)技術、使用ActionScript 3的FlashPlayer、Python語言以及在遊戲指令碼領域得到許多應用的Squirrel中都使用了引用計數演算法進行記憶體管理。但是,在Java領域,至少主流的Java虛擬機器裡面都沒有選用引用計數演算法來管理記憶體,主要原因是,這個看似簡單的演算法有很多例外情況要考慮,必須要配合大量額外處理才能保證正確地工作,譬如單純的引用計數就很難解決物件之間相互迴圈引用的問題。

舉個簡單的例子,請看程式碼清單3-1中的testGC()方法:物件objA和objB都有欄位instance,賦值令objA.instance=objB及objB.instance=objA,除此之外,這兩個物件再無任何引用,實際上這兩個物件已經不可能再被訪問,但是它們因為互相引用著對方,導致它們的引用計數都不為零,引用計數演算法也就無法回收它們。

/**
* testGC()方法執行後,objA和objB會不會被GC呢?
* @author zzm
*/
public class ReferenceCountingGC {
public Object instance = null;
private static final int _1MB = 1024 * 1024;
/**
* 這個成員屬性的唯一意義就是佔點記憶體,以便能在GC日誌中看清楚是否有回收過
*/
private byte[] bigSize = new byte[2 * _1MB];
public static void testGC() {
ReferenceCountingGC objA = new ReferenceCountingGC();
ReferenceCountingGC objB = new ReferenceCountingGC();
objA.instance = objB;
objB.instance = objA;
objA = null;
objB = null;
// 假設在這行發生GC,objA和objB是否能被回收?
System.gc();
}
}

執行結果:
[Full GC (System) [Tenured: 0K->210K(10240K), 0.0149142 secs] 4603K->210K(19456K), [Perm : 2999K->2999K(21248K)], 0.0150007 secs] [Times: user=0.01 sys=0.00, real=0.02 secs]
Heap
def new generation total 9216K, used 82K [0x00000000055e0000, 0x0000000005fe0000, 0x0000000005fe0000)
Eden space 8192K, 1% used [0x00000000055e0000, 0x00000000055f4850, 0x0000000005de0000)
from space 1024K, 0% used [0x0000000005de0000, 0x0000000005de0000, 0x0000000005ee0000)
to space 1024K, 0% used [0x0000000005ee0000, 0x0000000005ee0000, 0x0000000005fe0000)
tenured generation total 10240K, used 210K [0x0000000005fe0000, 0x00000000069e0000, 0x00000000069e0000)
the space 10240K, 2% used [0x0000000005fe0000, 0x0000000006014a18, 0x0000000006014c00, 0x00000000069e0000)
compacting perm gen total 21248K, used 3016K [0x00000000069e0000, 0x0000000007ea0000, 0x000000000bde0000)
the space 21248K, 14% used [0x00000000069e0000, 0x0000000006cd2398, 0x0000000006cd2400, 0x0000000007ea0000)
No shared spaces configured.

從執行結果中可以清楚看到記憶體回收日誌中包含“4603K->210K”,意味著虛擬機器並沒有因為這兩個物件互相引用就放棄回收它們,這也從側面說明了Java虛擬機器並不是透過引用計數演算法來判斷物件是否存活的

可達性分析演算法

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

如圖3-1所示,物件object 5、object 6、object 7雖然互有關聯,但是它們到GC Roots是不可達的,因此它們將會被判定為可回收的物件。
image

在Java技術體系裡面,固定可作為GC Roots的物件包括以下幾種:

  • 虛擬機器棧棧幀中的本地變數表)中引用的物件,譬如各個執行緒被呼叫的方法堆疊中使用到的引數、區域性變數、臨時變數等。

  • 方法區中類靜態屬性引用的物件,譬如Java類的引用型別靜態變數。

  • 方法區中常量引用的物件,譬如字串常量池(String Table)裡的引用。

  • 本地方法棧中JNI(即通常所說的Native方法)引用的物件。

  • Java虛擬機器內部的引用,如基本資料型別對應的Class物件,一些常駐的異常物件(比如NullPointExcepiton、OutOfMemoryError)等,還有系統類載入器。

  • 所有被同步鎖(synchronized關鍵字)持有的物件。

  • 反映Java虛擬機器內部情況的JMXBean、JVMTI中註冊的回撥、原生代碼快取等。

除了這些固定的GC Roots集合以外,根據使用者所選用的垃圾收集器以及當前回收的記憶體區域不同,還可以有其他物件“臨時性”地加入,共同構成完整GC Roots集合。譬如後文將會提到的分代收集和區域性回收(Partial GC),如果只針對Java堆中某一塊區域發起垃圾收集時(如最典型的只針對新生代的垃圾收集),必須考慮到記憶體區域是虛擬機器自己的實現細節(在使用者視角里任何記憶體區域都是不可見的),更不是孤立封閉的,所以某個區域裡的物件完全有可能被位於堆中其他區域的物件所引用,這時候就需要將這些關聯區域的物件也一併加入GC Roots集合中去,才能保證可達性分析的正確性。

目前最新的幾款垃圾收集器無一例外都具備了區域性回收的特徵,為了避免GC Roots包含過多物件而過度膨脹,它們在實現上也做出了各種最佳化處理。關於這些概念、最佳化技巧以及各種不同收集器實現等內容,都將在本章後續內容中一一介紹。

垃圾收集演算法

垃圾收集演算法的實現涉及大量的程式細節,且各個平臺的虛擬機器操作記憶體的方法都有差異,在本節中我們暫不過多討論演算法實現,只重點介紹分代收集理論和幾種演算法思想及其發展過程。如果讀者對其中的理論細節感興趣,推薦閱讀Richard Jones撰寫的《垃圾回收演算法手冊》的第2~4章的相關內容。

從如何判定物件消亡的角度出發,垃圾收集演算法可以劃分為引用計數式垃圾收集(ReferenceCounting GC)和追蹤式垃圾收集(Tracing GC)兩大類,這兩類也常被稱作“直接垃圾收集”和“間接垃圾收集”。由於引用計數式垃圾收集演算法在本書討論到的主流Java虛擬機器中均未涉及,所以我們暫不把它作為正文主要內容來講解,本節介紹的所有演算法均屬於追蹤式垃圾收集的範疇。

原著名為《The Garbage Collection Handbook》,2011年出版,中文版在2016年由機械工業出版社翻譯引進國內。

分代收集理論

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

弱分代假說(Weak Generational Hypothesis):絕大多數物件都是朝生夕滅的。
強分代假說(Strong Generational Hypothesis):熬過越多次垃圾收集過程的物件就越難以消亡。

這兩個分代假說共同奠定了多款常用的垃圾收集器的一致的設計原則:收集器應該將Java堆劃分出不同的區域,然後將回收物件依據其年齡(年齡即物件熬過垃圾收集過程的次數)分配到不同的區域之中儲存

顯而易見,如果一個區域中大多數物件都是朝生夕滅,難以熬過垃圾收集過程的話,那麼把它們集中放在一起,每次回收時只關注如何保留少量存活而不是去標記那些大量將要被回收的物件,就能以較低代價回收到大量的空間;
如果剩下的都是難以消亡的物件,那把它們集中放在一塊,虛擬機器便可以使用較低的頻率來回收這個區域,這就同時兼顧了垃圾收集的時間開銷和記憶體的空間有效利用。

在Java堆劃分出不同的區域之後,垃圾收集器才可以每次只回收其中某一個或者某些部分的區域——因而才有了Minor GC,Major GC,Full GC這樣的回收型別的劃分;也才能夠針對不同的區域安排與裡面儲存物件存亡特徵相匹配的垃圾收集演算法——因而發展出了“標記-複製演算法”“標記-清除演算法”“標記-整理演算法”等針對性的垃圾收集演算法。這裡筆者提前提及了一些新的名詞,它們都是本章的重要角色,稍後都會逐一登場,現在讀者只需要知道,這一切的出現都始於分代收集理論。

把分代收集理論具體放到現在的商用Java虛擬機器裡,設計者一般至少會把Java堆劃分為新生代(Young Generation)和老年代(Old Generation)兩個區域。顧名思義,在新生代中,每次垃圾收集時都發現有大批物件死去,而每次回收後存活的少量物件,將會逐步晉升到老年代中存放。如果讀者有興趣閱讀HotSpot虛擬機器原始碼的話,會發現裡面存在著一些名為Generation的實現,如“DefNewGeneration”和“ParNewGeneration”等,這些就是HotSpot的“分代式垃圾收集器框架”。原本HotSpot鼓勵開發者儘量在這個框架內開發新的垃圾收集器,但除了最早期的兩組四款收集器之外,後來的開發者並沒有繼續遵循。導致此事的原因有很多,最根本的是分代收集理論仍在不斷髮展之中,如何實現也有許多細節可以改進,被既定的程式碼框架約束反而不便。其實我們只要仔細思考一下,也很容易發現分代收集並非只是簡單劃分一下記憶體區域那麼容易,它至少存在一個明顯的困難:物件不是孤立的,物件之間會存在跨代引用。假如要現在進行一次只侷限於新生代區域內的收集(Minor GC),但新生代中的物件是完全有可能被老年代所引用的,為了找出該區域中的存活物件,不得不在固定的GC Roots之外,再額外遍歷整個老年代中所有物件來確保可達性分析結果的正確性,反過來也是一樣。遍歷整個老年代所有物件的方案雖然理論上可行,但無疑會為記憶體回收帶來很大的效能負擔。為了解決這個問題,就需要對分代收集理論新增第三條經驗法則:

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

這其實是可根據前兩條假說邏輯推理得出的隱含推論:存在互相引用關係的兩個物件,是應該傾向於同時生存或者同時消亡的。舉個例子,如果某個新生代物件存在跨代引用,由於老年代物件難以消亡,該引用會使得新生代物件在收集時同樣得以存活,進而在年齡增長之後晉升到老年代中,這時跨代引用也隨即被消除了。依據這條假說,我們就不應再為了少量的跨代引用去掃描整個老年代,也不必浪費空間專門記錄每一個物件是否存在及存在哪些跨代引用,只需在新生代上建立一個全域性的資料結構(該結構被稱為記憶集,Remembered Set),這個結構把老年代劃分成若干小塊,標識出老年代的哪一塊記憶體會存在跨代引用。此後當發生Minor GC時,只有包含了跨代引用的小塊記憶體裡的物件才會被加入到GCRoots進行掃描。雖然這種方法需要在物件改變引用關係(如將自己或者某個屬性賦值)時維護記錄資料的正確性,會增加一些執行時的開銷,但比起收集時掃描整個老年代來說仍然是划算的。

注意 剛才我們已經提到了“Minor GC”,後續文中還會出現其他針對不同分代的類似名詞,為避免讀者產生混淆,在這裡統一定義:

  • 部分收集(Partial GC):指目標不是完整收集整個Java堆的垃圾收集,其中又分為:
    • 新生代收集(Minor GC/Young GC):指目標只是新生代的垃圾收集。
    • 老年代收集(Major GC/Old GC):指目標只是老年代的垃圾收集。目前只有CMS收集器會有單獨收集老年代的行為。另外請注意“Major GC”這個說法現在有點混淆,在不同資料上常有不同所指,讀者需按上下文區分到底是指老年代的收集還是整堆收集。
    • 混合收集(Mixed GC):指目標是收集整個新生代以及部分老年代的垃圾收集。目前只有G1收集器會有這種行為。
  • 整堆收集(Full GC):收集整個Java堆和方法區的垃圾收集。

[1] 值得注意的是,分代收集理論也有其缺陷,最新出現(或在實驗中)的幾款垃圾收集器都展現出了面向全區域收集設計的思想,或者可以支援全區域不分代的收集的工作模式。
[2] 新生代(Young)、老年代(Old)是HotSpot虛擬機器,也是現在業界主流的命名方式。在IBM J9虛擬機器中對應稱為嬰兒區(Nursery)和長存區(Tenured),名字不同但其含義是一樣的。
[3] 通常能單獨發生收集行為的只是新生代,所以這裡“反過來”的情況只是理論上允許,實際上除了CMS收集器,其他都不存在只針對老年代的收集。

相關文章