Java虛擬機內存管理(三)—內存異常

Java 與 C++ 之間有一堵由內存動態分配和垃圾收集技術所圍成的 “高牆”,牆外面的人想進去,牆裏面的人卻想出來。——《深入理解Java虛擬機:JVM高級特性與最佳時實踐(第二版)》周志明

Java 虛擬機作爲運行 Java 程序抽象出來的計算機,具有內存管理的能力,像內存分配、垃圾回收等這些相關的內存管理問題,Java 虛擬機都會幫我們解決,所以作爲一個 Java 程序員要比 C++ 程序員幸福,但是內存方面一旦出現問題,如果對虛擬機怎樣使用內存不瞭解,就很難排查錯誤。

這段時間看周志明先生的《深入理解Java虛擬機:JVM高級特性與最佳時實踐(第二版)》,下面就對 Java 虛擬機對內存的管理做一個系統的整理,本篇文章是該專題的第三篇。

3、內存異常

雖然說有 Java 虛擬機幫助我們管理內存,但是在管理過程中仍然有內存異常的發生。除了前面內存劃分中說到的程序計數器外,其他區域都有發生 OutOfMemoryError 異常的可能。

我們可以給 Java 虛擬機設置參數來模擬這些異常的發生,不同的 Java 虛擬機運行結果可能也不同,這裏使用的是 Oracle 公司的 JDK。

特別說明:下面如果沒有特殊說明,默認使用的是 JDK8。

3.1 Java 堆內存異常

Java 堆是用於存儲對象實例的,所以只要不斷的創建對象把 Java 堆區域填滿,並且還要保證牢記垃圾回收機制不能清除這些對象,就可以模擬出 Java 堆內存的異常。

模擬程序代碼如下:

import java.util.ArrayList;
import java.util.List;

// 模擬 Java 堆內存異常
public class HeapOOM {
    // 聲明類內部靜態類,生命週期和外部類 HeapOOM 一樣長,使垃圾收集器無法回收這些對象佔用的內存空間
    static class OOMObject{
        
    }
    public static void main(String[] args) {
        List<OOMObject> list = new ArrayList<>();
        // 死循環不斷生成對象,並添加到 list 中, 直到佔滿 Java堆內存
        while(true) {
            list.add(new OOMObject());
        }
    }
}

這裏使用 MAT 內存分析器插件來對內存異常進行分析,IDE 使用免費的 Eclipse,當然 IDEA 也可以安裝,Eclipse種的安裝教程可以參看這篇文章《mat之一--eclipse安裝Memory Analyzer》

在 Debug 的配置頁面,設置 JVM 的參數。

Debug設置.jpg

JVM Debug 參數:

-verbose:gc -Xms20M -Xmx20M

-XX:+PrintGCDetails

-XX:+HeapDumpOnOutOfMemoryError

-XX:HeapDumpPath=D:\CodeWorkspace\Java\Dump

-Xms、-Xmx、-Xmn 後面分別是 Java 堆的最小值、Java 堆的最大值都是 20M,-XX後面可以添加一些額外的設置,PrintGCDetails 是打印出垃圾收集的詳細信息,HeapDumpOnOutOfMemoryError 是發生OutOfMemoryError 異常時記錄內存快照,HeapDumpPath後面是存放內存快照的文件夾位置。

Debug 結果如下:

Java堆異常運行結果.jpg

從上圖中可以看到 Java堆區域(Java heap space)出現了 OutOfMemoryError 的異常,並且在我們指定的文件夾生成了內存快照文件。在使用 MAT 內存分析器工具之前,我們還要知道內存泄露和內存溢出的區別,我在前面沒有將 OutOfMemoryError 異常翻譯成內存泄露異常或內存溢出異常,而是使用原本的英文,內存泄露和內存溢出只是導致出現異常的原因,該事件的結果纔是產生 OutOfMemoryError 異常。

內存泄露和內存溢出的區別:

  • 內存泄露是指程序在申請內存後,無法釋放已申請的內存空間,內存泄露會導致內存資源耗光,通俗的說就是對象佔着內存空間不歸還給系統。
  • 內存溢出是指程序申請內存使用時,發現內存空間並不夠使用,很常見的例子就是在存一個大數時超過了該數據類型的最大值,通俗的是說就是程序在借內存空間時發現無法滿足自己的要求。

知道了內存泄露和內存溢出的區別,我們再來用 MAT 工具分析內存快照,首先調出 MAT 視圖,然後在 “File” 選項中選擇 “Open Heap Dump” 打開內存快照文件。

調出MAT視圖.jpg

打開內存快照文件.jpg

打開後快照文件後可以清晰的看出內存異常的可能出現問題的地方(Problem Suspect)。

內存快照.jpg

點擊 “Details” 可以查看具體的細節。

具體細節.jpg

可以看到 OOMObject 佔用的內存空間很大,可以查看該對象是否有到 GC roots 的引用鏈,導致垃圾收集器無法回收對象佔用的內存空間,由於是內存空間被佔用無法回收,所以 OutOfMemoryError 異常產生的原因是內存泄露。

查看泄露對象到GCRoots的引用鏈.gif

3.2 棧內存異常

在 HotSpot 虛擬機中並不區分 Java 虛擬機棧和本地方法棧,棧的容量可以通過 -Xss 參數來設定。

在 Java 虛擬機規範中描述了兩種棧會出現的異常:

  • 如果線程請求的棧深度大於虛擬機所允許的深度,拋出 StackOverflowError 異常。
  • 如果虛擬機棧在動態擴展時無法申請到足夠的內存,拋出 OutOfMemoryError 異常。

