HotSpot JVM 記憶體管理

富士康質檢員張全蛋發表於2020-12-17

關於 JVM 記憶體管理或者說垃圾收集,大家可能看過很多的文章了,筆者準備給大家總結下。這算是系列的第一篇,接下來一段時間會持續更新。

本文主要是翻譯《Memory Management in the Java HotSpot Virtual Machine》白皮書的前四章內容,這是 2006 的老文章了,當年釋出這篇文章的還是 Sun Microsystems,以後應該會越來越少人記得這家曾經無比偉大的公司了。

雖然這個白皮書有點老了,不過那個時候 Sun 在 J2SE 5.0 版本的 HotSpot 虛擬機器上已經有了 Parallel 並行垃圾收集器和 CMS 這種併發收集器了,所以其實內容也沒那麼過時。

其實本文應該有挺多人都翻譯過,我大體上是意譯的,增、刪了部分內容。

其他的知識,包括 Java5 之後的垃圾收集器,如 Java8 的 MetaSpace 取代了永久代、G1 收集器等,將在日後的文章中進行介紹。

垃圾收集概念

GC 需要做 3 件事情:

分配記憶體,為每個新建的物件分配空間

確保還在使用的物件的記憶體一直還在,不能把有用的空間當垃圾回收了

釋放不再使用的物件所佔用的空間

我們把還被 GC Roots 引用的物件稱為活的,把不再被引用的物件認為是死的,也就是我們說的垃圾,GC 的工作就是找到死的物件,回收它們佔用的空間。

在這裡,我們總結一下 GC Roots 有哪些:

當前各執行緒執行方法中的區域性變數(包括形參)引用的物件

已被載入的類的 static 域引用的物件

方法區中常量引用的物件

JNI 引用

以上不完全,不過我覺得了解到這些就夠了,瞭解更多

我們把 GC 管理的記憶體稱為 堆(heap),垃圾收集啟動的時機取決於各個垃圾收集器,通常,垃圾收集發生於整個堆或堆的部分已經被使用光了,或者使用的空間達到了某個百分比閾值。這些後面都會具體說,這裡的每一句話都是對應了某些場景的。

對於記憶體分配請求,實現的難點在於在堆中找到一塊沒有被使用的確定大小的記憶體空間。所以,對於大部分垃圾回收演算法來說避免記憶體碎片化是非常重要的,它將使得空間分配更加高效。

垃圾收集器的理想特徵

安全和全面:活的物件一定不能被清理掉,死的物件一定不能在幾個回收週期結束後還在記憶體中。

高效:不能將我們的應用程式掛起太長時間。我們需要在時間、空間、頻次上作出權衡。比如,如果堆記憶體很小,每次垃圾收集就會很快,但是頻次會增加。如果堆記憶體很大,很久才會被填滿,但是每一次回收需要的時間很長。

儘量少的記憶體碎片:每次將垃圾物件釋放以後,這些空間可能分佈在各個地方,最糟糕的情況就是,記憶體中到處都是碎片,在給一個大物件分配空間的時候沒有記憶體可用,實際上記憶體是夠的。消除碎片的方式就是壓縮

可擴充套件性:在多核多執行緒應用中,記憶體分配和垃圾回收都不應該成為可擴充套件性的瓶頸。原文提到的這一點,我的理解是:單執行緒垃圾回收在多核系統中會浪費 CPU 資源,如果我理解錯誤,請指正我。

設計上的權衡

往下看之前,我們需要先分清楚這裡的兩個概念:併發和並行

並行:多個垃圾回收執行緒同時工作,而不是隻有一個垃圾回收執行緒在工作

併發:垃圾回收執行緒和應用程式執行緒同時工作,應用程式不需要掛起

在設計或選擇垃圾回收演算法的時候,我們需要作出以下幾個權衡:

序列 vs 並行

序列收集的情況,即使是多核 CPU,也只有一個核心參與收集。使用並行收集器的話,垃圾收集的工作將分配給多個執行緒在不同的 CPU 上同時進行。並行可以讓收集工作更快,缺點是帶來的複雜性和記憶體碎片問題。

