1. 程式人生 > 實用技巧 >JVM效能調優(3) —— 記憶體分配和垃圾回收調優

JVM效能調優(3) —— 記憶體分配和垃圾回收調優

前序文章:

JVM效能調優(1) —— JVM記憶體模型和類載入執行機制

JVM效能調優(2) —— 垃圾回收器和回收策略

一、記憶體調優的目標

新生代的垃圾回收是比較簡單的,Eden區滿了無法分配新物件時就觸發 YoungGC。而且新生代採用的複製演算法效率極高,加上新生代存活的物件很少,只要迅速標記出這少量存活物件,移動到Survivor區,然後快速回收掉Eden區,速度很快。一般一次YoungGC就耗費幾毫秒或幾十毫秒,所以新生代GC對系統的影響基本不是很大。

但老年代的GC就不一樣了,老年代GC通常都很耗費時間,尤其是頻繁觸發老年代GC(FullGC/OldGC)。因為無論是CMS垃圾回收器還是G1垃圾回收器,比如說CMS就要經歷初始標記、併發標記、重新標記、併發清理、碎片整理幾個環節,過程非常的複雜,STW的時間也會更長,G1同樣也是如此。通常來說,FullGC至少比YoungGC慢10倍以上。

新生代物件進入老年代有四個時機:物件年齡超過閥值、大物件直接進入老年代,動態年齡判斷規則、新生代GC後存活物件太多無法放入Survivor區。物件年齡太大進入老年代無可避免,因為這部分物件一般來說都是長期存活的物件,是需要進入老年代的。而後三個一般都是因為記憶體分配不合理或一些引數設定不合理導致物件進入老年代,而且基本都是生命週期較短的物件,然後佔滿老年代,觸發老年代GC。

因此,基於JVM執行的系統最大的問題,就是因為記憶體分配、引數設定不合理,導致物件頻繁的進入老年代,然後頻繁觸發FullGC,導致系統每隔一段時間就卡頓幾百毫秒甚至幾秒鐘,這對使用者體驗來說將是極差的。

所以,JVM調優的目標,最重要的就是對記憶體分配調優,然後合理優化新生代、老年代、Eden和Survivor各個區域的記憶體大小。接著再儘量優化引數避免新生代的物件進入老年代,儘量讓物件留在新生代裡被回收掉,甚至不會出現 FullGC。

二、估算記憶體運轉模型

在設定JVM記憶體的時候,是沒有一個固定標準、固定引數的,但是有一套比較通用的分析和優化方法,就是根據實際業務預估這個系統未來的業務量、訪問量,去推算這個系統每秒種的併發量,然後推算每秒鐘的請求對記憶體空間的佔用,進而推算出整個系統執行期間的JVM記憶體運轉模型。然後通過各個引數調優,儘量讓垃圾物件在年輕代被回收掉,避免頻繁 Full GC。

下面就假定有一個每日百萬交易的支付系統,來看看怎麼估算一個比較合理的記憶體運轉模型。

第1步:分析系統核心業務與核心壓力

首先要分析出一個系統的核心壓力集中在哪裡,每日百萬交易的支付系統,最核心的業務當屬支付流程。每次支付請求將建立至少一個訂單物件,這個訂單物件包含支付的使用者、渠道、金額、商品、時間等資訊。

支付系統的壓力有很多方面,包括高併發請求、高效能處理請求、大量訂單資料儲存等,但在JVM層面,這個支付系統最大的壓力就是每天會在JVM中頻繁的建立和銷燬100萬個支付訂單物件。

第2步:預估每秒需處理多少次請求

要設定合理的JVM記憶體大小,首先要估算出核心業務每秒鐘有多少次請求。假設每天100萬個支付訂單,一般使用者交易都集中在每天的高峰期,也就是中午或晚上那3~4個小時,那麼平均每秒就將近100次。

假設支付系統部署3臺機器,那麼平均到每臺機器就30個支付請求。

第3步:估算一次請求耗時多久

使用者發起一次支付請求,後端將建立一個訂單物件、做一些關聯校驗、寫入資料庫等,還有一些其它操作,比如呼叫第三方支付平臺等。假設一次支付請求耗時1秒吧,那麼每秒鐘就會產生30個訂單物件,然後1秒後這30個物件就變為垃圾物件了。

第4步:估算每秒請求佔多少記憶體

我們可以根據訂單類中的例項變數型別來計算就可以了,比如 Integer 佔4個位元組,Long 佔8個位元組,String 型別根據長度來計算。假設一個訂單類按20個欄位來算,往大一點粗略估算佔500位元組吧。那麼每秒30個支付請求就是 30 * 500B ≈ 15KB。

但實際上,每次請求的過程中,除了訂單物件,往往還會建立大量其它型別的物件,比如其它的一些關聯查詢物件,Spring框架建立的物件等,這時一般需要對單個物件放大10~20倍。

而且支付系統還會包含其它的一些業務,比如交易記錄、對賬管理、結算管理等,再擴大個5~10倍。這樣算下來每秒鐘基本會產生1M左右的物件。

但這些也不是絕對的,對於一些特殊的系統,比如報表系統、資料計算系統,每次請求建立的物件可能超過10幾M了,那麼附屬建立的這些物件可能影響就沒那麼大了,此時可以考慮忽略不計。

第5步:估算元空間大小

元空間主要是存放型別資訊,也沒什麼太多好調優的,一般設定幾百M夠用就可以了,比如256M。

第6步:估算棧記憶體大小

執行緒棧主要就是執行期間儲存方法的引數、區域性變數等資訊,一般設定1M就足夠了。比如系統有100個執行緒,那麼虛擬機器棧就會至少佔用100M記憶體。

第7步:記憶體分配

這個每日百萬交易的支付系統部署3臺機器,每臺機器每秒扛30個請求。假設部署的機器是2核4G,但是機器本身執行還需要一些記憶體,那麼JVM就只分2G,考慮到要給元空間、虛擬機器棧預留空間,那假設堆記憶體只分1G,新生代給500M,老年代給500M,那 Eden 區就佔400M,兩個 Survivor 區各佔50M。

這樣估算下來,就是如下的記憶體引數設定:

-Xms1G -Xmx1G -Xmn500M -Xss1M -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M -XX:SurvivorRatio=8

第8步:系統運轉模型

經過上面的分析,再結合機器配置,我們就能大致估算這個系統的記憶體運轉模型了。使用上面的記憶體設定,那麼每秒接收30個請求,在Eden區建立30個訂單相關的物件;將產生1M新物件,1秒後請求處理完成,將產生1M的垃圾物件;將在400秒後,也就幾分鐘的時間,Eden 區就佔滿了,然後觸發 Young GC;YoungGC時會把存活物件複製到FromSurvivor區,然後回收掉新生代的垃圾物件,如此往復。如果Survivor區分配不合理,導致存活物件進入老年代,還可以估算出多久觸發一次FullGC/OldGC。主要就是估算出GC的頻率,然後就可以對記憶體進行調優了。

第9步:瞬時壓力增加時的模型估算

如果遇到搞大促活動或一些突發的效能抖動,壓力可能瞬間增加10倍甚至更多,那每秒可能就是上千筆支付請求,每秒記憶體佔用至少10M以上了。這個時候每次支付請求可能就不是1秒能處理完的了,因為壓力驟增,系統記憶體、執行緒資源、CPU資源都將打滿,導致系統性能下降,這樣可能有些支付請求需要耗時好幾秒,那可能就有幾十M物件會佔用堆記憶體幾秒鐘。

還是按照2核4G的機器部署,堆記憶體設定1G,新生代500M,Eden區400M,Survivor50M。這時Eden區只需幾十秒就滿了,然後觸發YoungGC。但是,因為壓力增加,有些請求需要好幾秒,就會有幾十M物件會將無法被回收,就被複制到 Survivor 區。

這時就有多種情況了,首先存活幾十M的物件可能大於Survivor區50M的記憶體,那麼就會直接複製到老年代。然後如果小於Survivor區,也大於了Survivor區50%的空間了,下一次通過動態年齡規則判斷也可能會將部分物件複製到老年代。

然後經過大概10幾次YoungGC,也就幾百秒後老年代也快滿了,這時可能就會觸發FullGC,FullGC時要暫停系統執行,無法處理任何請求,而且這種情況下老年代大部分都是垃圾物件,回收效能是很低的。

三、YoungGC 調優

1、合理分配記憶體降低YoungGC頻率

根據前面的估算,在正常的情況下如果給堆分配1G的空間,會頻繁觸發 YoungGC,新生代回收雖然效率高,但也會 Stop The World,暫停系統執行,如果頻繁YoungGC,就會頻繁暫停系統。

我們可以考慮增大新生代記憶體,同時使用記憶體大一點的機器,比如使用4核8G,那麼JVM分4G,給堆空間分配3G,新生代給1.5G,老年代給1.5G,Eden 區差不多1.2G,Survivor區150M,這個時候Eden區差不多要半個小時才會佔滿,然後觸發一次YoungGC,而其中99%都是垃圾物件,採用標記-複製演算法基本上很能就能完成YoungGC,這就大大降低了YoungGC的頻率。

如果業務量更大,還可以考慮橫向多部署幾臺機器,這樣分到每臺機器的請求就更少了,壓力也更小。

2、保證Survivor空間足夠

如果遇到大促活動,瞬時壓力增大,每秒就會有10M以上的物件產生,然後有幾十兆甚至上百兆的物件會存活幾秒以上。按照前面的記憶體模型來分析下,那 Eden 區2分鐘左右就會佔滿,然後將存活的幾十兆物件複製到 Survivor 區;如果這批存活物件大於150M,將直接進入老年代;如果小於150M但大於 75M,那麼由於動態年齡判斷也有可能頻繁導致部分生命週期短的物件進入老年代。老年代如果快速佔滿將頻繁觸發FullGC。

新生代調優最重要的一個就是儘量保證 Surivivor 空間足夠,避免因為 YoungGC 時Survivor空間不夠導致大批物件進入老年代,這樣就能極大減少甚至不會FullGC了。

這種業務系統其實絕大多數物件的生命週期都很短,長時間存活的物件佔不了多少記憶體,我們應該儘量讓物件都留在新生代裡。因此我們可以把新生代的記憶體佔比調高一點,比如新生代給2G,老年代給1G,這樣 Eden 區就佔了1.6G,Survivor 佔200M,這樣就基本能保證每次YoungGC時存活的物件都能放進 Survivor 區了。或者再可以用 -XX:SurvivorRatio 引數調整下 Eden 區和 Survivor 區的比例,讓 Survivor 區儘可能裝下每次 YoungGC 後存活的物件。

3、優化物件年齡閥值

還有一種情況會導致新生代物件進入老年代,就是有些物件連續躲過15次回收後,就會晉升到老年代。這個我們也可以結合實際的業務模型做調整,比如大促的場景中,新生代分2G,Eden區分1.6G,差不多每隔3分鐘就觸發一次YoungGC,那麼在新生代來回複製15次就是45分鐘左右的時間才會進入老年代,對於這個系統來說,絕大多數物件的生命週期都是很短的,能存活幾分鐘以上的物件應該都是程式中的 Controller、Service、Repository 之類的需要長期存活的業務核心元件。

所以對於這種型別的系統,應儘快讓長期存活的物件進入老年代,而不是在新生代來回複製15次後再進入老年代。可以通過 -XX:MaxTenuringThreshold引數降低年齡閥值,比如設定為 5。

4、優化大物件閥值

還有一種情況就是大物件將直接進入老年代,大物件閥值一般設定1M就夠了,一般來說很少有一個物件超過1M的。如果我們確定系統中會頻繁建立生命週期短的大物件,我們可以適當調大這個閥值,避免其進入老年代。

可以通過引數 -XX:PretenureSizeThreshold=1M 來設定大物件閥值。

5、選擇垃圾回收器

新生代垃圾回收器有 Serial、ParNew、ParallelScavenge,一般來說老年代要用效能較好的 CMS 垃圾回收器,那麼新生代就只能指定 ParNew 回收器。

使用 ParNew 回收器,調優的思路基本就是前面4點,合理分配新生代記憶體,保證物件能放入 Survivor 區,避免進入老年代,基本 YoungGC 就沒啥問題了。

6、JVM引數

調優後的JVM引數如下:

-Xms3G
-Xmx3G
-Xmn2G
-Xss1M
-XX:SurvivorRatio=8
-XX:MetaspaceSize=256M
-XX:MaxMetaspaceSize=256M
-XX:MaxTenuringThreshold=5
-XX:PretenureSizeThreshold=1M
-XX:+UseParNewGC
-XX:+UseConcMarkSweepGC

四、FullGC 調優

老年代主要使用CMS垃圾回收器,我們就主要結合上面的業務模型來看看CMS回收器的各個引數優化。

1、多久觸發一次 FullGC

在前面年輕代的優化基礎之上,我們還需要估算系統多久會觸發一次 Full GC,這將決定我們是否要重點優化下老年代。比如估算下來每隔一兩個小時或更久執行一次 Full GC,這時候高峰期那一個小時已經過了,這時候執行 Full GC 對系統的影響來說其實是很小的了。

首先看下觸發 Full GC 的條件:

  • ① JDK6 之前有個-XX:HandlePromotionFailure 分配擔保失敗的引數,就是每次 YoungGC 前都會判斷老年代的可用空間大小是否大於新生代物件總大小,按前面的配置,新生代最多會有 1.8G 的物件,老年大最大才 1G,那豈不是每次 YoungGC 都會擔保失敗。不過JDK1.6之後就沒有這個引數了,也沒有這個判斷了。
  • ② 每次 YoungGC 前檢查老年代可用空間是否大於歷次YoungGC後進入老年代的平均物件大小,按照前面的配置,基本上物件在新生代就被回收了,歷次進入老年代的平均物件大小其實是很小的,這個條件基本不會觸發。
  • ③ 可能某次 YoungGC 後存活物件大於 Survivor 區大小了,要複製到老年代,但發現老年代空間不足也放不下了,這時就會觸發FullGC,但年輕代優化好之後,這種概率是非常小的了。
  • ④ CMS 有個 92% 的閥值,就是老年代超過 92% 的時候,會自動觸發老年代垃圾回收,這個引數可以通過 -XX:CMSInitiatingOccupancyFraction 設定。

系統執行時,可能會有部分物件慢慢進入老年代,但是新生代優化好之後,物件晉升到老年代的速度是很慢的,可能需要幾個小時才觸發一次 FullGC。錯過高峰期,FullGC 的影響也不會太大。

2、CMS併發失敗

觸發老年代GC後,基本就是老年代快滿了,CMS有個92%的閥值,那麼1G的老年代,就還剩100M左右空間,如果老年代在併發回收時,新晉升到老年代的物件超過100M了,就會導致併發失敗(Concurrent Model Failure)。併發失敗後,就會進入 Stop The World 的狀態,老年代切換為 Serial Old 回收器,Serial Old 回收器是單執行緒回收,效率非常低的。

但是經過年輕代的調優後,物件升入老年代的速度是很慢的,而且每次升入老年代的平均物件大小是很小的,所以一般在併發回收時還有超過100M的物件升入老年代的概率也是很小的。這種情況下我們一般也不用去調整-XX:CMSInitiatingOccupancyFraction 引數的值。

3、CMS回收後碎片整理頻率

CMS完成FullGC後,預設是每次都會進行一次記憶體碎片整理,這個過程也會 Stop The World。但是按照前面的分析,其實我們也沒必須要調整這部分引數。

CMS 通過-XX:+UseCMSCompactAtFullCollection 引數開啟GC後記憶體碎片整理的過程,通過-XX:CMSFullGCsBeforeCompaction 設定多少次FullGC後進行記憶體碎片整理,預設0,就是每次FullGC後都整理。

一般不用調整CMSFullGCsBeforeCompaction 的值,提高這個值,意味著要多次 FullGC 後才會進行記憶體碎片整理,那麼前幾次FullGC會導致很多記憶體碎片產生,不整理就會導致更頻繁的觸發FullGC,因為雖然FullGC後可用空間很多,但可用的連續空間並不多。所以一般是設定為0,每次FullGC後整理記憶體碎片。

4、CMS提升FullGC的效能

CMS還有兩個引數可以進一步優化FullGC的效能,降低FullGC的時間。

-XX:+CMSParallelInitialMarkEnabled:開啟這個引數會在CMS垃圾回收器的“初始標記”階段開啟多執行緒併發執行,減少STW的時間,進一步降低FullGC的時間。

-XX:+CMSScavengeBeforeRemark:這個引數會在CMS的重新標記階段之前,先儘量執行一次YoungGC。CMS的重新標記也會STW,所以如果在重新標記之前,先執行一次YoungGC,就會回收掉一些年輕代裡沒有被引用的物件,那麼在CMS的重新標記階段就可以少掃描一些物件,此時就可以提升CMS的重新標記階段的效能,減少這個階段的耗時。(注意:無論是併發標記還是重新標記,都會掃描整個堆的物件,因為就算物件在老年代,也可能被新生代物件引用著)

5、禁用System.gc

在程式碼中,我們可以通過 System.gc() 建議JVM執行一次 FullGC,但JVM不一定會執行。但這個方法不能隨便呼叫,基本上來說是禁止手動 GC 的,因為使用不當很有可能會頻繁觸發 FullGC。

針對這個,我們一般可以通過加入-XX:+DisableExplicitGC 引數來禁止顯示執行GC,就是不允許通過程式碼 System.gc 來觸發GC。

6、元空間優化

FullGC 不只老年代滿了會觸發,元空間配置不當或動態載入的類過多也有可能頻繁觸發 FullGC。

一般可能有如下情況會動態生成類放入Metaspace區域:

  • 比如通過 ASM、CGLib、javassist 等位元組碼框架建立代理類
  • 還有通過反射呼叫時,如Method method = XXX.class.getDeclaredMethod();method.invoke(target, args);,在反射呼叫一定次數後就會動態生成一些類

如果由於元空間導致了 FullGC,我們可以加上-XX:+TraceClassLoading、-XX:+TraceClassUnloading 來觀察有哪些類頻繁的被載入和解除安裝,然後分析出根源問題。

有兩個引數可控制元空間的大小:

  • -XX:MaxMetaspaceSize:設定元空間最大值,預設是 -1,即不限制,只受限於本地記憶體大小
  • -XX:MetaspaceSize:指定元空間的初始空間大小,達到該值就會觸發垃圾回收進行型別解除安裝,同時收集器會對該值進行調整:如果釋放了大量的空間,就適當降低該值;如果釋放了很少的空間,那麼在不超過 -XX:MaxMetaspaceSize的情況下,適當提高該值。

7、JVM引數

-Xms3G
-Xmx3G
-Xmn2G
-Xss1M
-XX:SurvivorRatio=8
-XX:MetaspaceSize=256M
-XX:MaxMetaspaceSize=256M
-XX:MaxTenuringThreshold=5
-XX:PretenureSizeThreshold=1M
-XX:+UseParNewGC
-XX:+UseConcMarkSweepGC
-XX:CMSInitiatingOccupancyFraction=92
-XX:CMSWaitDuration=2000
-XX:+UseCMSInitiatingOccupancyOnly -XX:+UseCMSCompactAtFullCollection -XX:CMSFullGCsBeforeCompaction=0
-XX:+CMSParallelInitialMarkEnabled
-XX:+CMSScavengeBeforeRemark
-XX:+DisableExplicitGC

五、大記憶體機器GC調優

1、使用大記憶體機器的場景

前面通過對支付系統的優化,YoungGC 的頻率為幾分鐘一次,Full GC 基本不會發生。但是像遇到雙十一這樣的大促場景,可能就凌晨那幾分鐘就會增加平時數十倍甚至上百倍的壓力,這個時候如果還是按照4核8G的記憶體來部署,那可能需要上百臺機器。這個時候就可以考慮提升機器的配置,比如提升到16核32G,每臺機器每秒可以扛幾千次請求,這樣就只需要部署十多臺機器可能就夠了。

其實還有類系統比如報表系統、BI系統、資料計算系統、大資料系統,這類系統的核心業務如資料報表,一次請求可能會查詢幾十上百兆資料在記憶體中做計算,如果還是使用小記憶體機器,那麼Eden區將迅速填滿,然後觸發 YoungGC,而且隨著併發壓力增加,需要加更多機器。這種情況下我們一般就可以提高機器配置,使用大記憶體機器來部署了。

總的來說使用大記憶體機器的場景一般就是由於併發量高或每次請求記憶體佔用高導致頻繁YoungGC,然後需要增加很多臺機器的時候,為了減少機器的數量,我們就可以使用大記憶體機器來部署。

2、大記憶體機器的問題

比如使用16核32G的記憶體,假設新生代給20G,那麼Eden區就是16G,Survivor 區各佔2G。按每秒產生50M物件來計算,5分鐘左右就會觸發一次YoungGC。記憶體比之前擴大了10倍,這時如果還是使用 ParNew+CMS這樣的垃圾回收器組合,YoungGC 的停頓時間就需要幾百毫秒甚至一兩秒,這個時候就是每隔幾分鐘卡個幾百毫秒。而且由於長時間卡頓,還會導致請求積壓排隊,嚴重的時候還會導致有些請求超時返回。如果再提高配置,比如使用32核64G,那每次YoungGC就需要停頓幾秒鐘了,這對系統的影響就非常大了。

這個時候就可以使用G1回收器來解決大記憶體YoungGC過慢的問題。我們可以給G1設定一個預期的GC停頓時間,比如100毫秒,這樣G1會保證每次YoungGC停頓時間不超過100毫秒,避免影響使用者的體驗。

不過對於一些後臺執行不直接面向用戶的系統,就算一次GC耗時1秒或幾秒其實影響也不大,這個時候就沒必要用G1回收器了。

3、G1回收器調優

1)G1記憶體佈局

G1 可以使用 -XX:G1NewSizePercent 設定新生代Region初始佔比,預設是5%;使用 -XX:G1MaxNewSizePercent 設定新生代Region最大佔比,預設是 60%。這兩個引數一般不用去設定,使用預設值就可以了。

預設情況下,G1 每個 Region 大小為堆記憶體大小除以2048,取2的N次冥。也可以通過-XX:G1HeapRegionSize 引數設定每個 Region 的大小。

2) GC停頓時間

G1 有一個非常重要的引數會影響到G1回收器的表現:-XX:MaxGCPauseMillis,用來設定一次GC最大的停頓時間。這個引數一般需要結合系統壓測工具、GC日誌、記憶體分析工具來綜合參考,要儘量讓GC的頻率別太高,同時每次GC停頓時間也別太長,達到一個理想的合理值。

G1會隨著系統的執行,不斷給新生代分配Region,但並不是非要到60%時才觸發YoungGC。其實G1到底會分配多少個Region給新生代,多久觸發一次YoungGC,每次耗費多長時間,這些都是不確定的。它整個都是動態的,它會根據預設的停頓時間,給新生代分配一些記憶體,然後到一定程度就觸發YoungGC,把GC時間控制在預設的時間內,避免一次回收過多的Region導致GC停頓時間超出預期,又避免一次回收過少的Region導致頻繁GC。

3)MixedGC 優化

G1 預設在老年代佔比超過45%時,就會觸發 MixedGC。其實優化 MixedGC 最重要的還是優化記憶體分配,儘量避免物件進入老年代,儘量避免頻繁觸發 MixedGC 就行了。

然後還是最核心的-XX:MaxGCPauseMillis 引數,如果這個引數設定過高,導致系統執行很久,然後新生代佔比達到60%了,這個時候可能存活下來的物件放不進Survivor區或者觸發Survivor區動態年齡判斷,就會導致有些物件進入老年代,進而觸發MixedGC。所以就需要合理設定這個引數,保證YoungGC別太頻繁的同時,還得考慮每次GC過後存活的物件大小,避免大量物件進入老年代而觸發 MixedGC。

4)JVM引數

-Xms24G
-Xmx24G
-Xmn20G
-Xss1M
-XX:SurvivorRatio=8
-XX:MetaspaceSize=256M
-XX:MaxMetaspaceSize=256M
-XX:MaxTenuringThreshold=5
-XX:PretenureSizeThreshold=1M
-XX:+UseG1GC 
-XX:+UnlockExperimentalVMOptions 
-XX:G1NewSizePercent=5
-XX:G1MaxNewSizePercent=60
-XX:G1HeapRegionSize=4M
-XX:MaxGCPauseMillis=200 
-XX:ParallelGCThreads=4

六、OOM記憶體溢位問題

在《Java虛擬機器規範》的規定裡,除了程式計數器外,虛擬機器記憶體的其他幾個執行時區域都有發生OutOfMemoryError(OOM)異常的可能。通常而言,記憶體溢位問題對系統是毀滅性的,它代表VM記憶體不足以支撐程式的執行,所以—旦發生這個情況,就會導致系統直接停止運轉,甚至會導致VM程序直接崩潰掉。OOM是非常嚴重的問題,這節就來看下通常有哪些原因導致OOM。

1、元空間溢位

1)元空間溢位原因

Metaspace 這塊區域一般很少發生記憶體溢位,如果發生記憶體溢位—般都是因為兩個原因:

  • Metaspace 引數設定不當,比如Metaspace 記憶體給的太小,就很容易導致Metaspace 不夠用
  • 程式碼中用 CGLib、ASM、javassist 等動態位元組碼技術動態建立一些類,如果程式碼寫的有問題就可能導致生成過多的類而把 Metaspace 塞滿

2)模擬元空間溢位

下面通過CGLib來不斷建立類來模擬塞滿 Metaspace。

首先在 pom.xml 新增 cglib 的依賴:

<dependency>
    <groupId>cglib</groupId>
    <artifactId>cglib</artifactId>
    <version>3.2.4</version>
</dependency>

下面這段程式通過CGLib不斷地建立代理類:

 1 public class GCMain {
 2 
 3     public static void main(String[] args) {
 4         while (true) {
 5             Enhancer enhancer = new Enhancer();
 6             enhancer.setSuperclass(IService.class);
 7             enhancer.setUseCache(false);
 8             enhancer.setCallback(new MethodInterceptor() {
 9                 @Override
10                 public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
11                     return methodProxy.invokeSuper(o, objects);
12                 }
13             });
14             enhancer.create();
15         }
16     }
17 
18     static class IService {    }
19 }

設定如下的JVM引數:元空間固定10M,還添加了追蹤類載入和解除安裝的引數

-Xms200M
-Xmx200M
-Xmn150M
-XX:SurvivorRatio=8
-XX:MetaspaceSize=10M
-XX:MaxMetaspaceSize=10M
-XX:+UseConcMarkSweepGC
-XX:+TraceClassLoading
-XX:+TraceClassUnloading
-XX:+PrintGC
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xloggc:./gc.log

執行程式一會就報OOM錯誤,然後直接退出執行。

從Caused by: java.lang.OutOfMemoryError: Metaspace 可以看出是由於 Metaspace 引起的OOM。而且從上面類載入的追蹤可以看到,程式一直在載入CGLIB動態建立的代理類。

再看下GC日誌:可以看出由於元空間滿了觸發了一次 FullGC。

2、棧溢位

1)棧溢位原因

通過前兩篇文章可以知道,每個執行緒都會有一個執行緒棧,執行緒棧的大小是固定的,比如設定的1MB。這個執行緒每呼叫一個方法,都會將呼叫方法的棧楨壓入執行緒棧裡,方法呼叫結束就彈出棧幀。棧楨會儲存方法的區域性變數、異常表、方法地址等資訊,也是會佔用一定記憶體的。

如果這個執行緒不停的呼叫方法,不停的壓入棧幀,而沒有彈出棧幀,比如遞迴呼叫沒有寫好結束條件,那執行緒棧遲早都會被佔滿,然後導致棧記憶體溢位。一般來說,引發棧記憶體溢位,往往都是程式碼裡寫了一些bug導致的,正常情況下很少發生。

關於虛擬機器棧和本地方法棧,《Java虛擬機器規範》中描述了兩種異常:StackOverflowError 和OutOfMemoryError。

①StackOverflowError

如果執行緒請求的棧深度大於虛擬機器所允許的最大深度,將丟擲 StackOverflowError 異常。棧深度在大多數情況下到達1000~2000是完全沒有問題,對於正常的方法呼叫,這個深度應該完全夠用了。

②OutOfMemoryError

如果虛擬機器的棧記憶體允許動態擴充套件,當擴充套件棧容量無法申請到足夠的記憶體時,將丟擲OutOfMemoryError 異常。而HotSpot虛擬機器是不支援擴充套件的,而且棧深度是動態變化的,在設定執行緒棧大小時(-Xss),如果設定小一些,相應的棧深度就會縮小。

所以HotSpot 虛擬機器棧溢位只會因為棧容量無法容納新的棧幀而導致 StackOverflowError 異常,而不會出現OutOfMemoryError 異常。

2)模擬棧溢位

