從實際案例聊聊Java應用的GC優化

當Java程序性能達不到既定目標,且其餘優化手段都已經窮盡時,一般須要調整垃圾回收器來進一步提升性能,稱爲GC優化。但GC算法複雜,影響GC性能的參數衆多,且參數調整又依賴於應用各自的特色,這些因素很大程度上增長了GC優化的難度。即使如此,GC調優也不是無章可循,仍然有一些通用的思考方法。本篇會介紹這些通用的GC優化策略和相關實踐案例,主要包括以下內容:html

優化前準備: 簡單回顧JVM相關知識、介紹GC優化的一些通用策略。
優化方法: 介紹調優的通常流程:明確優化目標→優化→跟蹤優化結果。
優化案例: 簡述筆者所在團隊遇到的GC問題以及優化方案。算法

1、優化前的準備

GC優化需知

爲了更好地理解本篇所介紹的內容,你須要瞭解以下內容。數組

  1. GC相關基礎知識,包括但不限於:
    a) GC工做原理。
    b) 理解新生代、老年代、晉升等術語含義。
    c) 能夠看懂GC日誌。安全

  2. GC優化不能解決一切性能問題,它是最後的調優手段。性能優化

若是對第一點中說起的知識點不是很熟悉,能夠先閱讀小結-JVM基礎回顧;若是已經很熟悉,能夠跳過該節直接往下閱讀。服務器

JVM基礎回顧

JVM內存結構

簡單介紹一下JVM內存結構和常見的垃圾回收器。多線程

當代主流虛擬機(Hotspot VM)的垃圾回收都採用「分代回收」的算法。「分代回收」是基於這樣一個事實:對象的生命週期不一樣,因此針對不一樣生命週期的對象能夠採起不一樣的回收方式,以便提升回收效率。架構

Hotspot VM將內存劃分爲不一樣的物理區,就是「分代」思想的體現。如圖所示,JVM內存主要由新生代、老年代、永久代構成。併發

GC影響

① 新生代(Young Generation):大多數對象在新生代中被建立,其中不少對象的生命週期很短。每次新生代的垃圾回收(又稱Minor GC)後只有少許對象存活,因此選用複製算法,只須要少許的複製成本就能夠完成回收。jvm

新生代內又分三個區:一個Eden區,兩個Survivor區(通常而言),大部分對象在Eden區中生成。當Eden區滿時,還存活的對象將被複制到兩個Survivor區(中的一個)。當這個Survivor區滿時,此區的存活且不知足「晉升」條件的對象將被複制到另一個Survivor區。對象每經歷一次Minor GC,年齡加1,達到「晉升年齡閾值」後,被放到老年代,這個過程也稱爲「晉升」。顯然,「晉升年齡閾值」的大小直接影響着對象在新生代中的停留時間,在Serial和ParNew GC兩種回收器中,「晉升年齡閾值」經過參數MaxTenuringThreshold設定,默認值爲15。

② 老年代(Old Generation):在新生代中經歷了N次垃圾回收後仍然存活的對象,就會被放到年老代,該區域中對象存活率高。老年代的垃圾回收(又稱Major GC)一般使用「標記-清理」或「標記-整理」算法。整堆包括新生代和老年代的垃圾回收稱爲Full GC(HotSpot VM裏,除了CMS以外,其它能收集老年代的GC都會同時收集整個GC堆,包括新生代)。

③ 永久代(Perm Generation):主要存放元數據,例如Class、Method的元信息,與垃圾回收要回收的Java對象關係不大。相對於新生代和年老代來講,該區域的劃分對垃圾回收影響比較小。

常見垃圾回收器

不一樣的垃圾回收器,適用於不一樣的場景。經常使用的垃圾回收器:

  • 串行(Serial)回收器是單線程的一個回收器,簡單、易實現、效率高。
  • 並行(ParNew)回收器是Serial的多線程版,能夠充分的利用CPU資源,減小回收的時間。
  • 吞吐量優先(Parallel Scavenge)回收器,側重於吞吐量的控制。
  • 併發標記清除(CMS,Concurrent Mark Sweep)回收器是一種以獲取最短回收停頓時間爲目標的回收器,該回收器是基於「標記-清除」算法實現的。

GC日誌

每一種回收器的日誌格式都是由其自身的實現決定的,換而言之,每種回收器的日誌格式均可以不同。但虛擬機設計者爲了方便用戶閱讀,將各個回收器的日誌都維持必定的共性。JavaGC日誌 中簡單介紹了這些共性。

參數基本策略

各分區的大小對GC的性能影響很大。如何將各分區調整到合適的大小,分析活躍數據的大小是很好的切入點。

活躍數據的大小是指,應用程序穩定運行時長期存活對象在堆中佔用的空間大小,也就是Full GC後堆中老年代佔用空間的大小。能夠經過GC日誌中Full GC以後老年代數據大小得出,比較準確的方法是在程序穩定後,屢次獲取GC數據,經過取平均值的方式計算活躍數據的大小。活躍數據和各分區之間的比例關係以下(見參考文獻1):

空間 倍數
總大小 3-4 倍活躍數據的大小
新生代 1-1.5 活躍數據的大小
老年代 2-3 倍活躍數據的大小
永久代 1.2-1.5 倍Full GC後的永久代空間佔用

例如,根據GC日誌得到老年代的活躍數據大小爲300M,那麼各分區大小能夠設爲:

總堆:1200MB = 300MB × 4
新生代:450MB = 300MB × 1.5

老年代: 750MB = 1200MB - 450MB*

這部分設置僅僅是堆大小的初始值,後面的優化中,可能會調整這些值,具體狀況取決於應用程序的特性和需求。

2、優化步驟

GC優化通常步驟能夠歸納爲:肯定目標、優化參數、驗收結果。

肯定目標

明確應用程序的系統需求是性能優化的基礎,系統的需求是指應用程序運行時某方面的要求,譬如:

  • 高可用,可用性達到幾個9。
  • 低延遲,請求必須多少毫秒內完成響應。
  • 高吞吐,每秒完成多少次事務。