併發 vs Stop-the-world

當 stop-the-world 垃圾收集器工作的時候,應用將完全被掛起。與之相對的,併發收集器在大部分工作中都是併發進行的,也許會有少量的 stop-the-world。

stop-the-world 垃圾收集器比並發收集器簡單很多,因為應用掛起後堆空間不再發生變化,它的缺點是在某些場景下掛起的時間我們是不能接受的(如 web 應用)。

相應的,併發收集器能夠降低掛起時間,但是也更加複雜,因為在收集的過程中,也會有新的垃圾產生,同時,需要有額外的空間用於在垃圾收集過程中應用程式的繼續使用。

壓縮 vs 不壓縮 vs 複製

當垃圾收集器標記出記憶體中哪些是活的,哪些是垃圾物件後,收集器可以進行壓縮,將所有活的物件移到一起,這樣新的記憶體分配就可以在剩餘的空間中進行了。經過壓縮後,分配新物件的記憶體空間是非常簡單快速的。

相對的,不壓縮的收集器只會就地釋放空間,不會移動存活物件。優點就是快速完成垃圾收集,缺點就是潛在的碎片問題。通常,這種情況下,分配物件空間會比較慢比較複雜,比如為新的一個大物件找到合適的空間。

還有一個選擇就是複製收集器,將活的物件複製到另一塊空間中,優點就是原空間被清空了,這樣後續分配物件空間非常迅速,缺點就是需要進行復制操作和佔用額外的空間。

效能指標

以下幾個是評估垃圾收集器效能的一些指標:

吞吐量:應用程式的執行時間佔總時間的百分比,當然是越高越好

垃圾收集開銷:垃圾收集時間佔總時間的百分比(1 - 吞吐量)

停頓時間:垃圾收集過程中導致的應用程式掛起時間

頻次:相對於應用程式來說,垃圾收集的頻次

空間:垃圾收集佔用的記憶體

及時性:一個物件從成為垃圾到該物件空間再次可用的時間

在互動式程式中,通常希望是低延時的,而對於非互動式程式,總執行時間比較重要。實時應用程式既要求每次停頓時間足夠短,也要求總的花費在收集的時間足夠短。在小型個人計算機和嵌入式系統中,則希望佔用更小的空間。

分代收集介紹

當我們使用分代垃圾收集器時,記憶體將被分為不同的代(generation),最常見的就是分為年輕代老年代

在不同的分代中,可以根據不同的特點使用不同的演算法。分代垃圾收集基於 weak generational hypothesis 假設(通常國人會翻譯成 弱分代假設):

大部分物件都是短命的,它們在年輕的時候就會死去

極少老年物件對年輕物件的引用

年輕代中的收集是非常頻繁的、高效的、快速的,因為年輕代空間中,通常都是小物件,同時有非常多的不再被引用的物件。

那些經歷過多次年輕代垃圾收集還存活的物件會晉升到老年代中,老年代的空間更大,而且佔用空間增長比較慢。這樣,老年代的垃圾收集是不頻繁的,但是進行一次垃圾收集需要的時間更長。

對於新生代,需要選擇速度比較快的垃圾回收演算法,因為新生代的垃圾回收是頻繁的。

對於老年代,需要考慮的是空間,因為老年代佔用了大部分堆記憶體,而且針對該部分的垃圾回收演算法,需要考慮到這個區域的垃圾密度比較低

J2SE 5.0 HotSpot JVM 中的垃圾收集器

J2SE 5.0 HotSpot 虛擬機器包含四種垃圾收集器,都是採用分代演算法。包括序列收集器並行收集器並行壓縮收集器CMS 垃圾收集器

HotSpot 分代

在 HotSpot 虛擬機器中,記憶體被組織成三個分代:年輕代、老年代、永久代。

大部分物件初始化的時候都是在年輕代中的。

老年代存放經過了幾次年輕代垃圾收集依然還活著的物件,還有部分大物件因為比較大所以分配的時候直接在老年代分配。