棧的深度是由棧的內存空間決定的,請求的棧越深,也即是已使用的棧的空間越大,所以上面 Java 虛擬機規範中的兩種異常是有重疊之處的,一種異常也可能會導致另外一種異常的發生,到底是棧的內存空間太小引起的內存異常還是已使用的棧的內存空間太大引起的內存異常?

減少棧內存的容量和定義大量的局部變量來增加棧幀中局部變量表的長度,理論上都是可以產生 StackOverflowError 異常,也可以產生 OutOfMemoryError 異常的。

但是下面的代碼只能產生 StackOverflowError 異常。

// 棧 StackOverflowError 異常
public class JVMStackSOF {
    private int stackLength = 1;
    // 遞歸函數
    public void stackLeak() {
        stackLength++;
        stackLeak();
    }
    public static void main(String[] args) {
        JVMStackSOF stackSOF = new JVMStackSOF();
        try {
            stackSOF.stackLeak();
        } catch (Throwable e) {
            System.out.println("Stack Length:" + stackSOF.stackLength);
            throw e;
        }
    }
}

Debug 的參數爲:-verbose:gc -Xss128k -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=D:\CodeWorkspace\Java\Dump

Debug 結果如下,只產生了 StackOverflowError 異常。

棧異常結果1.jpg

而在多線程環境中測試,可以才模擬出 OutOfMemoryError 異常。

特別提醒:此代碼運行時會導致系統假死,具有一定的風險性,請在運行前保存好其他文件。

代碼如下:

// 棧 OutOfMemoryError 異常
public class JVMStackOOM {
    private void dontStop() {
        while(true) {
            
        }
    }
    // !危險代碼請勿隨便嘗試
    public void stackLeakByTread() {
        // 死循環不斷創建線程
        while(true) {
            Thread thread = new Thread(new Runnable() {
                
                @Override
                public void run() {
                    dontStop();
                }
            });
            thread.start();
        }
    }
    
    public static void main(String[] args) {
        JVMStackOOM stackOOM = new JVMStackOOM();
        stackOOM.stackLeakByTread();
    }
}

由於在做這項危險的測試時,系統死掉了,所以筆者並沒有得出實際結果,根據《深入理解Java虛擬機:JVM高級特性與最佳時實踐(第二版)》,這裏給出理論結果,也可以在虛擬機系統中嘗試運行此代碼,但也可能會出現外部系統假死的情況,讀者可以自己嘗試。

棧異常結果2.jpg

3.3 方法區內存異常

方法區中有運行時常量池,如果向常量池中添加大量的內容,也可以導致方法區內存異常,可以通過 -XX:Permsize 和 -XX:MaxPermSize 來限制方法區的大小,進而限制常量池的容量。常量池在編譯期可以放入常量了,在運行時也可以再添加新的常量,不存在內存被佔用無法回收,所以這裏的異常不是內存泄露導致的,而是內存溢出。

代碼如下:

import java.util.ArrayList;
import java.util.List;

// 模擬方法區中的常量池內存溢出
public class RuntimeConstantPoolOOM {
    public static void main(String[] args) {
        List<String> list = new ArrayList<String>();
        int i = 0;
        while(true) {
            list.add(String.valueOf(i++).intern());
        }
    }
}

經過實際測試,發現 JDK6 會出現下面內存異常的情況,而在 JDK7 和 JDK8 中,發現垃圾回收器會不斷的回收常量池的舊常量所佔用的內存,以便新的常量可以進入,從而避免了常量池內存異常的發生。

方法區常量池內存異常.jpg

方法區用於存放類的相關信息,如類名,訪問修飾符,常量池,字段描述,方法描述等。使方法區內存異常的大致思路是產生大量的類填滿方法區,直到方法區內存溢出。由於實驗操作起來比較麻煩,直接操作字節碼文件來動態的生成大量的類,所以這裏也是使用書中的運行結果。

方法區內存異常.jpg

3.4 直接內存異常

直接內存的大小可以通過 -XX:MaxDirectMemorySize 來指定,如果不指定默認是和 Java 堆的最大值(-Xmx)一樣,可以通過使用 Unsafe 類來申請內存,由於該類的使用有限制,只有引導類的加載器纔會返回對象實例,所以只能通過反射來獲取 Unsafe 類的實例,但是在 Eclipse 中導入該類的包會報錯,解決方案見參考文章。

參考文章:

eclipse中解決import sun.misc.Unsafe報錯的方法

代碼如下:

import java.lang.reflect.Field;
import sun.misc.Unsafe;

// 模擬直接內存異常
public class DirectMemoryOOM {
    private static final int _1MB = 1024 * 1024;
    public static void main(String[] args) throws IllegalArgumentException, IllegalAccessException {
        Field unsafeField = Unsafe.class.getDeclaredFields()[0];
        unsafeField.setAccessible(true);
        Unsafe unsafe = (Unsafe)unsafeField.get(null);
        while(true) {
            unsafe.allocateMemory(_1MB); // 申請內存
        }
    }
}

Debug 參數:-verbose:gc -Xmx20M -XX:MaxDirectMemorySize=10M -XX:+PrintGCDetails

由於在 Eclipse 中使用 JDK6 和 JDK7 運行該程序時會直接閃退,無法得到輸出的異常,所以直接在控制檯中使用 JDK8 編譯運行該程序,運行結果如下:

直接內存異常.jpg

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章