明確系統需求之因此重要,是由於上述性能指標間可能衝突。好比一般狀況下,縮小延遲的代價是下降吞吐量或者消耗更多的內存或者二者同時發生。

因爲筆者所在團隊主要關注高可用和低延遲兩項指標,因此接下來分析,如何量化GC時間和頻率對於響應時間和可用性的影響。經過這個量化指標,能夠計算出當前GC狀況對服務的影響,也能評估出GC優化後對響應時間的收益,這兩點對於低延遲服務很重要。

舉例:假設單位時間T內發生一次持續25ms的GC,接口平均響應時間爲50ms,且請求均勻到達,根據下圖所示:

GC影響

那麼有(50ms+25ms)/T比例的請求會受GC影響,其中GC前的50ms內到達的請求都會增長25ms,GC期間的25ms內到達的請求,會增長0-25ms不等,若是時間T內發生N次GC,受GC影響請求佔比=(接口響應時間+GC時間)×N/T 。可見不管下降單次GC時間仍是下降GC次數N均可以有效減小GC對響應時間的影響。

優化

經過收集GC信息,結合系統需求,肯定優化方案,例如選用合適的GC回收器、從新設置內存比例、調整JVM參數等。

進行調整後,將不一樣的優化方案分別應用到多臺機器上,而後比較這些機器上GC的性能差別,有針對性的作出選擇,再經過不斷的試驗和觀察,找到最合適的參數。

驗收優化結果

將修改應用到全部服務器,判斷優化結果是否符合預期,總結相關經驗。

接下來,咱們經過三個案例來實踐以上的優化流程和基本原則(本文中三個案例使用的垃圾回收器均爲ParNew+CMS,CMS失敗時Serial Old替補)。

3、GC優化案例

案例一 Major GC和Minor GC頻繁

肯定目標

服務狀況:Minor GC每分鐘100次 ,Major GC每4分鐘一次,單次Minor GC耗時25ms,單次Major GC耗時200ms,接口響應時間50ms。

因爲這個服務要求低延時高可用,結合上文中提到的GC對服務響應時間的影響,計算可知因爲Minor GC的發生,12.5%的請求響應時間會增長,其中8.3%的請求響應時間會增長25ms,可見當前GC狀況對響應時間影響較大。

(50ms+25ms)× 100次/60000ms = 12.5%,50ms × 100次/60000ms = 8.3% 。

優化目標:下降TP9九、TP90時間。

優化

首先優化Minor GC頻繁問題。一般狀況下,因爲新生代空間較小,Eden區很快被填滿,就會致使頻繁Minor GC,所以能夠經過增大新生代空間來下降Minor GC的頻率。例如在相同的內存分配率的前提下,新生代中的Eden區增長一倍,Minor GC的次數就會減小一半。

這時不少人有這樣的疑問,擴容Eden區雖然能夠減小Minor GC的次數,但會增長單次Minor GC時間麼?根據上面公式,若是單次Minor GC時間也增長,很難保證最後的優化效果。咱們結合下面狀況來分析,單次Minor GC時間主要受哪些因素影響?是否和新生代大小存在線性關係?
首先,單次Minor GC時間由如下兩部分組成:T1(掃描新生代)和 T2(複製存活對象到Survivor區)以下圖。(注:這裏爲了簡化問題,咱們認爲T1只掃描新生代判斷對象是否存活的時間,其實該階段還須要掃描部分老年代,後面案例中有詳細描述。)

GC影響

  • 擴容前:新生代容量爲R ,假設對象A的存活時間爲750ms,Minor GC間隔500ms,那麼本次Minor GC時間= T1(掃描新生代R)+T2(複製對象A到S)。

  • 擴容後:新生代容量爲2R ,對象A的生命週期爲750ms,那麼Minor GC間隔增長爲1000ms,此時Minor GC對象A已再也不存活,不須要把它複製到Survivor區,那麼本次GC時間 = 2 × T1(掃描新生代R),沒有T2複製時間。

可見,擴容後,Minor GC時增長了T1(掃描時間),但省去T2(複製對象)的時間,更重要的是對於虛擬機來講,複製對象的成本要遠高於掃描成本,因此,單次Minor GC時間更多取決於GC後存活對象的數量,而非Eden區的大小。所以若是堆中短時間對象不少,那麼擴容新生代,單次Minor GC時間不會顯著增長。下面須要確認下服務中對象的生命週期分佈狀況:

GC影響

經過上圖GC日誌中兩處紅色框標記內容可知:

  1. new threshold = 2(動態年齡判斷,對象的晉升年齡閾值爲2),對象僅經歷2次Minor GC後就晉升到老年代,這樣老年代會迅速被填滿,直接致使了頻繁的Major GC。
  2. Major GC後老年代使用空間爲300M+,意味着此時絕大多數(86% = 2G/2.3G)的對象已經再也不存活,也就是說生命週期長的對象佔比很小。

因而可知,服務中存在大量短時間臨時對象,擴容新生代空間後,Minor GC頻率下降,對象在新生代獲得充分回收,只有生命週期長的對象才進入老年代。這樣老年代增速變慢,Major GC頻率天然也會下降。

優化結果

經過擴容新生代爲爲原來的三倍,單次Minor GC時間增長小於5ms,頻率降低了60%,服務響應時間TP90,TP99都降低了10ms+,服務可用性獲得提高。

調整前:GC影響

調整後:GC影響

小結

如何選擇各分區大小應該依賴應用程序中對象生命週期的分佈狀況:若是應用存在大量的短時間對象,應該選擇較大的年輕代;若是存在相對較多的持久對象,老年代應該適當增大。

更多思考

關於上文中提到晉升年齡閾值爲2,不少同窗有疑問,爲何設置了MaxTenuringThreshold=15,對象仍然僅經歷2次Minor GC,就晉升到老年代?這裏涉及到「動態年齡計算」的概念。

