JVM探究之 —— OOM異常

在Java虛擬機規範的描述中,除了程序計數器外,虛擬機內存的其餘幾個運行時區域都有發生OutOfMemoryError(下文稱OOM)異常的可能。本節探究主要基於jdk1.8的內存結構。java

1. Java堆溢出

 Java堆用於存儲對象實例,只要不斷地建立對象,而且保證GC Roots到對象之間有可達路徑來避免垃圾回收機制清除這些對象,那麼在對象數量到達最大堆的容量限制後就會產生內存溢出異常。windows

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」。bash

要解決這個區域的異常,通常的手段是先經過內存映像分析工具(如Eclipse Memory Analyzer)對Dump出來的堆轉儲快照(在項目目錄下)進行分析,重點是確認內存中的對象是不是必要的,也就是要先分清楚究竟是出現了內存泄漏(Memory Leak)仍是內存溢出(Memory Overflow)。 多線程

分析過程以下:併發

1. 經過mat打開快照文件,選擇運行內存泄漏嫌疑報告app

經過報告上面的餅圖,能夠清晰地看到一個可疑對象消耗了系統 96% 的內存。ide

在餅圖的下方有對這個可疑對象的進一步描述。能夠看到內存是由 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,那就能夠考慮檢查一下是否是這方面的緣由。

相關文章
相關標籤/搜索