1. 程式人生 > >Java虛擬機器垃圾回收相關知識點全梳理(下)

Java虛擬機器垃圾回收相關知識點全梳理(下)

一、前言

上一篇文章《Java虛擬機器垃圾回收相關知識點全梳理(上)》我整理分享了JVM執行時資料區域的劃分,垃圾判定演算法以及垃圾回收演算法,各種演算法的適用場景。今天,我整理分享下JVM效能的度量指標,垃圾收集器的分類,最後分享一下JVM的調優建議。

二、效能度量指標

  • 吞吐量:表示系統減去系統回收時間佔總時間的比率,比如系統運行了100秒,垃圾回收佔用了1秒,那麼吞吐量量就是(100-1)/100=99%。

  • 垃圾回收消耗:和吞吐量相反,垃圾回收器消耗指垃圾回收器耗時與系統執行總時間的比值。

  • 停頓時間:指垃圾回收器執行時,系統停頓的時間。

  • 回收頻率:指垃圾回收器多長時間會執行一次。一般來說,對於固定的應用而言,垃圾回收器的頻率應該是越低越好。通常增大堆空間可以有效降低垃圾回收發生的頻率,但是可能會增加回收產生的停頓時間。

  • 反應時間:當一個記憶體物件被標記為垃圾物件後到這個物件被真正回收產生的時間。

根據這幾個指標,我們可以知道,垃圾回收效能好的表現是:吞吐量高,垃圾回收消耗低,停頓時間少,回收頻率低,反應時間快。但是,並沒有這麼完美的效能表現,這幾個指標有些是互斥的,比如要降低迴收頻率,就要擴大空間,但是就會增加停頓時間;同樣要想反應時間快,就必須要提高回收頻率。所以,這些效能的追求就是一個博弈平衡的過程,我們可以根據我們追求的某一方面來進行調優,比如,對於客戶端應用而言,應該儘可能降低其停頓時間,給使用者良好的使用體驗,為此,可以犧牲垃圾回收的吞吐量;對服務端程式來說,可能會更加關注吞吐量。

三、垃圾回收器

3.1 Serial 收集器

Serial 收集器是所有垃圾收集器中最古老的一種,也是JDK中最基本的垃圾收集器之一。Serial回收器主要有兩個特點:第一:使用單執行緒進行垃圾回收;第二:獨佔式垃圾回收。

在序列收集器進行垃圾回收時,Java應用程式中的執行緒都需要暫停,等待垃圾回收完成。這種現象成為Stop-The-World。它將造成非常糟糕的使用者體驗,在實時性要求較高的應用場景中,這種現象往往是不能被接受的,但是它依然是在Client模式下預設的新生代收集器。在單核CPU環境下,由於沒有執行緒間的切換,它甚至比並發收集器的效能都要好。(以下圖片來源於網路)

圖片來源於網路

3.2 ParNew 收集器

ParNew 收集器是Serial 收集器的多執行緒版本。它的回收策略、演算法以及引數和序列回收器一樣。它是許多Server模式下新生代首選的收集器,除了他的多執行緒回收功能外,還有一點的就是隻有他能與CMS收集器配合工作。開啟ParNew 收集器可以使用以下引數:

-XX:+UseParNewGC:新生代使用並行收集器,老年代使用序列回收器。

-XX:+UseConcMarkSweepGC:新生代使用並行回收器,老年代使用CMS。

並行收集器工作時的執行緒數量可以使用 -XX:ParallelGCThreads 引數指定。一般最好與CPU數量相當,避免過多的執行緒數,影響垃圾收集效能。 在預設情況下,當CPU數量小於8個時,ParallelGCThreads 的值等於 CPU 數量;當 CPU 數量大於8個時,ParallelGCThreads 的值等於 3+[(5*CPU_Count)/8]

圖片來源於網路

3.3 Parallel Scavenge 收集器

Parallel Scavenge 收集器是新生代收集器,它是使用複製演算法的收集器,同時也是多執行緒收集器。它和其他併發收集器不同的點是,Parallel Scavenge 收集器 關注吞吐量,其他的並行收集器關注的是降低停頓時間。 開啟Parallel Scavenge 收集器可以使用以下引數:

-XX:+UseParallelGC:新生代使用並行回收收集器,老年代使用序列回收器。

-XX:+UseParallelOldGC:新生代與老年代都使用並行回收收集器。

並行回收收集器提供了兩個重要的引數用於控制系統的吞吐量:

-XX:+MaxGCPauseMills:設定最大垃圾收集停頓時間,它的值是一個大於 0 的整數。收集器在工作時會調整 Java 堆大小或者其他一些引數,儘可能地把停頓時間控制在 MaxGCPauseMills 以內。這裡需要注意的是如果希望減少停頓時間,而把這個值設定得非常小,虛擬機器為了達到預期的停頓時間,JVM 可能會使用一個較小的堆 (一個小堆比一個大堆回收快),而這將導致垃圾回收變得很頻繁,從而增加了垃圾回收總時間,降低了吞吐量。

-XX:+GCTimeRatio:設定吞吐量大小,它的值是一個 0-100 之間的整數。假設 GCTimeRatio 的值為 n,那麼系統將花費不超過 1/(1+n) 的時間用於垃圾收集。比如 GCTimeRatio 等於 19,則系統用於垃圾收集的時間不超過 1/(1+19)=5%。預設情況下,它的取值是 99,即不超過 1%的時間用於垃圾收集。

除此之外,Parallel Scavenge 收集器與ParNew 收集器另一個不同之處在於,前者支援一種自適應的 GC 調節策略,使用-XX:+UseAdaptiveSizePolicy 可以開啟自適應 GC 策略。在這種模式下,新生代的大小、eden 和 survivor 的比例、晉升老年代的物件年齡等引數會被自動調整,以達到在堆大小、吞吐量和停頓時間之間的平衡點。在手工調優比較困難的場合,可以直接使用這種自適應的方式,僅指定虛擬機器的最大堆、目標的吞吐量 (GCTimeRatio) 和停頓時間 (MaxGCPauseMills),讓虛擬機器自己完成調優工作。

3.4 Serial Old 收集器

Serial Old 收集器是Serial收集器的老年代版本,從名字我們就可以知道,它是一個單執行緒收集器,使用“標記-整理”演算法。該虛擬機器的主要使用場景是在Client模式下使用。它是CMS收集器的後備方案,當CMS收集器進行收集的時候,發生了Concurrent Mode Failure時,會觸發使用Serial Old 收集器進行Full GC,此時會帶來長時間的STW,進而影響系統響應,這也是CMS收集器的一個缺點。

圖片來源於網路

3.5 Parallel Old 收集器

Parallel Old 收集器也是一種多執行緒併發的收集器。和Parallel Scavenge 收集器一樣,它也是一種關注吞吐量的收集器。Parallel Old 收集器使用標記-壓縮演算法。

圖片來源於網路

3.6 CMS(Concurrent Mark Sweep) 收集器

CMS 收集器是一個以獲取最大回收停頓時間為目標的收集器,CMS垃圾回收的過程主要分為5步:初始標記、併發標記、重新標記、併發清除和併發重置。其中初始標記和重新標記是需要進行“Stop The World”,而併發標記、併發清除和併發重置是可以和使用者執行緒一起執行的。因此,從整體上來說,CMS 收集不是獨佔式的,它可以在應用程式執行過程中進行垃圾回收 。CMS收集器也有三大缺點:

  • 對CPU資源比較敏感,在併發階段,雖然不會導致使用者執行緒停頓,但是還是會佔用部分CPU資源,從而導致程式變慢,吞吐量下降。
  • CMS無法處理浮動垃圾,因為CMS進行垃圾收集是和使用者執行緒一起執行的,所以在收集的過程中就會產生垃圾,這部分垃圾就被稱為浮動垃圾,浮動垃圾只能等待下一次垃圾收集期間進行收集。因為垃圾收集過程與使用者執行緒一起執行,所以收集過程中還是要預留空間給使用者執行緒使用,如果空間不夠,就會出現“Concurrent Mode Failure” 失敗,接著就會出現備選方案的Serial Old收集器進行Full Gc,會進行長時間的停頓,進而影響效能。
  • CMS收集器是“標記-清除”演算法的收集器,所以在垃圾收集過後會帶來大量的記憶體碎片,CMS提供了一種記憶體壓縮引數+XX:+UseCMSCompactAtFullCollection(預設是開啟的)開啟後CMS會在進行Full GC 的時候進行記憶體整理,+XX:CMSFullGCsBeforeCompaction可以設定執行多少次不壓縮記憶體後再進行壓縮的Full GC。

來源於網路

3.7 G1(Garbage-First) 收集器

G1收集器是一款面向服務端的垃圾收集器,在jdk1.7後可以正式使用,可以通過命令-XX:+UnlockExperimentalVMOptions –XX:+UseG1G來啟用G1收集器。G1收集器採用的是“標記-整理”演算法,它也是一個進行可以預測停頓時間的垃圾收集器。可以通過引數設定停頓時間:

-XX:MaxGCPauseMills = 20

-XX:GCPauseIntervalMills = 200。