動態年齡計算:Hotspot遍歷全部對象時,按照年齡從小到大對其所佔用的大小進行累積,當累積的某個年齡大小超過了survivor區的一半時,取這個年齡和MaxTenuringThreshold中更小的一個值,做爲新的晉升年齡閾值。在本案例中,調優前:Survivor區 = 64M,desired survivor = 32M,此時Survivor區中age<=2的對象累計大小爲41M,41M大於32M,因此晉升年齡閾值被設置爲2,下次Minor GC時將年齡超過2的對象被晉升到老年代。

JVM引入動態年齡計算,主要基於以下兩點考慮:

  1. 若是固定按照MaxTenuringThreshold設定的閾值做爲晉升條件:
    a)MaxTenuringThreshold設置的過大,本來應該晉升的對象一直停留在Survivor區,直到Survivor區溢出,一旦溢出發生,Eden+Svuvivor中對象將再也不依據年齡所有提高到老年代,這樣對象老化的機制就失效了。
    b)MaxTenuringThreshold設置的太小,「過早晉升」即對象不能在新生代充分被回收,大量短時間對象被晉升到老年代,老年代空間迅速增加,引發頻繁的Major GC。分代回收失去了意義,嚴重影響GC性能。

  2. 相同應用在不一樣時間的表現不一樣:特殊任務的執行或者流量成分的變化,都會致使對象的生命週期分佈發生波動,那麼固定的閾值設定,由於沒法動態適應變化,會形成和上面相同的問題。

總結來講,爲了更好的適應不一樣程序的內存狀況,虛擬機並不老是要求對象年齡必須達到Maxtenuringthreshhold再晉級老年代。

案例二 請求高峯期發生GC,致使服務可用性降低

肯定目標

GC日誌顯示,高峯期CMS在重標記(Remark)階段耗時1.39s。Remark階段是Stop-The-World(如下簡稱爲STW)的,即在執行垃圾回收時,Java應用程序中除了垃圾回收器線程以外其餘全部線程都被掛起,意味着在此期間,用戶正常工做的線程所有被暫停下來,這是低延時服務不能接受的。本次優化目標是下降Remark時間。

GC影響

優化

解決問題前,先回顧一下CMS的四個主要階段,以及各個階段的工做內容。下圖展現了CMS各個階段能夠標記的對象,用不一樣顏色區分。

  1. Init-mark初始標記(STW) ,該階段進行可達性分析,標記GC ROOT能直接關聯到的對象,因此很快。
  2. Concurrent-mark併發標記,由前階段標記過的綠色對象出發,全部可到達的對象都在本階段中標記。
  3. Remark重標記(STW) ,暫停全部用戶線程,從新掃描堆中的對象,進行可達性分析,標記活着的對象。由於併發標記階段是和用戶線程併發執行的過程,因此該過程當中可能有用戶線程修改某些活躍對象的字段,指向了一個未標記過的對象,以下圖中紅色對象在併發標記開始時不可達,可是並行期間引用發生變化,變爲對象可達,這個階段須要從新標記出此類對象,防止在下一階段被清理掉,這個過程也是須要STW的。特別須要注意一點,這個階段是以新生代中對象爲根來判斷對象是否存活的。
  4. 併發清理,進行併發的垃圾清理。

GC影響

可見,Remark階段主要是經過掃描堆來判斷對象是否存活。那麼準確判斷對象是否存活,須要掃描哪些對象?CMS對老年代作回收,Remark階段僅掃描老年代是否可行?結論是不可行,緣由以下:
GC影響
若是僅掃描老年代中對象,即以老年代中對象爲根,判斷對象是否存在引用,上圖中,對象A由於引用存在新生代中,它在Remark階段就不會被修正標記爲可達,GC時會被錯誤回收。
新生代對象持有老年代中對象的引用,這種狀況稱爲「跨代引用」。因它的存在,Remark階段必須掃描整個堆來判斷對象是否存活,包括圖中灰色的不可達對象。

灰色對象已經不可達,但仍然須要掃描的緣由:新生代GC和老年代的GC是各自分開獨立進行的,只有Minor GC時纔會使用根搜索算法,標記新生代對象是否可達,也就是說雖然一些對象已經不可達,但在Minor GC發生前不會被標記爲不可達,CMS也沒法辨認哪些對象存活,只能全堆掃描(新生代+老年代)。因而可知堆中對象的數目影響了Remark階段耗時。
分析GC日誌能夠得出一樣的規律,Remark耗時>500ms時,新生代使用率都在75%以上。這樣下降Remark階段耗時問題轉換成如何減小新生代對象數量。

新生代中對象的特色是「朝生夕滅」,這樣若是Remark前執行一次Minor GC,大部分對象就會被回收。CMS就採用了這樣的方式,在Remark前增長了一個可中斷的併發預清理(CMS-concurrent-abortable-preclean),該階段主要工做仍然是併發標記對象是否存活,只是這個過程可被中斷。此階段在Eden區使用超過2M時啓動,固然2M是默認的閾值,能夠經過參數修改。若是此階段執行時等到了Minor GC,那麼上述灰色對象將被回收,Reamark階段須要掃描的對象就少了。

除此以外CMS爲了不這個階段沒有等到Minor GC而陷入無限等待,提供了參數CMSMaxAbortablePrecleanTime ,默認爲5s,含義是若是可中斷的預清理執行超過5s,無論發沒發生Minor GC,都會停止此階段,進入Remark。
根據GC日誌紅色標記2處顯示,可中斷的併發預清理執行了5.35s,超過了設置的5s被中斷,期間沒有等到Minor GC ,因此Remark時新生代中仍然有不少對象。

對於這種狀況,CMS提供CMSScavengeBeforeRemark參數,用來保證Remark前強制進行一次Minor GC。

優化結果

通過增長CMSScavengeBeforeRemark參數,單次執行時間>200ms的GC停頓消失,從監控上觀察,GCtime和業務波動保持一致,再也不有明顯的毛刺。
GC影響

小結