如 -XX:PretenureSizeThreshold=1024,這樣大於 1k 的物件就會直接分配在老年代

永久代,通常也叫 方法區,用於儲存已載入類的後設資料,以及儲存執行時常量池等。

垃圾回收型別

當年輕代被填滿後,會進行一次年輕代垃圾收集(也叫做 minor GC)。

下面這兩段我也沒有完全弄明白,弄明白會更新。至少讀者要明白一點,"minor gc 收集年輕代,full gc 收集老年代" 這句話是錯的。

當老年代或永久代被填滿了,會觸發 full GC(也叫做 major GC),full GC 會收集所有區域,先進行年輕代的收集,使用年輕代專用的垃圾回收演算法,然後使用老年代的垃圾回收演算法回收老年代和永久代。如果演算法帶有壓縮,每個代分別獨立地進行壓縮。

如果先進行年輕代垃圾收集,會使得老年代不能容納要晉升上來的物件,這種情況下,不會先進行 young gc,所有的收集器都會(除了 CMS)直接採用老年代收集演算法對整個堆進行收集(CMS 收集器比較特殊,因為它不能收集年輕代的垃圾)。

基於統計,計算出每次年輕代晉升到老年代的平均大小,if (老年代剩餘空間 < 平均大小) 觸發 full gc。

快速分配

如果垃圾收集完成後,存在大片連續的記憶體可用於分配給新物件,這種情況下分配空間是非常簡單快速的,只要一個簡單的指標碰撞就可以了(bump-the-pointer),每次分配物件空間只要檢測一下是否有足夠的空間,如果有,指標往前移動 N 位就分配好空間了,然後就可以初始化這個物件了。

對於多執行緒應用,物件分配必須要保證執行緒安全性,如果使用全域性鎖,那麼分配空間將成為瓶頸並降低程式效能。HotSpot 使用了稱之為 Thread-Local Allocation Buffers (TLABs) 的技術,該技術能改善多執行緒空間分配的吞吐量。首先,給予每個執行緒一部分記憶體作為快取區,每個執行緒都在自己的快取區中進行指標碰撞,這樣就不用獲取全域性鎖了。只有當一個執行緒使用完了它的 TLAB,它才需要使用同步來獲取一個新的緩衝區。HotSpot 使用了多項技術來降低 TLAB 對於記憶體的浪費。比如,TLAB 的平均大小被限制在 Eden 區大小的 1% 之內。TLABs 和使用指標碰撞的線性分配結合,使得記憶體分配非常簡單高效,只需要大概 10 條機器指令就可以完成。

序列收集器

使用序列收集器,年輕代和老年代都使用單執行緒進行收集(使用一個 CPU),收集過程中會 stop-the-world。所以當在垃圾收集的時候,應用程式是完全停止的。

在年輕代中使用序列收集器

下圖展示了年輕代中使用序列收集器的流程。

HotSpot JVM 記憶體管理

年輕代分為一個 Eden 區和兩個 Survivor 區(From 區和 To 區)。年輕代垃圾收集時,將 Eden 中活著的物件複製到空的 Survivor-To 區,Survivor-From 區的物件分兩類,一類是年輕的,也是複製到 Survivor-To 區,還有一類是老傢伙,晉升到老年代中。

Survivor-From 和 Survivor-To 是我瞎取的名字。。。

如果複製的過程中,發現 Survivor-To 空間滿了,將剩下還沒複製到 Survivor-To 的來自於 Eden 和 Survivor-From 區的物件直接晉升到老年代。

年輕代垃圾收集完成後,Eden 區和 Survivor-From 就乾淨了,此時,將 Survivor-From 和 Survivor-To 交換一下角色。得到下面這個樣子:

HotSpot JVM 記憶體管理

在老年代中使用序列收集器

如果使用序列收集器,在老年代和永久代將通過使用 標記 -> 清除 -> 壓縮 演算法。標記階段,收集器識別出哪些物件是活的;清除階段將遍歷一下老年代和永久代,識別出哪些是垃圾;然後執行壓縮,將活的物件左移到老年代的起始端(永久代類似),這樣就留下了右邊一片連續可用的空間,後續就可以通過指標碰撞的方式快速分配物件空間。