以上引數指定在200ms內,停頓時間不超過20ms。這兩個引數是G1回收器的目標,G1回收器並不保證能執行它們。 G1收集器的區域分佈如下圖所示:

圖片來源於網路

在G1中把java堆分成了多個大小相等的獨立區域(Region),雖然保留了新生代和老年代的概念,但是他們都不是物理隔離的,只是邏輯上還有區分。

G1收集器進行垃圾收集分為4個階段,初始標記,併發標記,最終標記,篩選回收。初始標記需要停頓使用者執行緒,但是時間很短;併發標記是從GC Roots對堆中的物件進行可達性分析,這個階段比較耗時,但是可以與使用者執行緒併發執行;最終標記是修正在併發標記中產生的變動;篩選回收就是對標記好的垃圾物件進行價值和成本排序,根據使用者設定的期望來進行回收(比如我們上面設定的200ms停頓時間不超過20ms)。

3.8 ZGC(Z Garbage Collector) 收集器

ZGC 被稱為“一個可伸縮低延遲的垃圾回收器”,這個垃圾回收器有什麼神奇之處呢?它的主要特點就是能把回收時間控制在10ms以內,而且不受堆大小的影響,所以它可以支援TB級別的垃圾回收。

ZGC也是和G1收集器一樣,並沒有進行分代,而是把整個記憶體分成了多個region,官方後續會嘗試採用分代的設計,目前完全因為是不分代這是最簡單的設計。一次完整的 ZGC 回收週期分為以下幾個階段(Phase):

  • Pause Mark Start:標記根物件;

  • Concurrent Mark:併發標記階段;

  • Concurrent Relocate:併發重定位;

    • 活動物件被移動到了一個新的 Heap Region B-region 中,之前舊物件所在的 Heap Region A-region 即可複用;如果 B-region 中物件之間的引用關係將會在這一階段被更新;
    • 在重定位過程中,新舊物件的對映關係(同一物件在不同 Region 中的對映關係)被記錄在了 Forwarding Tables 中。
  • Pause Mark Start:這個階段實際上已經進入了新的 ZGC Cycle,同樣也是標記根物件;

  • Concurrent Remap:併發重對映。 這個階段除了標記根物件直接引用的物件外,還會根據上個 ZGC Cycle 中生成的 Forwarding Tables 更新跨 Heap Region 的引用;

ZGC還是有停頓的,在Pause Mark Start 階段進行根物件掃描(Root Scanning)時會出現短暫的暫停。 流程示意圖如下(圖片來源於網路)

四、一些JVM調優建議

4.1將新物件預留在年輕代

眾所周知,由於 Full GC 的成本遠遠高於 Minor GC,因此某些情況下需要儘可能將物件分配在年輕代,這在很多情況下是一個明智的選擇。雖然在大部分情況下,JVM 會嘗試在 Eden 區分配物件,但是由於空間緊張等問題,很可能不得不將部分年輕物件提前向年老代壓縮。因此,在 JVM 引數調優時可以為應用程式分配一個合理的年輕代空間,以最大限度避免新物件直接進入年老代的情況發生。這裡實際上是為了避免“朝生夕滅”的大物件發生,儘可能的把設定合理新生代空間,把“朝生夕滅 ”物件留在新生代中。

4.2 將大物件直接分配再老年代

我們分配物件一般都是分配在年輕代,分配大物件在年輕代,需要年輕代提供足夠的空間,這個時候會導致原有的大量小物件進入老年代,佔用老年代空間。基於以上原因,可以將大物件直接分配到年老代,從而保留為年輕代保留了空間,保證了年輕代原有的目的,這樣也可以提高 GC 的效率。如果一個大物件同時又是一個短命的物件,假設這種情況出現很頻繁,那對於 GC 來說會是一場災難。原本應該用於存放永久物件的年老代,被短命的物件塞滿,這也意味著對堆空間進行了洗牌,擾亂了分代記憶體回收的基本思路。因此,在軟體開發過程中,應該儘可能避免使用“朝生夕滅”這樣短命的大物件。可以使用引數-XX:PetenureSizeThreshold 設定大物件直接進入年老代的閾值。當物件的大小超過這個值時,將直接在年老代分配。引數-XX:PetenureSizeThreshold 只對序列收集器和年輕代並行收集器有效,並行回收收集器不識別這個引數。

4.3 設定物件進入老年代的年齡