經過案例分析瞭解到,因爲跨代引用的存在,CMS在Remark階段必須掃描整個堆,同時爲了不掃描時新生代有不少對象,增長了可中斷的預清理階段用來等待Minor GC的發生。只是該階段有時間限制,若是超時等不到Minor GC,Remark時新生代仍然有不少對象,咱們的調優策略是,經過參數強制Remark前進行一次Minor GC,從而下降Remark階段的時間。

更多思考

案例中只涉及老年代GC,其實新生代GC存在一樣的問題,即老年代可能持有新生代對象引用,因此Minor GC時也必須掃描老年代。

JVM是如何避免Minor GC時掃描全堆的?
通過統計信息顯示,老年代持有新生代對象引用的狀況不足1%,根據這一特性JVM引入了卡表(card table)來實現這一目的。以下圖所示:

GC影響

卡表的具體策略是將老年代的空間分紅大小爲512B的若干張卡(card)。卡表自己是單字節數組,數組中的每一個元素對應着一張卡,當發生老年代引用新生代時,虛擬機將該卡對應的卡表元素設置爲適當的值。如上圖所示,卡表3被標記爲髒(卡表還有另外的做用,標識併發標記階段哪些塊被修改過),以後Minor GC時經過掃描卡表就能夠很快的識別哪些卡中存在老年代指向新生代的引用。這樣虛擬機經過空間換時間的方式,避免了全堆掃描。

總結來講,CMS的設計聚焦在獲取最短的時延,爲此它「竭盡全力」地作了不少工做,包括儘可能讓應用程序和GC線程併發、增長可中斷的併發預清理階段、引入卡表等,雖然這些操做犧牲了必定吞吐量但得到了更短的回收停頓時間。

案例三 發生Stop-The-World的GC

肯定目標

GC日誌以下圖(在GC日誌中,Full GC是用來講明此次垃圾回收的停頓類型,表明STW類型的GC,並不特指老年代GC),根據GC日誌可知本次Full GC耗時1.23s。這個在線服務一樣要求低時延高可用。本次優化目標是下降單次STW回收停頓時間,提升可用性。

GC影響

優化

首先,何時可能會觸發STW的Full GC呢?

  1. Perm空間不足;
  2. CMS GC時出現promotion failed和concurrent mode failure(concurrent mode failure發生的緣由通常是CMS正在進行,可是因爲老年代空間不足,須要儘快回收老年代裏面的再也不被使用的對象,這時中止全部的線程,同時終止CMS,直接進行Serial Old GC);
  3. 統計獲得的Young GC晉升到老年代的平均大小大於老年代的剩餘空間;
  4. 主動觸發Full GC(執行jmap -histo:live [pid])來避免碎片問題。

而後,咱們來逐一分析一下:

  • 排除緣由2:若是是緣由2中兩種狀況,日誌中會有特殊標識,目前沒有。
  • 排除緣由3:根據GC日誌,當時老年代使用量僅爲20%,也不存在大於2G的大對象產生。
  • 排除緣由4:由於當時沒有相關命令執行。
  • 鎖定緣由1:根據日誌發現Full GC後,Perm區變大了,推斷是因爲永久代空間不足容量擴展致使的。

找到緣由後解決方法有兩種:

  1. 經過把-XX:PermSize參數和-XX:MaxPermSize設置成同樣,強制虛擬機在啓動的時候就把永久代的容量固定下來,避免運行時自動擴容。
  2. CMS默認狀況下不會回收Perm區,經過參數CMSPermGenSweepingEnabled、CMSClassUnloadingEnabled ,可讓CMS在Perm區容量不足時對其回收。

因爲該服務沒有生成大量動態類,回收Perm區收益不大,因此咱們採用方案1,啓動時將Perm區大小固定,避免進行動態擴容。

優化結果

調整參數後,服務再也不有Perm區擴容致使的STW GC發生。

小結

對於性能要求很高的服務,建議將MaxPermSize和MinPermSize設置成一致(JDK8開始,Perm區徹底消失,轉而使用元空間。而元空間是直接存在內存中,不在JVM中),Xms和Xmx也設置爲相同,這樣能夠減小內存自動擴容和收縮帶來的性能損失。虛擬機啓動的時候就會把參數中所設定的內存所有化爲私有,即便擴容前有一部份內存不會被用戶代碼用到,這部份內存在虛擬機中被標識爲虛擬內存,也不會交給其餘進程使用。

4、總結

結合上述GC優化案例作個總結:

  1. 首先再次聲明,在進行GC優化以前,須要確認項目的架構和代碼等已經沒有優化空間。咱們不能期望一個系統架構有缺陷或者代碼層次優化沒有窮盡的應用,經過GC優化令其性能達到一個質的飛躍。
  2. 其次,經過上述分析,能夠看出虛擬機內部已有不少優化來保證應用的穩定運行,因此不要爲了調優而調優,不當的調優可能拔苗助長。
  3. 最後,GC優化是一個系統而複雜的工做,沒有萬能的調優策略能夠知足全部的性能指標。GC優化必須創建在咱們深刻理解各類垃圾回收器的基礎上,纔能有事半功倍的效果。

本文中案例均來北京業務安全中心(也稱風控)對接服務的實踐經驗。同時感謝風控的小夥伴們,是他們專業負責的審閱,才讓這篇文章更加完善。對於本文中涉及到的內容,歡迎你們指正和補充。

做者簡介

彔彔,2016年加入美團點評,主要負責北京業務安全中心對接服務的後臺研發工做。

招聘

美團點評北京業務安全中心致力於建設公司平臺級業務安全基礎設施、保障業務安全運行,工做涵蓋交易秩序、賬號安全、爬蟲防控等風控方向,基於千萬級訂單、千萬級日活躍用戶、億級存量用戶進行數據挖掘,實時處理每日百億級流量,熱誠期待各位開發、算法、策略產品經理人才加入。聯繫郵箱:tangyizhe#meituan.com。

參考文獻

  1. Scott O. Java Performance:The Definitive Guide. O'Reilly, 2014.
  2. 周志明,深刻理解Java虛擬機[M],機械工業出版社,2013.
  3. CMS垃圾回收機制.

當Java程序性能達不到既定目標,且其餘優化手段都已經窮盡時,一般須要調整垃圾回收器來進一步提升性能,稱爲GC優化。但GC算法複雜,影響GC性能的參數衆多,且參數調整又依賴於應用各自的特色,這些因素很大程度上增長了GC優化的難度。即使如此,GC調優也不是無章可循,仍然有一些通用的思考方法。本篇會介紹這些通用的GC優化策略和相關實踐案例,主要包括以下內容:

優化前準備: 簡單回顧JVM相關知識、介紹GC優化的一些通用策略。
優化方法: 介紹調優的通常流程:明確優化目標→優化→跟蹤優化結果。
優化案例: 簡述筆者所在團隊遇到的GC問題以及優化方案。

1、優化前的準備

GC優化需知

爲了更好地理解本篇所介紹的內容,你須要瞭解以下內容。

  1. GC相關基礎知識,包括但不限於:
    a) GC工做原理。
    b) 理解新生代、老年代、晉升等術語含義。
    c) 能夠看懂GC日誌。

  2. GC優化不能解決一切性能問題,它是最後的調優手段。

若是對第一點中說起的知識點不是很熟悉,能夠先閱讀小結-JVM基礎回顧;若是已經很熟悉,能夠跳過該節直接往下閱讀。

JVM基礎回顧

JVM內存結構

簡單介紹一下JVM內存結構和常見的垃圾回收器。

當代主流虛擬機(Hotspot VM)的垃圾回收都採用「分代回收」的算法。「分代回收」是基於這樣一個事實:對象的生命週期不一樣,因此針對不一樣生命週期的對象能夠採起不一樣的回收方式,以便提升回收效率。

Hotspot VM將內存劃分爲不一樣的物理區,就是「分代」思想的體現。如圖所示,JVM內存主要由新生代、老年代、永久代構成。

GC影響

① 新生代(Young Generation):大多數對象在新生代中被建立,其中不少對象的生命週期很短。每次新生代的垃圾回收(又稱Minor GC)後只有少許對象存活,因此選用複製算法,只須要少許的複製成本就能夠完成回收。

新生代內又分三個區:一個Eden區,兩個Survivor區(通常而言),大部分對象在Eden區中生成。當Eden區滿時,還存活的對象將被複制到兩個Survivor區(中的一個)。當這個Survivor區滿時,此區的存活且不知足「晉升」條件的對象將被複制到另一個Survivor區。對象每經歷一次Minor GC,年齡加1,達到「晉升年齡閾值」後,被放到老年代,這個過程也稱爲「晉升」。顯然,「晉升年齡閾值」的大小直接影響着對象在新生代中的停留時間,在Serial和ParNew GC兩種回收器中,「晉升年齡閾值」經過參數MaxTenuringThreshold設定,默認值爲15。

② 老年代(Old Generation):在新生代中經歷了N次垃圾回收後仍然存活的對象,就會被放到年老代,該區域中對象存活率高。老年代的垃圾回收(又稱Major GC)一般使用「標記-清理」或「標記-整理」算法。整堆包括新生代和老年代的垃圾回收稱爲Full GC(HotSpot VM裏,除了CMS以外,其它能收集老年代的GC都會同時收集整個GC堆,包括新生代)。

③ 永久代(Perm Generation):主要存放元數據,例如Class、Method的元信息,與垃圾回收要回收的Java對象關係不大。相對於新生代和年老代來講,該區域的劃分對垃圾回收影響比較小。

常見垃圾回收器

不一樣的垃圾回收器,適用於不一樣的場景。經常使用的垃圾回收器:

  • 串行(Serial)回收器是單線程的一個回收器,簡單、易實現、效率高。
  • 並行(ParNew)回收器是Serial的多線程版,能夠充分的利用CPU資源,減小回收的時間。
  • 吞吐量優先(Parallel Scavenge)回收器,側重於吞吐量的控制。
  • 併發標記清除(CMS,Concurrent Mark Sweep)回收器是一種以獲取最短回收停頓時間爲目標的回收器,該回收器是基於「標記-清除」算法實現的。

GC日誌

每一種回收器的日誌格式都是由其自身的實現決定的,換而言之,每種回收器的日誌格式均可以不同。但虛擬機設計者爲了方便用戶閱讀,將各個回收器的日誌都維持必定的共性。JavaGC日誌 中簡單介紹了這些共性。

參數基本策略

各分區的大小對GC的性能影響很大。如何將各分區調整到合適的大小,分析活躍數據的大小是很好的切入點。

活躍數據的大小是指,應用程序穩定運行時長期存活對象在堆中佔用的空間大小,也就是Full GC後堆中老年代佔用空間的大小。能夠經過GC日誌中Full GC以後老年代數據大小得出,比較準確的方法是在程序穩定後,屢次獲取GC數據,經過取平均值的方式計算活躍數據的大小。活躍數據和各分區之間的比例關係以下(見參考文獻1):

空間 倍數
總大小 3-4 倍活躍數據的大小
新生代 1-1.5 活躍數據的大小
老年代 2-3 倍活躍數據的大小
永久代 1.2-1.5 倍Full GC後的永久代空間佔用

例如,根據GC日誌得到老年代的活躍數據大小爲300M,那麼各分區大小能夠設爲:

總堆:1200MB = 300MB × 4
新生代:450MB = 300MB × 1.5

老年代: 750MB = 1200MB - 450MB*

這部分設置僅僅是堆大小的初始值,後面的優化中,可能會調整這些值,具體狀況取決於應用程序的特性和需求。

2、優化步驟

GC優化通常步驟能夠歸納爲:肯定目標、優化參數、驗收結果。

肯定目標

明確應用程序的系統需求是性能優化的基礎,系統的需求是指應用程序運行時某方面的要求,譬如:

  • 高可用,可用性達到幾個9。
  • 低延遲,請求必須多少毫秒內完成響應。
  • 高吞吐,每秒完成多少次事務。

明確系統需求之因此重要,是由於上述性能指標間可能衝突。好比一般狀況下,縮小延遲的代價是下降吞吐量或者消耗更多的內存或者二者同時發生。

因爲筆者所在團隊主要關注高可用和低延遲兩項指標,因此接下來分析,如何量化GC時間和頻率對於響應時間和可用性的影響。經過這個量化指標,能夠計算出當前GC狀況對服務的影響,也能評估出GC優化後對響應時間的收益,這兩點對於低延遲服務很重要。

舉例:假設單位時間T內發生一次持續25ms的GC,接口平均響應時間爲50ms,且請求均勻到達,根據下圖所示:

GC影響

那麼有(50ms+25ms)/T比例的請求會受GC影響,其中GC前的50ms內到達的請求都會增長25ms,GC期間的25ms內到達的請求,會增長0-25ms不等,若是時間T內發生N次GC,受GC影響請求佔比=(接口響應時間+GC時間)×N/T 。可見不管下降單次GC時間仍是下降GC次數N均可以有效減小GC對響應時間的影響。

優化

經過收集GC信息,結合系統需求,肯定優化方案,例如選用合適的GC回收器、從新設置內存比例、調整JVM參數等。

進行調整後,將不一樣的優化方案分別應用到多臺機器上,而後比較這些機器上GC的性能差別,有針對性的作出選擇,再經過不斷的試驗和觀察,找到最合適的參數。

驗收優化結果

將修改應用到全部服務器,判斷優化結果是否符合預期,總結相關經驗。

接下來,咱們經過三個案例來實踐以上的優化流程和基本原則(本文中三個案例使用的垃圾回收器均爲ParNew+CMS,CMS失敗時Serial Old替補)。

3、GC優化案例

案例一 Major GC和Minor GC頻繁

肯定目標

服務狀況:Minor GC每分鐘100次 ,Major GC每4分鐘一次,單次Minor GC耗時25ms,單次Major GC耗時200ms,接口響應時間50ms。

因爲這個服務要求低延時高可用,結合上文中提到的GC對服務響應時間的影響,計算可知因爲Minor GC的發生,12.5%的請求響應時間會增長,其中8.3%的請求響應時間會增長25ms,可見當前GC狀況對響應時間影響較大。

(50ms+25ms)× 100次/60000ms = 12.5%,50ms × 100次/60000ms = 8.3% 。

優化目標:下降TP9九、TP90時間。

優化

首先優化Minor GC頻繁問題。一般狀況下,因爲新生代空間較小,Eden區很快被填滿,就會致使頻繁Minor GC,所以能夠經過增大新生代空間來下降Minor GC的頻率。例如在相同的內存分配率的前提下,新生代中的Eden區增長一倍,Minor GC的次數就會減小一半。

這時不少人有這樣的疑問,擴容Eden區雖然能夠減小Minor GC的次數,但會增長單次Minor GC時間麼?根據上面公式,若是單次Minor GC時間也增長,很難保證最後的優化效果。咱們結合下面狀況來分析,單次Minor GC時間主要受哪些因素影響?是否和新生代大小存在線性關係?
首先,單次Minor GC時間由如下兩部分組成:T1(掃描新生代)和 T2(複製存活對象到Survivor區)以下圖。(注:這裏爲了簡化問題,咱們認爲T1只掃描新生代判斷對象是否存活的時間,其實該階段還須要掃描部分老年代,後面案例中有詳細描述。)

GC影響

  • 擴容前:新生代容量爲R ,假設對象A的存活時間爲750ms,Minor GC間隔500ms,那麼本次Minor GC時間= T1(掃描新生代R)+T2(複製對象A到S)。

  • 擴容後:新生代容量爲2R ,對象A的生命週期爲750ms,那麼Minor GC間隔增長爲1000ms,此時Minor GC對象A已再也不存活,不須要把它複製到Survivor區,那麼本次GC時間 = 2 × T1(掃描新生代R),沒有T2複製時間。

可見,擴容後,Minor GC時增長了T1(掃描時間),但省去T2(複製對象)的時間,更重要的是對於虛擬機來講,複製對象的成本要遠高於掃描成本,因此,單次Minor GC時間更多取決於GC後存活對象的數量,而非Eden區的大小。所以若是堆中短時間對象不少,那麼擴容新生代,單次Minor GC時間不會顯著增長。下面須要確認下服務中對象的生命週期分佈狀況:

GC影響

經過上圖GC日誌中兩處紅色框標記內容可知:

  1. new threshold = 2(動態年齡判斷,對象的晉升年齡閾值爲2),對象僅經歷2次Minor GC後就晉升到老年代,這樣老年代會迅速被填滿,直接致使了頻繁的Major GC。
  2. Major GC後老年代使用空間爲300M+,意味着此時絕大多數(86% = 2G/2.3G)的對象已經再也不存活,也就是說生命週期長的對象佔比很小。

因而可知,服務中存在大量短時間臨時對象,擴容新生代空間後,Minor GC頻率下降,對象在新生代獲得充分回收,只有生命週期長的對象才進入老年代。這樣老年代增速變慢,Major GC頻率天然也會下降。

優化結果

經過擴容新生代爲爲原來的三倍,單次Minor GC時間增長小於5ms,頻率降低了60%,服務響應時間TP90,TP99都降低了10ms+,服務可用性獲得提高。

調整前:GC影響

調整後:GC影響

小結

如何選擇各分區大小應該依賴應用程序中對象生命週期的分佈狀況:若是應用存在大量的短時間對象,應該選擇較大的年輕代;若是存在相對較多的持久對象,老年代應該適當增大。

更多思考

關於上文中提到晉升年齡閾值爲2,不少同窗有疑問,爲何設置了MaxTenuringThreshold=15,對象仍然僅經歷2次Minor GC,就晉升到老年代?這裏涉及到「動態年齡計算」的概念。

動態年齡計算:Hotspot遍歷全部對象時,按照年齡從小到大對其所佔用的大小進行累積,當累積的某個年齡大小超過了survivor區的一半時,取這個年齡和MaxTenuringThreshold中更小的一個值,做爲新的晉升年齡閾值。在本案例中,調優前:Survivor區 = 64M,desired survivor = 32M,此時Survivor區中age<=2的對象累計大小爲41M,41M大於32M,因此晉升年齡閾值被設置爲2,下次Minor GC時將年齡超過2的對象被晉升到老年代。

JVM引入動態年齡計算,主要基於以下兩點考慮:

  1. 若是固定按照MaxTenuringThreshold設定的閾值做爲晉升條件:
    a)MaxTenuringThreshold設置的過大,本來應該晉升的對象一直停留在Survivor區,直到Survivor區溢出,一旦溢出發生,Eden+Svuvivor中對象將再也不依據年齡所有提高到老年代,這樣對象老化的機制就失效了。
    b)MaxTenuringThreshold設置的太小,「過早晉升」即對象不能在新生代充分被回收,大量短時間對象被晉升到老年代,老年代空間迅速增加,引發頻繁的Major GC。分代回收失去了意義,嚴重影響GC性能。

  2. 相同應用在不一樣時間的表現不一樣:特殊任務的執行或者流量成分的變化,都會致使對象的生命週期分佈發生波動,那麼固定的閾值設定,由於沒法動態適應變化,會形成和上面相同的問題。

總結來講,爲了更好的適應不一樣程序的內存狀況,虛擬機並不老是要求對象年齡必須達到Maxtenuringthreshhold再晉級老年代。

案例二 請求高峯期發生GC,致使服務可用性降低

肯定目標

GC日誌顯示,高峯期CMS在重標記(Remark)階段耗時1.39s。Remark階段是Stop-The-World(如下簡稱爲STW)的,即在執行垃圾回收時,Java應用程序中除了垃圾回收器線程以外其餘全部線程都被掛起,意味着在此期間,用戶正常工做的線程所有被暫停下來,這是低延時服務不能接受的。本次優化目標是下降Remark時間。

GC影響

優化

解決問題前,先回顧一下CMS的四個主要階段,以及各個階段的工做內容。下圖展現了CMS各個階段能夠標記的對象,用不一樣顏色區分。

  1. Init-mark初始標記(STW) ,該階段進行可達性分析,標記GC ROOT能直接關聯到的對象,因此很快。
  2. Concurrent-mark併發標記,由前階段標記過的綠色對象出發,全部可到達的對象都在本階段中標記。
  3. Remark重標記(STW) ,暫停全部用戶線程,從新掃描堆中的對象,進行可達性分析,標記活着的對象。由於併發標記階段是和用戶線程併發執行的過程,因此該過程當中可能有用戶線程修改某些活躍對象的字段,指向了一個未標記過的對象,以下圖中紅色對象在併發標記開始時不可達,可是並行期間引用發生變化,變爲對象可達,這個階段須要從新標記出此類對象,防止在下一階段被清理掉,這個過程也是須要STW的。特別須要注意一點,這個階段是以新生代中對象爲根來判斷對象是否存活的。
  4. 併發清理,進行併發的垃圾清理。

GC影響

可見,Remark階段主要是經過掃描堆來判斷對象是否存活。那麼準確判斷對象是否存活,須要掃描哪些對象?CMS對老年代作回收,Remark階段僅掃描老年代是否可行?結論是不可行,緣由以下:
GC影響
若是僅掃描老年代中對象,即以老年代中對象爲根,判斷對象是否存在引用,上圖中,對象A由於引用存在新生代中,它在Remark階段就不會被修正標記爲可達,GC時會被錯誤回收。
新生代對象持有老年代中對象的引用,這種狀況稱爲「跨代引用」。因它的存在,Remark階段必須掃描整個堆來判斷對象是否存活,包括圖中灰色的不可達對象。

灰色對象已經不可達,但仍然須要掃描的緣由:新生代GC和老年代的GC是各自分開獨立進行的,只有Minor GC時纔會使用根搜索算法,標記新生代對象是否可達,也就是說雖然一些對象已經不可達,但在Minor GC發生前不會被標記爲不可達,CMS也沒法辨認哪些對象存活,只能全堆掃描(新生代+老年代)。因而可知堆中對象的數目影響了Remark階段耗時。
分析GC日誌能夠得出一樣的規律,Remark耗時>500ms時,新生代使用率都在75%以上。這樣下降Remark階段耗時問題轉換成如何減小新生代對象數量。

新生代中對象的特色是「朝生夕滅」,這樣若是Remark前執行一次Minor GC,大部分對象就會被回收。CMS就採用了這樣的方式,在Remark前增長了一個可中斷的併發預清理(CMS-concurrent-abortable-preclean),該階段主要工做仍然是併發標記對象是否存活,只是這個過程可被中斷。此階段在Eden區使用超過2M時啓動,固然2M是默認的閾值,能夠經過參數修改。若是此階段執行時等到了Minor GC,那麼上述灰色對象將被回收,Reamark階段須要掃描的對象就少了。

除此以外CMS爲了不這個階段沒有等到Minor GC而陷入無限等待,提供了參數CMSMaxAbortablePrecleanTime ,默認爲5s,含義是若是可中斷的預清理執行超過5s,無論發沒發生Minor GC,都會停止此階段,進入Remark。
根據GC日誌紅色標記2處顯示,可中斷的併發預清理執行了5.35s,超過了設置的5s被中斷,期間沒有等到Minor GC ,因此Remark時新生代中仍然有不少對象。

對於這種狀況,CMS提供CMSScavengeBeforeRemark參數,用來保證Remark前強制進行一次Minor GC。

優化結果

通過增長CMSScavengeBeforeRemark參數,單次執行時間>200ms的GC停頓消失,從監控上觀察,GCtime和業務波動保持一致,再也不有明顯的毛刺。
GC影響

小結

