HotSpot JVM內存管理

關於 JVM 內存管理或者說垃圾收集,你們可能看過不少的文章了,筆者準備給你們總結下。這算是系列的第一篇,接下來一段時間會持續更新。java

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

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

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

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

垃圾收集概念

GC 須要作 3 件事情:服務器

  • 分配內存,爲每一個新建的對象分配空間
  • 確保還在使用的對象的內存一直還在,不能把有用的空間當垃圾回收了
  • 釋放再也不使用的對象所佔用的空間

咱們把還被 GC Roots 引用的對象稱爲活的,把再也不被引用的對象認爲是死的,也就是咱們說的垃圾,GC 的工做就是找到死的對象,回收它們佔用的空間。多線程

在這裏,咱們總結一下 GC Roots 有哪些:併發

  • 當前各線程執行方法中的局部變量(包括形參)引用的對象
  • 已被加載的類的 static 域引用的對象
  • 方法區中常量引用的對象
  • JNI 引用

以上不徹底,不過我以爲了解到這些就夠了,瞭解更多oracle

咱們把 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。因此當在垃圾收集的時候,應用程序是徹底中止的。

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

下圖展現了年輕代中使用串行收集器的流程。

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 交換一下角色。獲得下面這個樣子:

4

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

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

5

什麼時候應該使用串行收集器

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

使用串行收集器

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

並行收集器

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

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

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

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 收集器的區別:

7

因爲部分任務增長了收集器的工做,如遍歷併發階段應用程序修改的對象,因此增長了 CMS 收集器的負載。對於大部分試圖下降停頓時間的收集器來講,這是一種權衡方案。

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

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 取代了,這個之後再介紹。

(全文完)

相關文章
相關標籤/搜索