堆中的每一個物件都有自己的年齡。一般情況下,年輕物件存放在年輕代,老年物件存放在老年代。為了做到這點,虛擬機器為每個物件都維護一個年齡。如果物件在 Eden 區,經過一次 GC 後依然存活,則被移動到 Survivor 區中,物件年齡加 1。以後,如果物件每經過一次 GC 依然存活,則年齡再加 1。當物件年齡達到閾值時,就移入老年代,成為老年物件。那麼設定一個合適的老年代的年齡就有利於提升系統性能,可以通過-XX:MaxTenuringThreshold 來設定,預設值是 15。雖然-XX:MaxTenuringThreshold 的值可能是 15 或者更大,但這不意味著新物件非要達到這個年齡才能進入老年代。如果在Survivor空間中相同年齡所有物件的大小總和大於Survivor空間的一半,年齡大於或等於該年齡的物件就可以直接進入老年代。

4.4 穩定的堆與震盪的堆

一般來說,穩定的堆大小對垃圾回收是有利的。獲得一個穩定的堆大小的方法是使-Xms 和-Xmx 的大小一致,即最大堆和最小堆 (初始堆) 一樣。如果這樣設定,系統在執行時堆大小理論上是恆定的,穩定的堆空間可以減少 GC 的次數。因此,很多服務端應用都會將最大堆和最小堆設定為相同的數值。穩定的堆大小雖然可以減少 GC 次數,但同時也增加了每次 GC 的時間。讓堆大小在一個區間中震盪,在系統不需要使用大記憶體時,壓縮堆空間,使 GC 應對一個較小的堆,可以加快單次 GC 的速度。基於這樣的考慮,JVM 還提供了兩個引數用於壓縮和擴充套件堆空間。

XX:MinHeapFreeRatio: 設定堆的最小空閒比例,預設是40,當堆空間的空閒空間小於這個數值時,jvm會自動擴充套件空間。

-XX:MaxHeapFreeRatio: 設定堆的最大空閒比例,預設是70,當堆空間的空閒空間大於這個數值時,jvm會自動壓縮空間。

當-Xmx 和-Xms 相等時,-XX:MinHeapFreeRatio 和-XX:MaxHeapFreeRatio 兩個引數無效。

4.5 嘗試使用大的記憶體分頁

CPU 是通過定址來訪問記憶體的。32 位 CPU 的定址寬度是 0~0xFFFFFFFF ,計算後得到的大小是 4G,也就是說可支援的實體記憶體最大是 4G。但在實踐過程中,碰到了這樣的問題,程式需要使用 4G 記憶體,而可用實體記憶體小於 4G,導致程式不得不降低記憶體佔用。為了解決此類問題,現代 CPU 引入了 MMU(Memory Management Unit 記憶體管理單元)。MMU 的核心思想是利用虛擬地址替代實體地址,即 CPU 定址時使用虛址,由 MMU 負責將虛址對映為實體地址。MMU 的引入,解決了對實體記憶體的限制,對程式來說,就像自己在使用 4G 記憶體一樣。記憶體分頁 (Paging) 是在使用 MMU 的基礎上,提出的一種記憶體管理機制。它將虛擬地址和實體地址按固定大小(4K)分割成頁 (page) 和頁幀 (page frame),並保證頁與頁幀的大小相同。這種機制,從資料結構上,保證了訪問記憶體的高效,並使 OS 能支援非連續性的記憶體分配。在程式記憶體不夠用時,還可以將不常用的實體記憶體頁轉移到其他儲存裝置上,比如磁碟,在windows下,這部分空間叫做虛擬記憶體,Linux下叫做SWAP分割槽。

在 Solaris 系統中,JVM 可以支援 Large Page Size 的使用。使用大的記憶體分頁可以增強 CPU 的記憶體定址能力,從而提升系統的效能。

java –Xmx2506m –Xms2506m –Xmn1536m –Xss128k –XX:++UseParallelGC
 –XX:ParallelGCThreads=20 –XX:+UseParallelOldGC –XX:+LargePageSizeInBytes=256m
–XX:+LargePageSizeInBytes:設定大頁的大小。

過大的記憶體分頁會導致 JVM 在計算 Heap 內部分割槽(perm, new, old)記憶體佔用比例時,會出現超出正常值的劃分,最壞情況下某個區會多佔用一個頁的大小

4.6 根據場景選擇合適的收集器

對於對響應時間不敏感的場景,可以選擇吞吐量優先的收集器來提升效能,比如Parallel Old 收集器。如果是對響應時間要求高的場景,就需要選擇低停頓的垃圾回收器,比如CMS,G1,ZGC(雖然目前還不是非常成熟)。

五、總結

這篇文章內容比較,主要分享了虛擬機器的效能度量指標,垃圾回收器的分類,一些調優建議。最後放一張本文的腦圖進行總結:

六、參考