JVM系列之實戰記憶體溢位異常

我又不是架構師發表於2017-11-23

實戰記憶體溢位異常

大家好,相信大部分Javaer在code時經常會遇到原生程式碼執行正常,但在生產環境偶爾會莫名其妙的報一些關於記憶體的異常,StackOverFlowError,OutOfMemoryError異常是最常見的。今天就基於上篇文章JVM系列之Java記憶體結構詳解講解的各個記憶體區域重點實戰分析下記憶體溢位的情況。在此之前,我還是想多餘累贅一些其他關於物件的問題,具體內容如下:

文章結構

  1. 物件的建立過程
  2. 物件的記憶體佈局
  3. 物件的訪問定位
  4. 實戰記憶體異常

1 . 物件的建立過程

關於物件的建立,第一反應是new關鍵字,那麼本文就主要講解new關鍵字建立物件的過程。

Student stu =new Student("張三""18");複製程式碼

就拿上面這句程式碼來說,虛擬機器首先會去檢查Student這個類有沒有被載入,如果沒有,首先去載入這個類到方法區,然後根據載入的Class類物件建立stu例項物件,需要注意的是,stu物件所需的記憶體大小在Student類載入完成後便可完全確定。記憶體分配完成後,虛擬機器需要將分配到的記憶體空間的例項資料部分初始化為零值,這也就是為什麼我們在編寫Java程式碼時建立一個變數不需要初始化。緊接著,虛擬機器會對物件的物件頭進行必要的設定,如這個物件屬於哪個類,如何找到類的後設資料(Class物件),物件的鎖資訊,GC分代年齡等。設定完物件頭資訊後,呼叫類的建構函式。
其實講實話,虛擬機器建立物件的過程遠不止這麼簡單,我這裡只是把大致的脈絡講解了一下,方便大家理解。

2 . 物件的記憶體佈局

剛剛提到的例項資料,物件頭,有些小夥伴也許有點陌生,這一小節就詳細講解一下物件的記憶體佈局,物件建立完成後大致可以分為以下幾個部分:

  • 物件頭
  • 例項資料
  • 對齊填充

物件頭: 物件頭中包含了物件執行時一些必要的資訊,如GC分代資訊,鎖資訊,雜湊碼,指向Class類元資訊的指標等,其中對Javaer比較有用的是鎖資訊與指向Class物件的指標,關於鎖資訊,後期有機會講解併發程式設計JUC時再擴充套件,關於指向Class物件的指標其實很好理解。比如上面那個Student的例子,當我們拿到stu物件時,呼叫Class stuClass=stu.getClass();的時候,其實就是根據這個指標去拿到了stu物件所屬的Student類在方法區存放的Class類物件。雖然說的有點拗口,但這句話我反覆琢磨了好幾遍,應該是說清楚了。^_^

例項資料:例項資料部分是物件真正儲存的有效資訊,就是程式程式碼中所定義的各種型別的欄位內容。

對齊填充:虛擬機器規範要求物件大小必須是8位元組的整數倍。對齊填充其實就是來補全物件大小的。

3 . 物件的訪問定位

談到物件的訪問,還拿上面學生的例子來說,當我們拿到stu物件時,直接呼叫stu.getName();時,其實就完成了對物件的訪問。但這裡要累贅說一下的是,stu雖然通常被認為是一個物件,其實準確來說是不準確的,stu只是一個變數,變數裡儲存的是指向物件的指標,(如果幹過C或者C++的小夥伴應該比較清楚指標這個概念),當我們呼叫stu.getName()時,虛擬機器會根據指標找到堆裡面的物件然後拿到例項資料name.需要注意的是,當我們呼叫stu.getClass()時,虛擬機器會首先根據stu指標定位到堆裡面的物件,然後根據物件頭裡面儲存的指向Class類元資訊的指標再次到方法區拿到Class物件,進行了兩次指標尋找。具體講解圖如下:

4 .實戰記憶體異常

記憶體異常是我們工作當中經常會遇到問題,但如果僅僅會通過加大記憶體引數來解決問題顯然是不夠的,應該通過一定的手段定位問題,到底是因為引數問題,還是程式問題(無限建立,記憶體洩露)。定位問題後才能採取合適的解決方案,而不是一記憶體溢位就查詢相關引數加大。