經過案例分析瞭解到,因爲跨代引用的存在,CMS在Remark階段必須掃描整個堆,同時爲了不掃描時新生代有不少對象,增長了可中斷的預清理階段用來等待Minor GC的發生。只是該階段有時間限制,若是超時等不到Minor GC,Remark時新生代仍然有不少對象,咱們的調優策略是,經過參數強制Remark前進行一次Minor GC,從而下降Remark階段的時間。

更多思考

案例中只涉及老年代GC,其實新生代GC存在一樣的問題,即老年代可能持有新生代對象引用,因此Minor GC時也必須掃描老年代。

JVM是如何避免Minor GC時掃描全堆的?
通過統計信息顯示,老年代持有新生代對象引用的狀況不足1%,根據這一特性JVM引入了卡表(card table)來實現這一目的。以下圖所示:

GC影響

卡表的具體策略是將老年代的空間分紅大小爲512B的若干張卡(card)。卡表自己是單字節數組,數組中的每一個元素對應着一張卡,當發生老年代引用新生代時,虛擬機將該卡對應的卡表元素設置爲適當的值。如上圖所示,卡表3被標記爲髒(卡表還有另外的做用,標識併發標記階段哪些塊被修改過),以後Minor GC時經過掃描卡表就能夠很快的識別哪些卡中存在老年代指向新生代的引用。這樣虛擬機經過空間換時間的方式,避免了全堆掃描。

總結來講,CMS的設計聚焦在獲取最短的時延,爲此它「竭盡全力」地作了不少工做,包括儘可能讓應用程序和GC線程併發、增長可中斷的併發預清理階段、引入卡表等,雖然這些操做犧牲了必定吞吐量但得到了更短的回收停頓時間。

案例三 發生Stop-The-World的GC

肯定目標

GC日誌以下圖(在GC日誌中,Full GC是用來講明此次垃圾回收的停頓類型,表明STW類型的GC,並不特指老年代GC),根據GC日誌可知本次Full GC耗時1.23s。這個在線服務一樣要求低時延高可用。本次優化目標是下降單次STW回收停頓時間,提升可用性。

GC影響

優化

首先,何時可能會觸發STW的Full GC呢?

  1. Perm空間不足;
  2. CMS GC時出現promotion failed和concurrent mode failure(concurrent mode failure發生的緣由通常是CMS正在進行,可是因爲老年代空間不足,須要儘快回收老年代裏面的再也不被使用的對象,這時中止全部的線程,同時終止CMS,直接進行Serial Old GC);
  3. 統計獲得的Young GC晉升到老年代的平均大小大於老年代的剩餘空間;
  4. 主動觸發Full GC(執行jmap -histo:live [pid])來避免碎片問題。

而後,咱們來逐一分析一下:

  • 排除緣由2:若是是緣由2中兩種狀況,日誌中會有特殊標識,目前沒有。
  • 排除緣由3:根據GC日誌,當時老年代使用量僅爲20%,也不存在大於2G的大對象產生。
  • 排除緣由4:由於當時沒有相關命令執行。
  • 鎖定緣由1:根據日誌發現Full GC後,Perm區變大了,推斷是因爲永久代空間不足容量擴展致使的。

找到緣由後解決方法有兩種:

  1. 經過把-XX:PermSize參數和-XX:MaxPermSize設置成同樣,強制虛擬機在啓動的時候就把永久代的容量固定下來,避免運行時自動擴容。
  2. CMS默認狀況下不會回收Perm區,經過參數CMSPermGenSweepingEnabled、CMSClassUnloadingEnabled ,可讓CMS在Perm區容量不足時對其回收。

因爲該服務沒有生成大量動態類,回收Perm區收益不大,因此咱們採用方案1,啓動時將Perm區大小固定,避免進行動態擴容。

優化結果

調整參數後,服務再也不有Perm區擴容致使的STW GC發生。

小結

對於性能要求很高的服務,建議將MaxPermSize和MinPermSize設置成一致(JDK8開始,Perm區徹底消失,轉而使用元空間。而元空間是直接存在內存中,不在JVM中),Xms和Xmx也設置爲相同,這樣能夠減小內存自動擴容和收縮帶來的性能損失。虛擬機啓動的時候就會把參數中所設定的內存所有化爲私有,即便擴容前有一部份內存不會被用戶代碼用到,這部份內存在虛擬機中被標識爲虛擬內存,也不會交給其餘進程使用。

4、總結

結合上述GC優化案例作個總結:

  1. 首先再次聲明,在進行GC優化以前,須要確認項目的架構和代碼等已經沒有優化空間。咱們不能期望一個系統架構有缺陷或者代碼層次優化沒有窮盡的應用,經過GC優化令其性能達到一個質的飛躍。
  2. 其次,經過上述分析,能夠看出虛擬機內部已有不少優化來保證應用的穩定運行,因此不要爲了調優而調優,不當的調優可能拔苗助長。
  3. 最後,GC優化是一個系統而複雜的工做,沒有萬能的調優策略能夠知足全部的性能指標。GC優化必須創建在咱們深刻理解各類垃圾回收器的基礎上,纔能有事半功倍的效果。

本文中案例均來北京業務安全中心(也稱風控)對接服務的實踐經驗。同時感謝風控的小夥伴們,是他們專業負責的審閱,才讓這篇文章更加完善。對於本文中涉及到的內容,歡迎你們指正和補充。

做者簡介

彔彔,2016年加入美團點評,主要負責北京業務安全中心對接服務的後臺研發工做。

招聘

美團點評北京業務安全中心致力於建設公司平臺級業務安全基礎設施、保障業務安全運行,工做涵蓋交易秩序、賬號安全、爬蟲防控等風控方向,基於千萬級訂單、千萬級日活躍用戶、億級存量用戶進行數據挖掘,實時處理每日百億級流量,熱誠期待各位開發、算法、策略產品經理人才加入。聯繫郵箱:tangyizhe#meituan.com。

參考文獻

  1. Scott O. Java Performance:The Definitive Guide. O'Reilly, 2014.
  2. 周志明,深刻理解Java虛擬機[M],機械工業出版社,2013.
  3. CMS垃圾回收機制.
相關文章
相關標籤/搜索