【深入Java虚拟机JVM 04】JVM内存溢出OutOfMemoryError异常实例
說明:文章所有內(nèi)容均摘自《深入理解Java虛擬機:JVM高級特性與最佳實踐(第二版)》
?
在Java虛擬機規(guī)范的描述中,除了程序計數(shù)器外,虛擬機內(nèi)存的其他幾個運行時區(qū)域都有發(fā)生OutOfMemoryError(下文稱OOM)異常的可能。
目的有兩個:
備注:下文代碼的開頭都注釋了執(zhí)行時所需要設置的虛擬機啟動參數(shù)(注釋中“VM Args”后面跟著的參數(shù)),這些參數(shù)對實驗的結(jié)果有直接影響,讀者調(diào)試代碼的時候千萬不要忽略。下文的代碼都是基于Sun公司的HotSpot虛擬機運行的,對于不同公司的不同版本的虛擬機,參數(shù)和程序運行的結(jié)果可能會有所差別。
1.1 Java堆溢出
Java堆用于存儲對象實例,只要不斷地創(chuàng)建對象,并且保證GC Roots到對象之間有可達路徑來避免垃圾回收機制清除這些對象,那么在對象數(shù)量到達最大堆的容量限制后就會產(chǎn)生內(nèi)存溢出異常。
代碼清單2-3中代碼限制Java堆的大小為20MB,不可擴展(將堆的最小值-Xms參數(shù)與最大值-Xmx參數(shù)設置為一樣即可避免堆自動擴展),通過參數(shù)-XX:+HeapDumpOnOutOfMemoryError可以讓虛擬機在出現(xiàn)內(nèi)存溢出異常時Dump出當前的內(nèi)存堆轉(zhuǎn)儲快照以便事后進行分析 。
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ?代碼清單2-3 Java堆內(nèi)存溢出異常測試
/** *VM Args:-Xms20m-Xmx20m-XX:+HeapDumpOnOutOfMemoryError *@author zzm */public class HeapOOM{static class OOMObject{}public static void main(String[]args){List<OOMObject>list=new ArrayList<OOMObject>();while(true){list.add(new OOMObject());}}}運行結(jié)果:
java.lang.OutOfMemoryError:Java heap space Dumping heap to java_pid3404.hprof…… Heap dump file created[22045981 bytes in 0.663 secs]
Java堆內(nèi)存的OOM異常是實際應用中常見的內(nèi)存溢出異常情況。當出現(xiàn)Java堆內(nèi)存溢出時,異常堆棧信息“java.lang.OutOfMemoryError”會跟著進一步提示“Java heap space”。要解決這個區(qū)域的異常,一般的手段是先通過內(nèi)存映像分析工具(如Eclipse ?Memory Analyzer)對Dump出來的堆轉(zhuǎn)儲快照進行分析,重點是確認內(nèi)存中的對象是否是必要的,也就是要先分清楚到底是出現(xiàn)了內(nèi)存泄漏(Memory ?Leak)還是內(nèi)存溢出(Memory Overflow)。
如果是內(nèi)存泄露(對象不該存在而存在):?可進一步通過工具查看泄露對象到GC Roots的引用鏈。于是就能找到泄露對象是通過怎樣的路徑與GC Roots相關(guān)聯(lián)并導致垃圾收集器無法自動回收它們的。掌握了泄露對象的類型信息及GC ?Roots引用鏈的信息,就可以比較準確地定位出泄露代碼的位置。
如果不存在泄露(對象確實應該存在): 也就是內(nèi)存中的對象確實都還必須存活著,那就應當檢查虛擬機的堆參數(shù)(-Xmx與-Xms),與機器物理內(nèi)存對比看是否還可以調(diào)大,從代碼上檢查是否存在某些對象生命周期過長、持有狀態(tài)時間過長的情況,嘗試減少程序運行期的內(nèi)存消耗。
以上是處理Java堆內(nèi)存問題的簡單思路。
圖2-5顯示了使用Eclipse Memory Analyzer打開的堆轉(zhuǎn)儲快照文件。
1.2 虛擬機棧和本地方法棧溢出
由于在HotSpot虛擬機中并不區(qū)分虛擬機棧和本地方法棧,因此,對于HotSpot來說,雖然-Xoss參數(shù)(設置本地方法棧大小)存在,但實際上是無效的,棧容量只由-Xss參數(shù)設定。關(guān)于虛擬機棧和本地方法棧,在Java虛擬機規(guī)范中描述了兩種異常:
- 如果線程請求的棧深度大于虛擬機所允許的最大深度,將拋出StackOverflowError異常。
- 如果虛擬機在擴展棧時無法申請到足夠的內(nèi)存空間,則拋出OutOfMemoryError異常。
這里把異常分成兩種情況,看似更加嚴謹,但卻存在著一些互相重疊的地方:當棧空間無法繼續(xù)分配時,到底是內(nèi)存太小,還是已使用的棧空間太大,其本質(zhì)上只是對同一件事情的兩種描述而已。
在筆者的實驗中,將實驗范圍限制于單線程中的操作,嘗試了下面兩種方法均無法讓虛擬機產(chǎn)生OutOfMemoryError異常,嘗試的結(jié)果都是獲得StackOverflowError異常,測試代碼如代碼清單2-4所示。
使用-Xss參數(shù)減少棧內(nèi)存容量。結(jié)果:拋出StackOverflowError異常,異常出現(xiàn)時輸出的堆棧深度相應縮小。
定義了大量的本地變量,增大此方法幀中本地變量表的長度。結(jié)果:拋出StackOverflowError異常時輸出的堆棧深度相應縮小。
代碼清單2-4 虛擬機棧和本地方法棧OOM測試(僅作為第1點測試程序)
/***VM Args:-Xss128k*@author zzm*/public class JavaVMStackSOF{private int stackLength=1;public void stackLeak(){stackLength++;stackLeak();}public static void main(String[]args)throws Throwable{JavaVMStackSOF oom=new JavaVMStackSOF();try{oom.stackLeak();}catch(Throwable e){System.out.println("stack length:"+oom.stackLength);throw e;}}}運行結(jié)果: stack length:2402 Exception in thread"main"java.lang.StackOverflowError at org.fenixsoft.oom.VMStackSOF.leak(VMStackSOF.java:20) at org.fenixsoft.oom.VMStackSOF.leak(VMStackSOF.java:21) at org.fenixsoft.oom.VMStackSOF.leak(VMStackSOF.java:21) ……后續(xù)異常堆棧信息省略?
實驗結(jié)果表明:在單個線程下,無論是由于棧幀太大還是虛擬機棧容量太小,當內(nèi)存無法分配的時候,虛擬機拋出的都是StackOverflowError異常。
如果測試時不限于單線程,通過不斷地建立線程的方式倒是可以產(chǎn)生內(nèi)存溢出異常,如代碼清單2-5所示。但是這樣產(chǎn)生的內(nèi)存溢出異常與棧空間是否足夠大并不存在任何聯(lián)系,或者準確地說,在這種情況下,為每個線程的棧分配的內(nèi)存越大,反而越容易產(chǎn)生內(nèi)存溢出異常。
忽略程序計數(shù)器和虛擬機本身內(nèi)存消耗,則棧內(nèi)容計算如下:
棧(JVM棧和本地方法棧)可用內(nèi)存 = 系統(tǒng)分配進程內(nèi)存 - 堆最大容量 - 方法區(qū)最大容量?
其實原因不難理解,操作系統(tǒng)分配給每個進程的內(nèi)存是有限制的,譬如32位的Windows限制為2GB。虛擬機提供了參數(shù)來控制Java堆和方法區(qū)的這兩部分內(nèi)存的最大值。剩余的內(nèi)存為2GB(操作系統(tǒng)限制)減去Xmx(最大堆容量),再減去MaxPermSize(最大方法區(qū)容量),程序計數(shù)器消耗內(nèi)存很小,可以忽略掉。如果虛擬機進程本身耗費的內(nèi)存不計算在內(nèi),剩下的內(nèi)存就由虛擬機棧和本地方法棧“瓜分”了。每個線程分配到的棧容量越大,可以建立的線程數(shù)量自然就越少,建立線程時就越容易把剩下的內(nèi)存耗盡。
這一點讀者需要在開發(fā)多線程的應用時特別注意,出現(xiàn)StackOverflowError異常時有錯誤堆棧可以閱讀,相對來說,比較容易找到問題的所在。而且,如果使用虛擬機默認參數(shù),棧深度在大多數(shù)情況下(因為每個方法壓入棧的幀大小并不是一樣的,所以只能說在大多數(shù)情況下)達到1000~2000完全沒有問題,對于正常的方法調(diào)用(包括遞歸),這個深度應該完全夠用了。但是,如果是建立過多線程導致的內(nèi)存溢出,在不能減少線程數(shù)或者更換64位虛擬機的情況下,就只能通過減少最大堆和減少棧容量來換取更多的線程。
代碼清單2-5 創(chuàng)建線程導致內(nèi)存溢出異常
注意:特別提示一下,如果讀者要嘗試運行下面這段代碼,記得要先保存當前的工作。由于在Windows平臺的虛擬機中,Java的線程是映射到操作系統(tǒng)的內(nèi)核線程上的 [1] ,因此上述代碼執(zhí)行時有較大的風險,可能會導致操作系統(tǒng)假死。
/***VM Args:-Xss2M(這時候不妨設置大些)*@author zzm*/public class JavaVMStackOOM{private void dontStop(){while(true){}}public void stackLeakByThread(){while(true){Thread thread=new Thread(new Runnable(){@Overridepublic void run(){dontStop();}});thread.start();}}public static void main(String[]args)throws Throwable{JavaVMStackOOM oom=new JavaVMStackOOM();oom.stackLeakByThread();}}運行結(jié)果: Exception in thread"main"java.lang.OutOfMemoryError:unable to create new native thread1.3 方法區(qū)和運行時常量池溢出
由于運行時常量池是方法區(qū)的一部分,因此這兩個區(qū)域的溢出測試就放在一起進行。前面提到JDK 1.7開始逐步“去永久代”的事情,在此就以測試代碼觀察一下這件事對程序的實際影響。
------------------------------------------------------常量池測試------------------------------------------------------
String.intern()是一個Native方法,它的作用是:如果字符串常量池中已經(jīng)包含一個等于此String對象的字符串,則返回代表池中這個字符串的String對象;否則,將此String對象包含的字符串添加到常量池中,并且返回此String對象的引用。在JDK 1.6及之前的版本中,由于常量池分配在永久代內(nèi),我們可以通過-XX:PermSize和-XX:MaxPermSize限制方法區(qū)大小,從而間接限制其中常量池的容量,如代碼清單2-6所示。
代碼清單2-6 運行時常量池導致的內(nèi)存溢出異常
/***VM Args:-XX:PermSize=10M-XX:MaxPermSize=10M*@author zzm*/public class RuntimeConstantPoolOOM{public static void main(String[]args){//使用List保持著常量池引用,避免Full GC回收常量池行為List<String>list=new ArrayList<String>();//10MB的PermSize在integer范圍內(nèi)足夠產(chǎn)生OOM了int i=0;while(true){list.add(String.valueOf(i++).intern());}}}運行結(jié)果: Exception in thread"main"java.lang.OutOfMemoryError:PermGen space at java.lang.String.intern(Native Method) at org.fenixsoft.oom.RuntimeConstantPoolOOM.main(RuntimeConstantPoolOOM.java:18)從運行結(jié)果中可以看到,運行時常量池溢出,在OutOfMemoryError后面跟隨的提示信息是“PermGen ?space”,說明運行時常量池屬于方法區(qū)(HotSpot虛擬機中的永久代)的一部分。
而使用JDK 1.7運行這段程序就不會得到相同的結(jié)果,while循環(huán)將一直進行下去。關(guān)于這個字符串常量池的實現(xiàn)問題,還可以引申出一個更有意思的影響,如代碼清單2-7所示。
代碼清單2-7 String.intern()返回引用的測試
public class RuntimeConstantPoolOOM{public static void main(String[]args){public static void main(String[]args){String str1=new StringBuilder("計算機").append("軟件").toString();System.out.println(str1.intern()==str1);String str2=new StringBuilder("ja").append("va").toString();System.out.println(str2.intern()==str2);}}}在JDK 1.6中運行:?會得到兩個false。
在JDK 1.7中運行:會得到一個true和一個false。
產(chǎn)生差異的原因是:
在JDK 1.6中,intern()方法會把首次遇到的字符串實例復制到永久代中,返回的也是永久代中這個字符串實例的引用,而由StringBuilder創(chuàng)建的字符串實例在Java堆上,所以必然不是同一個引用,將返回false。
而JDK ?1.7(以及部分其他虛擬機,例如JRockit)的intern()實現(xiàn)不會再復制實例,只是在常量池中記錄首次出現(xiàn)的實例引用,因此intern()返回的引用和由StringBuilder創(chuàng)建的那個字符串實例是同一個。對str2比較返回false是因為“java”這個字符串在執(zhí)行StringBuilder.toString()之前已經(jīng)出現(xiàn)過,字符串常量池中已經(jīng)有它的引用了,不符合“首次出現(xiàn)”的原則,而“計算機軟件”這個字符串則是首次出現(xiàn)的,因此返回true。
?
-----------------------------------------------方法區(qū)測試-----------------------------------------------------------
方法區(qū)用于存放Class的相關(guān)信息,如類名、訪問修飾符、常量池、字段描述、方法描述等。對于這些區(qū)域的測試,基本的思路是運行時產(chǎn)生大量的類去填滿方法區(qū),直到溢出。雖然直接使用Java SE API也可以動態(tài)產(chǎn)生類(如反射時的GeneratedConstructorAccessor和動態(tài)代理等),但在本次實驗中操作起來比較麻煩。在代碼清單2-8中,筆者借助CGLib 直接操作字節(jié)碼運行時生成了大量的動態(tài)類。
代碼清單2-8 借助CGLib使方法區(qū)出現(xiàn)內(nèi)存溢出異常
/***VM Args:-XX:PermSize=10M-XX:MaxPermSize=10M*@author zzm*/public class JavaMethodAreaOOM{public static void main(String[]args){while(true){Enhancer enhancer=new Enhancer();enhancer.setSuperclass(OOMObject.class);enhancer.setUseCache(false);enhancer.setCallback(new MethodInterceptor(){public Object intercept(Object obj,Method method,Object[]args,MethodProxy proxy)throws Throwable{return proxy.invokeSuper(obj,args);}});enhancer.create();}}static class OOMObject{}}運行結(jié)果: Caused by:java.lang.OutOfMemoryError:PermGen space at java.lang.ClassLoader.defineClass1(Native Method) at java.lang.ClassLoader.defineClassCond(ClassLoader.java:632) at java.lang.ClassLoader.defineClass(ClassLoader.java:616) ……8 more值得特別注意的是,該例子中模擬的場景經(jīng)常會出現(xiàn)在實際應用中:當前的很多主流框架,如Spring、Hibernate,在對類進行增強時,都會使用到CGLib這類字節(jié)碼技術(shù),增強的類越多,就需要越大的方法區(qū)來保證動態(tài)生成的Class可以加載入內(nèi)存。另外,JVM上的動態(tài)語言(例如Groovy等)通常都會持續(xù)創(chuàng)建類來實現(xiàn)語言的動態(tài)性,隨著這類語言的流行,也越來越容易遇到與代碼清單2-8相似的溢出場景。
方法區(qū)溢出也是一種常見的內(nèi)存溢出異常,一個類要被垃圾收集器回收掉,判定條件是比較苛刻的。在經(jīng)常動態(tài)生成大量Class的應用中,需要特別注意類的回收狀況。這類場景除了上面提到的程序使用了CGLib字節(jié)碼增強和動態(tài)語言之外,常見的還有:大量JSP或動態(tài)產(chǎn)生JSP文件的應用(JSP第一次運行時需要編譯為Java類)、基于OSGi的應用(即使是同一個類文件,被不同的加載器加載也會視為不同的類)等。
?
1.4 本機直接內(nèi)存溢出
DirectMemory容量可通過-XX:MaxDirectMemorySize指定,如果不指定,則默認與Java堆最大值(-Xmx指定)一樣,代碼清單2-9越過了DirectByteBuffer類,直接通過反射獲取Unsafe實例進行內(nèi)存分配。因為,雖然使用DirectByteBuffer分配內(nèi)存也會拋出內(nèi)存溢出異常,但它拋出異常時并沒有真正向操作系統(tǒng)申請分配內(nèi)存,而是通過計算得知內(nèi)存無法分配,于是手動拋出異常,真正申請分配內(nèi)存的方法是unsafe.allocateMemory()。
Unsafe類的getUnsafe()方法限制了只有引導類加載器才會返回實例,也就是設計者希望只有rt.jar中的類才能使用Unsafe的功能
代碼清單2-9 使用unsafe分配本機內(nèi)存
由DirectMemory導致的內(nèi)存溢出,一個明顯的特征是在Heap Dump文件中不會看見明顯的異常,如果讀者發(fā)現(xiàn)OOM之后Dump文件很小,而程序中又直接或間接使用了NIO,那就可以考慮檢查一下是不是這方面的原因。
?
總結(jié)
以上是生活随笔為你收集整理的【深入Java虚拟机JVM 04】JVM内存溢出OutOfMemoryError异常实例的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 【Spring注解系列04】@Condi
- 下一篇: 【深入Java虚拟机JVM 05】Hot