概念

  • 記憶體洩露:程式碼中的某個物件本應該被虛擬機器回收,但因為擁有GCRoot引用而沒有被回收。關於GCRoot概念,下一篇文章講解。
  • 記憶體溢位: 虛擬機器由於堆中擁有太多不可回收物件沒有回收,導致無法繼續建立新物件。

在分析問題之前先給大家講一講排查記憶體溢位問題的方法,記憶體溢位時JVM虛擬機器會退出,那麼我們怎麼知道JVM執行時的各種資訊呢,Dump機制會幫助我們,可以通過加上VM引數-XX:+HeapDumpOnOutOfMemoryError讓虛擬機器在出現記憶體溢位異常時生成dump檔案,然後通過外部工具(作者使用的是VisualVM)來具體分析異常的原因。

下面從以下幾個方面來配合程式碼實戰演示記憶體溢位及如何定位:

  • Java堆記憶體異常
  • Java棧記憶體異常
  • 方法區記憶體異常

Java堆記憶體異常

/**
    VM Args:
    //這兩個引數保證了堆中的可分配記憶體固定為20M
    -Xms20m
    -Xmx20m  
    //檔案生成的位置,作則生成在桌面的一個目錄
    -XX:+HeapDumpOnOutOfMemoryError //檔案生成的位置,作則生成在桌面的一個目錄
    //檔案生成的位置,作則生成在桌面的一個目錄
    -XX:HeapDumpPath=/Users/zdy/Desktop/dump/ 
 */
public class HeapOOM {
    //建立一個內部類用於建立物件使用
    static class OOMObject {
    }
    public static void main(String[] args) {
        List<OOMObject> list = new ArrayList<OOMObject>();
        //無限建立物件,在堆中
        while (true) {
            list.add(new OOMObject());
        }
    }
}複製程式碼

Run起來程式碼後爆出異常如下:

java.lang.OutOfMemoryError: Java heap space
Dumping heap to /Users/zdy/Desktop/dump/java_pid1099.hprof ...

可以看到生成了dump檔案到指定目錄。並且爆出了OutOfMemoryError,還告訴了你是哪一片區域出的問題:heap space

開啟VisualVM工具匯入對應的heapDump檔案(如何使用請讀者自行查閱相關資料),相應的說明見圖:

"類標籤"
"類標籤"

切換到"例項數"標籤頁
"例項數標籤"
"例項數標籤"

分析dump檔案後,我們可以知道,OOMObject這個類建立了810326個例項。所以它能不溢位嗎?接下來就在程式碼裡找這個類在哪new的。排查問題。(我們的樣例程式碼就不用排查了,While迴圈太凶猛了)

Java棧記憶體異常

老實說,在棧中出現異常(StackOverFlowError)的概率小到和去蘋果專賣店買手機,買回來後發現是Android系統的概率是一樣的。因為作者確實沒有在生產環境中遇到過,除了自己作死寫樣例程式碼測試。先說一下異常出現的情況,前面講到過,方法呼叫的過程就是方法幀進虛擬機器棧和出虛擬機器棧的過程,那麼有兩種情況可以導致StackOverFlowError,當一個方法幀(比如需要2M記憶體)進入到虛擬機器棧(比如還剩下1M記憶體)的時候,就會報出StackOverFlow.這裡先說一個概念,棧深度:指目前虛擬機器棧中沒有出棧的方法幀。虛擬機器棧容量通過引數-Xss來控制,下面通過一段程式碼,把棧容量人為的調小一點,然後通過遞迴呼叫觸發異常。

/**
 * VM Args:
    //設定棧容量為160K,預設1M
   -Xss160k
 */
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:751
Exception in thread "main" java.lang.StackOverflowError

可以看到,遞迴呼叫了751次,棧容量不夠用了。
預設的棧容量在正常的方法呼叫時,棧深度可以達到1000-2000深度,所以,一般的遞迴是可以承受的住的。如果你的程式碼出現了StackOverflowError,首先檢查程式碼,而不是改引數。

這裡順帶提一下,很多人在做多執行緒開發時,當建立很多執行緒時,容易出現OOM(OutOfMemoryError),這時可以通過具體情況,減少最大堆容量,或者棧容量來解決問題,這是為什麼呢。請看下面的公式:

執行緒數*(最大棧容量)+最大堆值+其他記憶體(忽略不計或者一般不改動)=機器最大記憶體

當執行緒數比較多時,且無法通過業務上削減執行緒數,那麼再不換機器的情況下,你只能把最大棧容量設定小一點,或者把最大堆值設定小一點。

方法區記憶體異常

寫到這裡時,作者本來想寫一個無限建立動態代理物件的例子來演示方法區溢位,避開談論JDK7與JDK8的記憶體區域變更的過渡,但細想一想,還是把這一塊從始致終的說清楚。在上一篇文章中JVM系列之Java記憶體結構詳解講到方法區時提到,JDK7環境下方法區包括了(執行時常量池),其實這麼說是不準確的。因為從JDK7開始,HotSpot團隊就想到開始去"永久代",大家首先明確一個概念,方法區和"永久代"(PermGen space)是兩個概念,方法區是JVM虛擬機器規範,任何虛擬機器實現(J9等)都不能少這個區間,而"永久代"只是HotSpot對方法區的一個實現。為了把知識點列清楚,我還是才用列表的形式:

  • JDK7之前(包括JDK7)擁有"永久代"(PermGen space),用來實現方法區。但在JDK7中已經逐漸在實現中把永久代中把很多東西移了出來,比如:符號引用(Symbols)轉移到了native heap,執行時常量池(interned strings)轉移到了java heap;類的靜態變數(class statics)轉移到了java heap.
    所以這就是為什麼我說上一篇文章中說方法區中包含執行時常量池是不正確的,因為已經移動到了java heap;
  • 在JDK7之前(包括7)可以通過-XX:PermSize -XX:MaxPermSize來控制永久代的大小.
  • JDK8正式去除"永久代",換成Metaspace(元空間)作為JVM虛擬機器規範中方法區的實現。
  • 元空間與永久代之間最大的區別在於:元空間並不在虛擬機器中,而是使用本地記憶體。因此,預設情況下,元空間的大小僅受本地記憶體限制,但仍可以通過引數控制:-XX:MetaspaceSize與-XX:MaxMetaspaceSize來控制大小。

下面作者還是通過一段程式碼,來不停的建立Class物件,在JDK8中可以看到metaSpace記憶體溢位:

/**
  作者準備在JDK8下測試方法區,所以設定了Metaspace的大小為固定的8M
 -XX:MetaspaceSize=8m
 -XX:MaxMetaspaceSize=8m
 */
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);
                }
            });
            //無限建立動態代理,生成Class物件
            enhancer.create();
        }
    }

    static class OOMObject {

    }
}複製程式碼

在JDK8的環境下將報出異常:
Exception in thread "main" java.lang.OutOfMemoryError: Metaspace
這是因為在呼叫CGLib的建立代理時會生成動態代理類,即Class物件到Metaspace,所以While一下就出異常了。
提醒一下:雖然我們日常叫"堆Dump",但是dump技術不僅僅是對於"堆"區域才有效,而是針對OOM的,也就是說不管什麼區域,凡是能夠報出OOM錯誤的,都可以使用dump技術生成dump檔案來分析。

在經常動態生成大量Class的應用中,需要特別注意類的回收狀況,這類場景除了例子中的CGLib技術,常見的還有,大量JSP,反射,OSGI等。需要特別注意,當出現此類異常,應該知道是哪裡出了問題,然後看是調整引數,還是在程式碼層面優化。

附加-直接記憶體異常

直接記憶體異常非常少見,而且機制很特殊,因為直接記憶體不是直接向作業系統分配記憶體,而且通過計算得到的記憶體不夠而手動丟擲異常,所以當你發現你的dump檔案很小,而且沒有明顯異常,只是告訴你OOM,你就可以考慮下你程式碼裡面是不是直接或者間接使用了NIO而導致直接記憶體溢位。

好了,"JVM系列之實戰記憶體溢位異常"到這裡就給大家介紹完了,Have a good day .歡迎留言指錯。

往期入口:

  1. JVM系列之Java記憶體結構詳解

相關文章