HotSpot JVM 內存管理

關於 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),垃圾收集啟動的時機取決於各個垃圾收集器,通常,垃圾收集發生於整個堆或堆的部分已經被使用光了,或者使用的空間達到了某個百分比閾值。這些後面都會具體說,這裡的每一句話都是對應了某些場景的。

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

垃圾收集器的理想特徵

  1. 安全和全面:活的對象一定不能被清理掉,死的對象一定不能在幾個回收週期結束後還在內存中。
  2. 高效:不能將我們的應用程序掛起太長時間。我們需要在時間、空間、頻次上作出權衡。比如,如果堆內存很小,每次垃圾收集就會很快,但是頻次會增加。如果堆內存很大,很久才會被填滿,但是每一次回收需要的時間很長。
  3. 儘量少的內存碎片:每次將垃圾對象釋放以後,這些空間可能分佈在各個地方,最糟糕的情況就是,內存中到處都是碎片,在給一個大對象分配空間的時候沒有內存可用,實際上內存是夠的。消除碎片的方式就是壓縮
  4. 可擴展性:在多核多線程應用中,內存分配和垃圾回收都不應該成為可擴展性的瓶頸。
    原文提到的這一點,我的理解是:單線程垃圾回收在多核系統中會浪費 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 內存管理

3

年輕代分為一個 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 內存管理

4

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

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

HotSpot JVM 內存管理

5

何時應該使用串行收集器

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

使用串行收集器

它是 J2SE 5.0 版本 HotSpot 虛擬機在非服務器級別硬件的默認選擇。你也可以使用 -XX:+UseSerialGC 來強制使用串行收集器。

並行收集器

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

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

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

HotSpot JVM 內存管理

6

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

在老年代中,並行收集器使用的是和串行收集器一樣的算法:

單線程,標記 -> 清除 -> 壓縮

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

何時使用並行收集器

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

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

使用並行收集器

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

並行壓縮收集器

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

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

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

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

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

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

  1. 標記階段,將 GC Roots 分給多個垃圾收集線程,每個線程並行地去標記存活的對象,一旦標記一個存活對象,在該對象所在的區塊記錄這個對象的大小和對象所在的位置。
  2. 彙總階段,此階段針對區塊進行。由於之前的垃圾回收影響,老年代和永久代的左側是
    存活對象密集區,對這部分區域直接進行壓縮的代價是不值得的,能清理出來的空間有限。所以第一件事就是,檢查每個區塊的密度,從左邊第一個開始,直到找到一個區塊滿足:對右側的所有區塊進行壓縮獲得的空間抵得上壓縮它們的成本。這個區塊左邊的區域過於密集,不會有對象移動到這個區域中。然後,計算並保存右側區域中每個區塊被壓縮後的新位置首字節地址。 右側的區域將被壓縮,對於右側的每個區塊,由於每個區塊中保存了該區塊的存活對象信息,所以很容易計算每個區塊的新位置。注意:彙總階段目前被實現為串行進行,這個階段修改為並行也是可行的,不過沒有在標記階段和下面的壓縮階段並行那麼重要。
  3. 壓縮階段,在彙總階段已經完成了每個區塊新位置的計算,所以壓縮階段每個回收線程並行將每個區塊複製到新位置即可。壓縮結束後,就清出來了右側一大片連續可用的空間。

何時使用並行壓縮收集器

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

並行壓縮收集器不適合運行在大型共享主機上(如 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 內存管理

7

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

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

HotSpot JVM 內存管理

8

這將節省垃圾回收的時間,但是由於之後空閒空間不是連續的,所以也就不能使用簡單的

指針碰撞(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 取代了,這個以後再介紹。

(全文完)


分享到:


相關文章: