前序文章:
一、內存調優的目標
新生代的垃圾回收是比較簡單的,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、堆外內存溢出
1)堆外內存
Java中還有一塊區域叫直接內存(Direct Memory),也叫堆外內存,它的的容量大小可通過 -XX:MaxDirectMemorySize 參數來指定,如果不指定,則默認與Java堆最大值(-Xmx)一致。
如果想在Java代碼里申請使用一塊堆外內存空間,可以使用 DirectByteBuffer 這個類,然后構建一個 DirectByteBuffer 對象,這個對象本身是在JVM堆內存里的。但是在構建這個對象的同時,就會在堆外內存中划出來一塊內存空間跟這個對象關聯起來。當 DirectByteBuffer 對象沒地方引用了,成了垃圾對象之后,就會在某一次YoungGC或FullGC的時候把 DirectByteBuffer 對象回收掉,然后就可以釋放掉 DirectByteBuffer 關聯的堆外內存了。
2)模擬堆外內存溢出
如果創建了很多的 DirectByteBuffer 對象,占用了大量的堆外內存,而這些 DirectByteBuffer 對象雖然成為了垃圾對象,如果沒有被GC回收掉,那么就不會釋放堆外內存,久而久之,就有可能導致堆外內存溢出。
但是NIO實際上有個機制是當堆外內存快滿了的時候,就調用一次 System.gc() 來建議JVM去執行一次 GC,把垃圾對象回收掉,進而釋放堆外內存。
運行如下代碼:通過 ByteBuffer.allocateDirect 循環分配1M的堆外內存,allocateDirect 內部會構建 DirectByteBuffer 對象。
1 public class GCMain { 2 private static final int _1M = 1024 * 1024; 3 4 public static void main(String[] args) { 5 ByteBuffer byteBuffer; 6 for (int i = 0; i < 40; i++) { 7 byteBuffer = ByteBuffer.allocateDirect(_1M); 8 } 9 } 10 }
設置如下JVM參數:新生代300M,堆外內存最大20M,這樣不會觸發YoungGC。
-Xms500M -Xmx500M -Xmn300M -XX:MaxDirectMemorySize=20M -XX:+PrintGC -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:./gc.log
運行程序后看GC日志:可以看到由於堆外內存不足,NIO調用了兩次 System.gc(),這樣就沒有導致OOM了。
如果我們再加上 -XX:+DisableExplicitGC 參數,禁止調用 System.gc():
-Xms500M -Xmx500M -Xmn300M -XX:MaxDirectMemorySize=20M -XX:+DisableExplicitGC -XX:+PrintGC -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:./gc.log
這時就會發現拋出了堆外內存溢出的異常了:
所以一般來說,如果程序中使用了堆外內存時,為了保險起見,就不要設置 -XX:+DisableExplicitGC 參數了。
5、OOM問題如何解決
1)OOM分析思路
一般來說解決OOM問題大致的思路是類似的,出現OOM時,首先從日志中分析是哪塊區域內存溢出了,然后分析下OOM的線程棧,如果是自己編寫的代碼通過線程棧基本就能看出問題所在。
然后先檢查下內存是否分配合理,是否存在頻繁YoungGC和FullGC,因為如果內存分配不合理就會導致年輕代和老年代迅速占滿或長時間有大量對象存活,那必然很快占滿內存,也有可能導致OOM。
最后可以結合MAT工具分析下堆轉儲快照,堆轉儲包含了堆現場全貌和線程棧信息,可以知道是什么對象太多導致OOM的,然后分析對象引用情況,定位是哪部分代碼導致的內存溢出,找出根源問題所在。
但是分析OOM問題一般來說是比較復雜的,一般線上系統OOM都不是由我們編寫的代碼引發的,可能是由於使用的某個開源框架、容器等導致的,這種就需要了解這個框架,進一步分析其底層源碼才能從根本上了解其原因。
2)堆轉儲快照
加入如下啟動參數就可以在OOM時自動dump內存快照:
- -XX:+HeapDumpOnOutOfMemoryError:OOM時自動dump內存快照
- -XX:HeapDumpPath=dump.hprof:快照文件存儲位置
有了內存快照后就可以使用 MAT 這類工具來分析大量創建了哪些對象。但是對於堆外內存溢出來說,dump的快照文件不會看見什么明顯的異常,這個時候就要注意檢查下程序是不是使用了堆外內存,比如使用了NIO,然后從這方面入手去排查。
七、性能調優總結
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:MaxTenuringThreshold 對象GC年齡調整為5歲,讓長期存活的對象更快的進入老年代
- -XX:PretenureSizeThreshold 大對象閥值設置為1M,如果有超過1M的大對象,可以調整下這個值
- -XX:+UseParNewGC、-XX:+UseConcMarkSweepGC,垃圾回收器使用 ParNew + CMS 的組合
- -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參數。
參考
本文是學習、參考了如下課程,再通過自己的總結和實踐總結而來。如果想了解更多深入的細節,建議閱讀原著。
《深入理解Java虛擬機:JVM高級特性與最佳實踐 第三版》