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