執行如下這段程式碼:遞迴呼叫recursion 方法,沒有結束條件,所以必定會導致棧溢位

 1 public class GCMain {
 2 
 3     public static void main(String[] args) {
 4         recursion(1);
 5     }
 6 
 7     public static void recursion(int count) {
 8         System.out.println("times: " + count++);
 9         recursion(count);
10     }
11 }

設定如下JVM引數:執行緒棧設定為256K

-Xms200M
-Xmx200M
-Xmn150M
-Xss256K
-XX:SurvivorRatio=8
-XX:MetaspaceSize=10M
-XX:MaxMetaspaceSize=10M

執行一會就出現了 StackOverflowError 異常:

3、堆溢位

1)堆溢位原因

堆記憶體溢位主要就是因為有限的記憶體中放了過多的物件,而且大多數都是存活的,即使GC過後還是大部分都存活,然後堆記憶體無法在放入物件就導致堆記憶體溢位。

—般來說堆記憶體溢位有兩種主要的場景:

  • 系統負載過高,請求量過大,導致大量物件都是存活的,無法繼續放入物件後,就會引發OOM系統崩潰
  • 系統有記憶體洩漏的問題,莫名其妙建立了很多的物件,而且都是存活的,GC時無法回收,最終導致OOM

2)模擬堆溢位

執行如下程式碼:不斷的建立 String 物件,而且都被 datas 引用著無法被回收掉,最終必然會導致OOM。

1 public static void main(String[] args) {
2     Set<String> datas = new HashSet<>();
3     while (true) {
4         datas.add(UUID.randomUUID().toString());
5     }
6 }

設定如下JVM引數:新生代、老年代各100M

-Xms200M
-Xmx200M
-Xmn100M
-Xss1M
-XX:SurvivorRatio=8
-XX:MetaspaceSize=10M
-XX:MaxMetaspaceSize=10M
-XX:+UseParNewGC

OutOfMemoryError:可以看到由於Java heap space 不夠了導致OOM。

4、OOM問題如何解決

要解決OOM問題,首先要知道什麼物件太多導致OOM的,這就需要在OOM時dump記憶體快照。

只需要加入如下啟動引數就可以在OOM時自動dump記憶體快照:

  • -XX:+HeapDumpOnOutOfMemoryError:OOM時自動dump記憶體快照
  • -XX:HeapDumpPath=dump.hprof:快照檔案儲存位置

有了記憶體快照後就可以使用 MAT 這類工具來分析大量建立了哪些物件。

七、效能調優總結

1、調優過程總結

一般來說GC頻率是越少越好,YoungGC的效率很快,FullGC則至少慢10倍以上,所以應儘可能讓物件在年輕代回收掉,減少FullGC的頻率。一般一天只發生幾次FullGC或者幾天發生一次,甚至不發生FullGC才是一個比較良好的JVM效能。

從前面的調優過程可以總結出來,老年代調優的前提是年輕代調優,年輕代調優的前提是合理分配記憶體空間,合理分配記憶體空間的前提就是估算記憶體使用模型。

因此JVM調優的大致思路就是先估算記憶體使用模型,合理分配各代的記憶體空間和比例,儘量讓年輕代存活物件進入Survivor區,讓垃圾物件在年輕代被回收掉,不要進入老年代,減少 FullGC 的頻率。最後就是選擇合適的垃圾回收器。

2、頻繁FullGC的幾種表現

當出現如下情況時,我們就要考慮是不是出現頻繁的FullGC了:

  • 機器 CPU 負載過高
  • 頻繁 FullGC 報警
  • 系統無法處理請求或者處理過慢

CPU負載過高一般就兩個場景:

  • 在系統裡建立了大量的執行緒,這些執行緒同時併發執行,而且工作負載都很重,過多的執行緒同時併發執行就會導致機器CPU負載過高。
  • 機器上執行的VM在執行頻繁的FullGC,FullGC是非常耗費CPU資源的。而且頻繁的FullGC會導致系統時不時的卡死。

3、頻繁FullGC的幾種常見原因

① 系統承載高併發請求,或者處理資料量過大,導致YoungGC很頻繁,而且每次YoungGC過後存活物件太多,記憶體分配不合理,Survivor區域過小,導致物件頻繁進入老年代,頻繁觸發FullGC

② 系統一次性載入過多資料進記憶體,搞出來很多大物件,導致頻繁有大物件進入老年代,然後頻繁觸發FullGC

③ 系統發生了記憶體洩漏,建立大量的物件,始終無法回收,一直佔用在老年代裡,必然頻繁觸發FullGC

④ Metaspace 因為載入類過多觸發FullGC

⑤ 誤呼叫 System.gc() 觸發 FullGC

4、JVM引數模板

通過前面的分析總結,JVM引數雖然沒有固定的標準,但對於一般的系統,我們其實可以總結出一套通用的JVM引數模板,基本上保證JVM的效能不會太差,又不用一個個系統去調優,在某個系統遇到效能問題時,再針對性的去調優就可以了。

