在Java虛擬機規范的描述中,除了程序計數器外,虛擬機內存的其他幾個運行時區域都有發生OutOfMemoryError(下文稱OOM)異常的可能。本節探究主要基於jdk1.8的內存結構。
1. Java堆溢出
Java堆用於存儲對象實例,只要不斷地創建對象,並且保證GC Roots到對象之間有可達路徑來避免垃圾回收機制清除這些對象,那么在對象數量到達最大堆的容量限制后就會產生內存溢出異常。
import java.util.ArrayList; import java.util.List; /** * Java堆內存溢出異常測試 * <p> * -Xms20m -Xmx20m -XX:HeapDumpOnOutOfMemoryError */ public class HeapOOM { static class OOMObject { } public static void main(String[] args) { List<OOMObject> list = new ArrayList<OOMObject>(); while (true) { list.add(new OOMObject()); } } }
通過配置VM參數限制Java堆的大小為20MB,不可擴展(將堆的最小值-Xms參數與最大值-Xmx參數設置為一樣即可避免堆自動擴展),通過參數-XX:+HeapDumpOnOutOfMemoryError可以讓虛擬機在出現內存溢出異常時Dump出當前的內存堆轉儲快照以便事后進行分析。
執行結果如下,Java堆內存的OOM異常是實際應用中常見的內存溢出異常情況。當出現Java堆內存溢出時,異常堆棧信息“java.lang.OutOfMemoryError”會跟着進一步提示“Java heap space”。
要解決這個區域的異常,一般的手段是先通過內存映像分析工具(如Eclipse Memory Analyzer)對Dump出來的堆轉儲快照(在項目目錄下)進行分析,重點是確認內存中的對象是否是必要的,也就是要先分清楚到底是出現了內存泄漏(Memory Leak)還是內存溢出(Memory Overflow)。
分析過程如下:
1. 通過mat打開快照文件,選擇運行內存泄漏嫌疑報告
通過報告上面的餅圖,可以清晰地看到一個可疑對象消耗了系統 96% 的內存。
在餅圖的下方有對這個可疑對象的進一步描述。可以看到內存是由 java.lang.Object[]的數組實例消耗的,system class loader 負責這個對象的加載。通過描述可以了解到一些線索,比如是哪個類占用了絕大多數的內存,它屬於哪個組件等等。
因此需要分析問題的原因,為什么一個 Object[]會占據了系統 99% 的內存?誰阻止了垃圾回收機制對它的回收?
回顧下 JAVA 的內存回收機制,內存空間中垃圾回收的工作由垃圾回收器 (Garbage Collector,GC) 完成的,它的核心思想是:對虛擬機可用內存空間,即堆空間中的對象進行識別,如果對象正在被引用,那么稱其為存活對象,反之,如果對象不再被引用,則為垃圾對象,可以回收其占據的空間,用於再分配。
在垃圾回收機制中有一組元素被稱為根元素集合,它們是一組被虛擬機直接引用的對象,比如,正在運行的線程對象,系統調用棧里面的對象以及被 system class loader 所加載的那些對象。堆空間中的每個對象都是由一個根元素為起點被層層調用的。因此,一個對象還被某一個存活的根元素所引用,就會被認為是存活對象,不能被回收,進行內存釋放。因此,可以通過分析一個對象到根元素的引用路徑來分析為什么該對象不能被順利回收。如果說一個對象已經不被任何程序邏輯所需要但是還存在被根元素引用的情況,可以說這里存在內存泄露。
2. 具體分析
點擊“Details ”鏈接,查看對可疑對象 的詳細分析報告。
查看下從 GC 根元素到內存消耗聚集點的最短路徑,在Shortest Paths To the Accumulation Point(GC root到聚集點的最短路徑,就是持有可能泄漏內存對象的最近一層)的列表中,可以追溯到問題代碼的類樹的結構,並找到自己代碼中的類。 在列表中,有兩列Shallow Heap和Retained Heap。Shallow Heap指的是就是對象本身占用內存的大小,不包含對其他對象的引用,也就是對象頭加成員變量(不是成員變量的值)的總和。Retained Heap指的是該對象自己的Shallow Heap,加上從該對象能直接或間接訪問到對象的Shallow Heap之和。換句話說,Retained Heap是該對象被GC之后所能回收到內存的總和。
可以很清楚的看到整個引用鏈,內存聚集點是一個擁有大量對象的集合。
接下來,再繼續看看,這個對象集合里到底存放了什么,為什么會消耗掉如此多的內存。在Accumulated Objects in Dominator Tree列表中,可以查看創建的大量的對象的聚集詳情,即完整的reference chain 。
在這張圖上,我們可以清楚的看到,這個對象集合中保存了大量 OOMObject對象的引用,就是它導致的泄露。
如果確定為內存泄露,可進一步通過工具查看泄露對象到GC Roots的引用鏈。於是就能找到泄露對象是通過怎樣的路徑與GC Roots相關聯並導致垃圾收集器無法自動回收它們的。掌握了泄露對象的類型信息及GC Roots引用鏈的信息,就可以比較准確地定位出泄露代碼的位置。
如果不存在泄露,換句話說,就是內存中的對象確實都還必須存活着,那就應當檢查虛擬機的堆參數(-Xmx與-Xms),與機器物理內存對比看是否還可以調大,從代碼上檢查是否存在某些對象生命周期過長、持有狀態時間過長的情況,嘗試減少程序運行期的內存消耗。
2. 虛擬機棧和本地方法棧溢出
由於在HotSpot虛擬機中並不區分虛擬機棧和本地方法棧,因此,對於HotSpot來說,雖然-Xoss參數(設置本地方法棧大小)存在,但實際上是無效的,棧容量只由-Xss參數設定。關於虛擬機棧和本地方法棧,在Java虛擬機規范中描述了兩種異常:
- 如果線程請求的棧深度大於虛擬機所允許的最大深度,將拋出StackOverflowError異常。
- 如果虛擬機在擴展棧時無法申請到足夠的內存空間,則拋出OutOfMemoryError異常。
這里把異常分成兩種情況,看似更加嚴謹,但卻存在着一些互相重疊的地方:當棧空間無法繼續分配時,到底是內存太小,還是已使用的棧空間太大,其本質上只是對同一件事情的兩種描述而已。
定義大量的本地變量,增大此方法幀中本地變量表的長度或者設置-Xss參數減少棧內存容量,這兩種操作都會拋出StackOverflowError異常。
/** * 虛擬機棧SOF測試 * <p> * -Xss128k */ 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; } } }
運行結果如下,拋出StackOverflowError異常時輸出的堆棧深度相應縮小。
所以,如果在單線程的情況下,無論是棧幀太大還是虛擬機棧容量太小,當內存無法再分配的時候,虛擬機拋出的是StackOverflowError異常。
如果在多線程下,不斷地建立線程可能會產生OutOfMemoryError異常。
/** * 創建線程導致內存溢出異常 注意:windows平台下執行可能會導致系統卡死 * -Xss2M */ public class JavaVMStackOOM { private void dontStop(){ while(true){} } public void stackLeakByThread(){ while(true){ Thread thread = new Thread(new Runnable() { @Override public void run() { dontStop(); } }); thread.start(); } } public static void main(String[] args) { JavaVMStackOOM oom = new JavaVMStackOOM(); oom.stackLeakByThread(); } }
運行結果如下:
Exception in thread"main"java.lang.OutOfMemoryError:unable to create new native thread
上面代碼導致OOM的原因不難理解,操作系統分配給每個進程的內存是有限制的,譬如32位的Windows限制為2GB。虛擬機提供了參數來控制Java堆和方法區的這兩部分內存的最大值。剩余的內存為2GB(操作系統限制)減去Xmx(最大堆容量),再減去MaxPermSize(最大方法區容量),程序計數器消耗內存很小,可以忽略掉。如果虛擬機進程本身耗費的內存不計算在內,剩下的內存就由虛擬機棧和本地方法棧“瓜分”了。每個線程分配到的棧容量越大,可以建立的線程數量自然就越少,建立線程時就越容易把剩下的內存耗盡;64位的Windows限制為8TB,理論上是可以創建很多線程的,但是,誰的機器內存有8TB??所以,在其他系統如Linux,創建多線程時,盡管未達到進程的內存限制,往往也會達到機器的最大內存,導致OOM。
在開發多線程的應用時特別注意,出現StackOverflowError異常時有錯誤堆棧可以閱讀,相對來說,比較容易找到問題的所在。而且,如果使用虛擬機默認參數,棧深度在大多數情況下(因為每個方法壓入棧的幀大小並不是一樣的,所以只能說在大多數情況下)達到1000~2000完全沒有問題,對於正常的方法調用(包括遞歸),這個深度應該完全夠用了。但是,如果是建立過多線程導致的內存溢出,在不能減少線程數或者更換64位虛擬機的情況下,就只能通過減少最大堆和減少棧容量來換取更多的線程。
3. 方法區和運行時常量池溢出
String.intern()是一個Native方法,它的作用是:如果字符串常量池中已經包含一個等於此String對象的字符串,則返回代表池中這個字符串的String對象;否則,將此String對象包含的字符串添加到常量池中,並且返回此String對象的引用。
import java.util.ArrayList; import java.util.List; /** * 運行時常量池導致的內存溢出異常*/ public class RuntimeConstantPoolOOM { public static void main(String[] args) { //使用List保持常量池引用,避免Full GC回收常量池行為 List<String> list = new ArrayList<String>(); //10M的PermSize在integer范圍內足夠產生OOM int i = 0; while (true){ list.add(String.valueOf(i++).intern()); } } }
在JDK 1.6及之前的版本中,由於常量池分配在永久代內,我們可以通過-XX:PermSize和-XX:MaxPermSize限制方法區(HotSpot虛擬機中的永久代)大小,從而間接限制其中常量池的容量。
JDK 1.6通過設置VM參數設置永久代大小 -XX:PermSize=10M -XX:MaxPermSize=10M,運行結果如下:
報錯信息為永久代溢出,說明JDK1.6時運行時常量池在永久代。
JDK 1.7設置VM參數 -Xmx20m -Xms20m -XX:-UseGCOverheadLimit,這里的-XX:-UseGCOverheadLimit是關閉GC占用時間過長時會報的異常,然后限制堆的大小 -Xmx20m -Xms20m 。
報錯信息為堆內存溢出,原因是增加的常量都放到了堆中,所以限制堆內存以后,不斷增加常量,導致堆內存溢出。說明JDK1.7時運行時常量池在堆中。
在JDK1.8中測試,設置VM參數 -Xmx20m -Xms20m -XX:-UseGCOverheadLimit,結果和JDK1.7相同。
補充一點:如果在上面的JDK 1.7或者JDK1.8中不通過VM參數 -XX:-UseGCOverheadLimit關閉GC占用時間過長時報的異常,即只設置VM參數 -Xmx20m -Xms20m ,執行結果如下:
並行/並發回收器在GC回收時間過長時會拋出OutOfMemroyError。過長的定義是,超過98%的時間用來做GC並且回收了不到2%的堆內存。用來避免內存過小造成應用不能正常工作。
由此可證明,在JDK1.2 ~ JDK6的實現中,HotSpot使用永久代實現方法區,從JDK7開始Oracle HotSpot開始移除永久代,JDK7中符號表被移動到Native Heap中,字符串常量和類引用被移動到Java Heap中。在JDK8中,字符串常量依然在堆中,“永久代”完全被元空間(Meatspace)所取代。
運行如下一段代碼測試String.intern()的返回引用
public class InternMethodTest { 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的相關信息,如類名、訪問修飾符、常量池、字段描述、方法描述等。對於這些區域的測試,基本的思路是運行時產生大量的類去填滿方法區,直到溢出。
方法區溢出也是一種常見的內存溢出異常,一個類要被垃圾收集器回收掉,判定條件是比較苛刻的。在經常動態生成大量Class的應用中,需要特別注意類的回收狀況。這類場景除了上面提到的程序使用了CGLib字節碼增強和動態語言之外,常見的還有:大量JSP或動態產生JSP文件的應用(JSP第一次運行時需要編譯為Java類)、基於OSGi的應用(即使是同一個類文件,被不同的加載器加載也會視為不同的類)等。
4. 本機直接內存溢出
DirectMemory容量可通過-XX:MaxDirectMemorySize指定,如果不指定,則默認與Java堆最大值(-Xmx指定)一樣,下面代碼越過了DirectByteBuffer類,直接通過反射獲取Unsafe實例進行內存分配(Unsafe類的getUnsafe()方法限制了只有引導類加載器才會返回實例,也就是設計者希望只有rt.jar中的類才能使用Unsafe的功能)。因為,雖然使用DirectByteBuffer分配內存也會拋出內存溢出異常,但它拋出異常時並沒有真正向操作系統申請分配內存,而是通過計算得知內存無法分配,於是手動拋出異常,真正申請分配內存的方法是unsafe.allocateMemory()。
import sun.misc.Unsafe; import java.lang.reflect.Field; /** * 使用unsafe分配本機內存 * -Xmx20M -XX:MaxDirectMemorySize=10M*/ public class DirectMemoryOOM { private static final int _1MB = 1024 * 1024; public static void main(String[] args) throws Exception { Field unsafeField = Unsafe.class.getDeclaredFields()[0]; unsafeField.setAccessible(true); Unsafe unsafe = (Unsafe) unsafeField.get(null); while (true) { unsafe.allocateMemory(_1MB); } } }
上面代碼運行結果如下:
由DirectMemory導致的內存溢出,一個明顯的特征是在Heap Dump文件中不會看見明顯的異常,如果讀者發現OOM之后Dump文件很小,而程序中又直接或間接使用了NIO,那就可以考慮檢查一下是不是這方面的原因。