在《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不斷地創建代理類:
@RequestMapping(value="test003") @ResponseBody public void test003(){ while (true) { Enhancer enhancer = new Enhancer(); enhancer.setSuperclass(IService.class); enhancer.setUseCache(false); enhancer.setCallback(new MethodInterceptor() { @Override public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable { return methodProxy.invokeSuper(o, objects); } }); enhancer.create(); } } static class IService { }
設置如下的JVM參數:元空間固定100M,還添加了追蹤類加載和卸載的參數
-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:D:\data\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 方法,沒有結束條件,所以必定會導致棧溢出
@RequestMapping(value="test004") @ResponseBody public void test004(@RequestParam("count") int count) { System.out.println("times: " + count++); test004(count); }
設置如下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。
@RequestMapping(value="test005") @ResponseBody public void test005() { Set<String> datas = new HashSet<>(); while (true) { datas.add(UUID.randomUUID().toString()); } }
設置如下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 對象。
private static final int _1M = 1024 * 1024; @RequestMapping("/test002") public void test002(){ ByteBuffer byteBuffer; for (int i = 0; i < 40; i++) { byteBuffer = ByteBuffer.allocateDirect(_1M); } }
設置如下JVM參數:新生代300M,堆外內存最大20M,這樣不會觸發YoungGC。
-Xms500M -Xmx500M -Xmn300M -XX:MaxDirectMemorySize=20M -XX:+PrintGC -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:D:\gc.log
運行程序后看GC日志:可以看到由於堆外內存不足,NIO調用了兩次 System.gc(),這樣就沒有導致OOM了。
如果我們再加上 -XX:+DisableExplicitGC 參數,禁止調用 System.gc():
-Xms500M -Xmx500M -Xmn300M -XX:MaxDirectMemorySize=20M -XX:+PrintGC -XX:+DisableExplicitGC -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:D:\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