HotSpot JVM 記憶體管理

何時應該使用序列收集器

序列收集器適用於執行在 client 模式下的大部分程式,它們不要求低延時。在現代硬體條件下,序列收集器可以高效管理 64M 堆記憶體,並且能將 full GC 控制在半秒內完成。

使用序列收集器

它是 J2SE 5.0 版本 HotSpot 虛擬機器在非伺服器級別硬體的預設選擇。你也可以使用 -XX:+UseSerialGC 來強制使用序列收集器。

並行收集器

現在大多數 Java 應用都執行在大記憶體、多核環境中,並行收集器,也就是大家熟知的吞吐量收集器,利用多核的優勢來進行垃圾收集,而不是像序列收集器一樣將程式掛起後只使用單執行緒來收集垃圾。

在年輕代中使用並行收集器

並行收集器在年輕代中其實就是序列收集器收集演算法的並行版本。它仍然使用 stop-the-world 和複製演算法,只不過使用了多核的優勢並行執行,降低垃圾收集的時間,從而提高吞吐量。下圖示意了在年輕代中,序列收集器和並行收集器的區別:

HotSpot JVM 記憶體管理

在老年代中使用並行收集器

在老年代中,並行收集器使用的是和序列收集器一樣的演算法:單執行緒,標記 -> 清除 -> 壓縮

是的,並行收集器只能在年輕代中並行

何時使用並行收集器

其適用於多核、不要求低停頓的應用,因為老年代的收集雖然不頻繁,但是每次老年代的單執行緒垃圾收集依然可能會需要很長時間。比如說,它可以應用在批處理、賬單計算、科學計算等。

你應該不會想要這個收集器,而是要一個可以對每個代都採用並行收集的並行壓縮收集器,下一節將介紹這個。

使用並行收集器

前面我們說了,J2SE 5.0 中 client 模式自動選擇使用序列收集器,如果是 server 模式,那麼將自動使用並行收集器。在其他版本中,顯示使用 -XX:+UseParallelGC 可以指定並行收集器。

並行壓縮收集器

並行壓縮收集器於 J2SE 5.0 update 6 引入,和並行收集器的區別在於它在老年代也使用並行收集演算法。注意:並行壓縮收集器終將會取代並行收集器。

在年輕代中使用並行壓縮收集器

並行壓縮收集器在年輕代中使用了和並行收集器一樣的演算法。即使用 並行、stop-the-world、複製 演算法。

在老年代中使用並行壓縮收集器

在老年代和永久代中,其使用 並行、stop-the-world、滑動壓縮 演算法。

一次收集分三個階段,首先,將老年代或永久代邏輯上分為固定大小的區塊。

標記階段,將 GC Roots 分給多個垃圾收集執行緒,每個執行緒並行地去標記存活的物件,一旦標記一個存活物件,在該物件所在的區塊記錄這個物件的大小和物件所在的位置。

彙總階段,此階段針對區塊進行。由於之前的垃圾回收影響,老年代和永久代的左側是 存活物件密集區,對這部分割槽域直接進行壓縮的代價是不值得的,能清理出來的空間有限。所以第一件事就是,檢查每個區塊的密度,從左邊第一個開始,直到找到一個區塊滿足:對右側的所有區塊進行壓縮獲得的空間抵得上壓縮它們的成本。這個區塊左邊的區域過於密集,不會有物件移動到這個區域中。然後,計算並儲存右側區域中每個區塊被壓縮後的新位置首位元組地址。

右側的區域將被壓縮,對於右側的每個區塊,由於每個區塊中儲存了該區塊的存活物件資訊,所以很容易計算每個區塊的新位置。注意:彙總階段目前被實現為序列進行,這個階段修改為並行也是可行的,不過沒有在標記階段和下面的壓縮階段並行那麼重要。