對於一般的系統,我們可能使用4核8G的機器來部署,那麼總結一套模板如下:

  • 堆記憶體分配4G,新生代3G,老年代1G,Eden區2.4G,Survivor區各300M,一般來說YoungGC後存活的物件小於150M就沒太大問題
  • 元空間給個512M 一般就足夠了,如果系統會執行時建立很多類,可以調大這個值
  • -XX:CMSFullGCsBeforeCompaction設定為0,每次FullGC後都進行一次記憶體碎片整理
  • -XX:+CMSParallelInitialMarkEnabled,CMS初始標記階段開啟多執行緒併發執行,降低FullGC的時間
  • -XX:+CMSScavengeBeforeRemark,CMS重新標記階段之前,先儘量執行一次Young GC
  • -XX:+DisableExplicitGC,禁止顯示手動GC
  • -XX:+HeapDumpOnOutOfMemoryError,OOM時匯出堆快照便於分析問題
  • -XX:+PrintGC,列印GC日誌便於出問題時分析問題
-Xms4G
-Xmx4G
-Xmn3G
-Xss1M
-XX:SurvivorRatio=8
-XX:MetaspaceSize=512M
-XX:MaxMetaspaceSize=512M
-XX:MaxTenuringThreshold=5
-XX:PretenureSizeThreshold=1M
-XX:+UseParNewGC
-XX:+UseConcMarkSweepGC
-XX:CMSInitiatingOccupancyFraction=92
-XX:+UseCMSInitiatingOccupancyOnly
-XX:CMSWaitDuration=2000
-XX:+UseCMSCompactAtFullCollection
-XX:CMSFullGCsBeforeCompaction=0
-XX:+CMSParallelInitialMarkEnabled
-XX:+CMSScavengeBeforeRemark
-XX:+DisableExplicitGC
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=dump.hprof
-XX:+PrintGC
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xloggc:./gc.log

八、JVM引數

前面已經提到過很多JVM的引數了,這節再簡單彙總下,以及部分不常用的引數。

Java啟動引數共分為三類:

  • 標準引數(-):所有的JVM實現都必須實現這些引數的功能,而且向後相容,如 -version、-classpath
  • 非標準引數(-X):預設jvm實現這些引數的功能,但是並不保證所有jvm實現都滿足,且不保證向後相容,如 -Xms、-Xmx
  • 非Stable引數(-XX):此類引數各個jvm實現會有所不同,將來可能會隨時取消,需要慎重使用,如 -XX:UseParNewGC、-XX:MetaspaceSize

1、JVM標準引數(-)

通過 java -help 命令可以看到JVM的標準引數

2、JVM非標準引數(-X)

通過 java -X 命令可以看到JVM非標準引數

常用引數:

3、JVM非Stable引數(-XX)

JVM非Stable引數分為三類:

  • 功能開關引數:一些功能的開關,用於改變jvm的一些基礎行為
  • 效能調優引數:用於jvm的效能調優
  • 除錯引數:一般用於開啟跟蹤、列印、輸出等jvm引數,用於顯示jvm更加詳細的資訊

注意:帶有加號“+”、減號“-”的引數一般為開關引數,加號就是啟用,減號就是禁用,如 -XX:+/-UseAdaptiveSizePolicy。不帶加減號的就需要通過等號“=”帶上引數值,如 -XX:SurvivorRatio=8。

可以通過設定 -XX:+PrintFlagsFinal 在啟動時列印所有JVM的引數及其值。

1)功能開關引數

① 垃圾回收器相關引數

② 其它的一些引數

2)效能調優引數

3)除錯引數

4、即時編譯調優引數

類初始化完成後,類在呼叫執行過程中,執行引擎會把位元組碼轉為機器碼,然後在作業系統中才能執行。在位元組碼轉換為機器碼的過程中,虛擬機器中還存在著一道編譯,那就是即時編譯。最初,虛擬機器中的位元組碼是由直譯器( Interpreter )完成編譯的,當虛擬機發現某個方法或程式碼塊的執行特別頻繁的時候,就會把這些程式碼認定為“熱點程式碼”。為了提高熱點程式碼的執行效率,在執行時,即時編譯器(JIT)會把這些程式碼編譯成與本地平臺相關的機器碼,並進行各層次的優化,然後儲存到記憶體中。如果沒有 JIT 即時編譯,每次執行相同的程式碼都會使用直譯器編譯。

與編譯優化有關的主要有即時編譯器的選擇、熱點探測計數閥值的優化、方法內聯、逃逸分析、鎖消除、標量替換等,一般來說也不用對編譯進行調優,這裡就不展開說了,下面先列舉下編譯優化相關的一些JVM引數。

參考

本文是學習、參考瞭如下課程,再通過自己的總結和實踐總結而來。如果想了解更多深入的細節,建議閱讀原著。

從 0 開始帶你成為JVM實戰高手

極客時間:Java效能調優實戰

《深入理解Java虛擬機器:JVM高階特性與最佳實踐 第三版》