壓縮階段,在彙總階段已經完成了每個區塊新位置的計算,所以壓縮階段每個回收執行緒並行將每個區塊複製到新位置即可。壓縮結束後,就清出來了右側一大片連續可用的空間。

何時使用並行壓縮收集器

首先是多核上的並行優勢,這個就不重複了。其次,前面的並行收集器對於老年代和永久代使用序列,而並行壓縮收集器在這些區域使用並行,能降低停頓時間。

並行壓縮收集器不適合執行在大型共享主機上(如 SunRays),因為它在收集的時候會獨佔幾個 CPU,在這種機器上,可以考慮減少垃圾收集的執行緒數(通過 –XX:ParallelGCThreads=n),或者就選擇其他收集器。

使用並行壓縮收集器

顯示指定:-XX:+UseParallelOldGC

Concurrent Mark-Sweep(CMS)收集器

重頭戲 CMS 登場了,至少對於我這個 web 開發者來說,目前 CMS 最常用(使用 JDK8 的應用一般都切換到 G1 收集器了)。前面介紹的都是並行收集,這裡要介紹併發收集了,也就是垃圾回收執行緒和應用程式執行緒同時執行。

對於許多程式來說,吞吐量不如響應時間來得重要。通常年輕代的垃圾收集不會停頓多長時間,但是,老年代垃圾回收,雖然不頻繁,但是可能導致長時間的停頓,尤其當堆記憶體比較大的時候。為了解決這個問題,HotSpot 虛擬機器提供了 CMS 收集器,也叫做 低延時收集器

在年輕代中使用 CMS 收集器

在年輕代中,CMS 和 並行收集器 一樣,即:並行、stop-the-world、複製

在老年代中使用 CMS 收集器

在老年代的垃圾收集過程中,大部分收集任務是和應用程式併發執行的。

CMS 收集過程首先是一段小停頓 stop-the-world,叫做 初始標記階段(initial mark),用於確定 GC Roots。然後是 併發標記階段(concurrent mark),標記 GC Roots 可達的所有存活物件,由於這個階段應用程式同時也在執行,所以併發標記階段結束後,並不能標記出所有的存活物件。為了解決這個問題,需要再次停頓應用程式,稱為 再次標記階段(remark),遍歷在併發標記階段應用程式修改的物件(標記出應用程式在這個期間的活物件),由於這次停頓比初始標記要長得多,所以會使用多執行緒並行執行來增加效率

再次標記階段結束後,能保證所有存活物件都被標記完成,所以接下來的 併發清理階段(concurrent sweep) 將就地回收垃圾物件所佔空間。下圖示意了老年代中 序列、標記 -> 清理 -> 壓縮收集器和 CMS 收集器的區別:

HotSpot JVM 記憶體管理

由於部分任務增加了收集器的工作,如遍歷併發階段應用程式修改的物件,所以增加了 CMS 收集器的負載。對於大部分試圖降低停頓時間的收集器來說,這是一種權衡方案。

CMS 收集器是唯一不進行壓縮的收集器,在它釋放了垃圾物件佔用的空間後,它不會移動存活物件到一邊去。

HotSpot JVM 記憶體管理

這將節省垃圾回收的時間,但是由於之後空閒空間不是連續的,所以也就不能使用簡單的 指標碰撞(bump-the-pointer)進行物件空間分配了。它需要維護一個 空閒列表,將所有的空閒區域連線起來,當分配空間時,需要尋找到一個可以容納該物件的區域。顯然,它比使用簡單的指標碰撞成本要高。同時它也會加大年輕代垃圾收集的負載,因為年輕代中的物件如果要晉升到老年代中,需要老年代進行空間分配。

另外一個缺點就是,CMS 收集器相比其他收集器需要使用更大的堆記憶體。因為在併發標記階段,程式還需要執行,所以需要留足夠的空間給應用程式。另外,雖然收集器能保證在標記階段識別出所有的存活物件,但是由於應用程式併發執行,所以剛剛標記的存活物件很可能立馬成為垃圾,而且這部分由於已經被標記為存活物件,所以只能到下次老年代收集才會被清理,這部分垃圾稱為 浮動垃圾

最後,由於缺少壓縮環節,堆將會出現碎片化問題。為了解決這個問題,CMS 收集器需要追蹤統計最常用的物件大小,評估將來的分配需求,可能還需要分割或合併空閒區域。

不像其他垃圾收集器,CMS 收集器不能等到老年代滿了才開始收集。否則的話,CMS 收集器將退化到使用更加耗時的 stop-the-world、標記-清除-壓縮 演算法。為了避免這個,CMS 收集器需要統計之前每次垃圾收集的時間和老年代空間被消耗的速度。另外,如果老年代空間被消耗了 預設佔用率(initiating occupancy),也將會觸發一次垃圾收集,這個佔用率通過 –XX:CMSInitiatingOccupancyFraction=n 進行設定,n 為老年代空間的佔用百分比,預設值是 68

這個數字到 Java8 的時候已經變為預設 92 了。如果老年代空間不足以容納從新生代垃圾回收晉升上來的物件,那麼就會發生 concurrent mode failure,此時會退化到發生 Full GC,清除老年代中的所有無效物件,這個過程是單執行緒的,比較耗時

另外,即使在晉升的時候判斷出老年代有足夠的空間,但是由於老年代的碎片化問題,其實最終沒法容納晉升上來的物件,那麼此時也會發生 Full GC,這次的耗時將更加嚴重,因為需要對整個堆進行壓縮,壓縮後年輕代徹底就空了。

總結下來,和並行收集器相比,CMS 收集器降低了老年代收集時的停頓時間(有時是顯著降低),稍微增加了一些年輕代收集的時間降低了吞吐量 以及 需要更多的堆記憶體

增量模式

CMS 收集器可以使用增量模式,在併發標記階段,週期性地將自己的 CPU 時鐘週期讓出來給應用程式。這個功能適用於需要 CMS 的低延時,但是 CPU 核心只有 1 個或 2 個的情況。

增量模式在 Java8 已經不推薦使用。

目前我瞭解到的是,在所有的併發或並行收集器中,都提供了控制垃圾收集執行緒數量的引數設定。

何時使用 CMS 收集器

適用於應用程式要求低停頓,同時能接受在垃圾收集階段和垃圾收集執行緒一起共享 CPU 資源的場景,典型的就是 web 應用了。

在 web 應用中,低延時非常重要,所以 CMS 幾乎就是唯一選擇,直到後來 G1 的出現。

使用 CMS 收集器

顯示指定:-XX:+UseConcMarkSweepGC

如果需要增量模式:–XX:+CMSIncrementalModeoption

當然,CMS 還有好些引數可以設定,這裡就不展開了,想要了解更多 CMS 細節,建議讀者可以參考《Java 效能權威指南》,非常不錯的一本書。

小結

雖然是翻譯的文章,也小結一下吧。

序列收集器:在年輕代和老年代都採用單執行緒,年輕代中使用 stop-the-world、複製 演算法;老年代使用 stop-the-world、標記 -> 清理 -> 壓縮 演算法。

並行收集器:在年輕代中使用 並行、stop-the-world、複製 演算法;老年代使用序列收集器的 序列、stop-the-world、標記 -> 清理 -> 壓縮 演算法。

並行壓縮收集器:在年輕代中使用並行收集器的 並行、stop-the-world、複製 演算法;老年代使用 並行、stop-the-world、標記 -> 清理 -> 壓縮 演算法。和並行收集器的區別是老年代使用了並行。

CMS 收集器:在年輕使用並行收集器的 並行、stop-the-world、複製 演算法;老年代使用 併發、標記 -> 清理 演算法,不壓縮。本文介紹的唯一一個併發收集器,也是唯一一個不對老年代進行壓縮的收集器。

另外,在 HotSpot 中,永久代使用的是和老年代一樣的演算法。到了 J2SE 8.0 的 HotSpot JVM 中,永久代被 MetaSpace 取代了,這個以後再介紹。

(全文完)


相關文章