深入理解JVM - 內存溢出實戰

Java堆溢出

Java堆用於存儲對象實例,只要不斷地創建對象,當對象數量到達最大堆的容量限制後就會產生內存溢出異常。最常見的內存溢出就是存在大的容器,而沒法回收,比如:Map,List等。

  • 內存溢出:內存空間不足導致,新對象無法分配到足夠的內存;
  • 內存泄漏:應該釋放的對象沒有被釋放,多見於自己使用容器保存元素的情況下。

出現下面信息就可以斷定出現了堆內存溢出。

java.lang.OutOfMemoryError: Java heap space

保證GC Roots到對象之間有可達路徑來避免垃圾回收機制清除這些對象

示例

設置JVM內存參數:

-verbose:gc -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=D:\dump
/**
 * java 堆內存溢出
 * <p>
 * VM Args: -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=D:\dump
 *
 * @author yuhao.wang3
 * @since 2019/11/30 17:09
 */
public class HeapOutOfMemoryErrorTest {
    public static void main(String[] args) throws InterruptedException {
        // 模擬大容器
        List<Object> list = Lists.newArrayList();
        for (long i = 1; i > 0; i++) {
            list.add(new Object());
            if (i % 100_000 == 0) {
                System.out.println(Thread.currentThread().getName() + "::" + i);
            }
        }
    }
}

運行結果

[GC (Allocation Failure)  5596K->1589K(19968K), 0.0422027 secs]
main::100000
main::200000
[GC (Allocation Failure)  7221K->5476K(19968K), 0.0144103 secs]
main::300000
[GC (Allocation Failure)  9190K->9195K(19968K), 0.0098252 secs]
main::400000
main::500000
[Full GC (Ergonomics)  17992K->13471K(19968K), 0.3431052 secs]
main::600000
main::700000
main::800000
[Full GC (Ergonomics)  17127K->16788K(19968K), 0.1581969 secs]
[Full GC (Allocation Failure)  16788K->16758K(19968K), 0.1994445 secs]
java.lang.OutOfMemoryError: Java heap space
Dumping heap to D:\dump\java_pid7432.hprof ...
Heap dump file created [28774262 bytes in 0.221 secs]
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
	at java.util.Arrays.copyOf(Arrays.java:3210)
	at java.util.Arrays.copyOf(Arrays.java:3181)
	at java.util.ArrayList.grow(ArrayList.java:261)
	at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:235)
	at java.util.ArrayList.ensureCapacityInternal(ArrayList.java:227)
	at java.util.ArrayList.add(ArrayList.java:458)
	at com.xiaolyuh.HeapOutOfMemoryErrorTest.main(HeapOutOfMemoryErrorTest.java:23)
Disconnected from the target VM, address: '127.0.0.1:61622', transport: 'socket'

分析工具

JDK自帶的jvisualvm.exe工具可以分析.hprof.dump文件。

首先需要找出最大的對象,判斷最大對象的存在是否合理,如何合理就需要調整JVM內存大小。如果不合理,那麼這個對象的存在,就是最有可能是引起內存溢出的根源。通過GC Roots的引用鏈信息,就可以比較準確地定位出泄露代碼的位置。

  1. 查詢最大對象

1.png

  1. 找出具體的對象

2.png

解決方案

  1. 優化代碼,去除大對象;
  2. 調整JVM內存大小(-Xmx與-Xms);

超出GC開銷限制

當出現java.lang.OutOfMemoryError: GC overhead limit exceeded異常信息時,表示超出了GC開銷限制。當超過98%的時間用來做GC,但是卻回收了不到2%的堆內存時會拋出此異常。

異常棧

[Full GC (Ergonomics)  19225K->19225K(19968K), 0.1044070 secs]
[Full GC (Ergonomics)  19227K->19227K(19968K), 0.0684710 secs]
java.lang.OutOfMemoryError: GC overhead limit exceeded
Dumping heap to D:\dump\java_pid17556.hprof ...
Heap dump file created [34925385 bytes in 0.132 secs]
Exception in thread "main" java.lang.OutOfMemoryError: GC overhead limit exceeded
[Full GC (Ergonomics)  19257K->933K(19968K), 0.0403569 secs]
	at com.xiaolyuh.HeapOutOfMemoryErrorTest.main(HeapOutOfMemoryErrorTest.java:25)
ERROR: JDWP Unable to get JNI 1.2 environment, jvm->GetEnv() return code = -2
JDWP exit error AGENT_ERROR_NO_JNI_ENV(183):  [util.c:840]

解決方案

  1. 通過-XX:-UseGCOverheadLimit參數來禁用這個檢查,但是並不能從根本上來解決內存溢出的問題,最後還是會報出java.lang.OutOfMemoryError: Java heap space異常;
  2. 調整JVM內存大小(-Xmx與-Xms);

虛擬機棧和本地方法棧溢出

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

這裏把異常分成兩種情況,看似更加嚴謹,但卻存在着一些互相重疊的地方:當棧空間無法繼續分配時,到底是內存太小,還是已使用的棧空間太大,其本質上只是對同一件事情的兩種描述而已。

StackOverflowError

出現StackOverflowError異常的主要原因有兩點:

  • 單個線程請求的棧深度大於虛擬機所允許的最大深度
  • 創建的線程過多

單個線程請求的棧深度過大

單個線程請求的棧深度大於虛擬機所允許的最大深度,主要表現有以下幾點:

  1. 存在遞歸調用
  2. 存在循環依賴調用
  3. 方法調用鏈路很深,比如使用裝飾器模式的時候,對已經裝飾後的對象再進行裝飾

異常信息java.lang.StackOverflowError

裝飾器示例:

Collections.unmodifiableList(
        Collections.unmodifiableList(
                Collections.unmodifiableList(
                        Collections.unmodifiableList(
                                Collections.unmodifiableList(
                                                        ...)))))));

遞歸示例:

/**
 * java 虛擬機棧和本地方法棧內存溢出測試
 * <p>
 * VM Args: -Xss128k
 *
 * @author yuhao.wang3
 * @since 2019/11/30 17:09
 */
public class StackOverflowErrorErrorTest {
    private int stackLength = 0;

    public void stackLeak() {
        stackLength++;
        stackLeak();
    }

    public static void main(String[] args) {
        StackOverflowErrorErrorTest sof = new StackOverflowErrorErrorTest();
        try {
            sof.stackLeak();
        } catch (Exception e) {
            System.out.println(sof.stackLength);
            e.printStackTrace();
        }
    }
}

運行結果:

stackLength::1372
java.lang.StackOverflowError
	at com.xiaolyuh.StackOverflowErrorErrorTest.stackLeak(StackOverflowErrorErrorTest.java:16)
	at com.xiaolyuh.StackOverflowErrorErrorTest.stackLeak(StackOverflowErrorErrorTest.java:16)
	at com.xiaolyuh.StackOverflowErrorErrorTest.stackLeak(StackOverflowErrorErrorTest.java:16)
...

當增大棧空間的時候我們就會發現,遞歸深度會增加,修改棧空間-Xss1m,然後運行程序,運行結果如下:

stackLength::20641
java.lang.StackOverflowError
	at com.xiaolyuh.StackOverflowErrorErrorTest.stackLeak(StackOverflowErrorErrorTest.java:16)
	at com.xiaolyuh.StackOverflowErrorErrorTest.stackLeak(StackOverflowErrorErrorTest.java:16)
...

修改遞歸方法的參數列表後遞歸深度急劇減少:

public void stackLeak(String ags1, String ags2, String ags3) {
    stackLength++;
    stackLeak(ags1, ags2, ags3);
}

運行結果如下:

stackLength::13154
java.lang.StackOverflowError
	at com.xiaolyuh.StackOverflowErrorErrorTest.stackLeak(StackOverflowErrorErrorTest.java:16)
	at com.xiaolyuh.StackOverflowErrorErrorTest.stackLeak(StackOverflowErrorErrorTest.java:16)
...

由此可見影響遞歸的深度因素有:

  1. 單個線程的棧空間大小(-Xss)
  2. 局部變量表的大小

單個線程請求的棧深度超過內存限制導致的棧內存溢出,一般是由於非正確的編碼導致的。從上面的示例我們可以看出,當棧空間在-Xss128k的時候,調用層級都在1000以上,一般情況下方法的調用是達不到這個深度的。如果方法調用的深度確實有這麼大,那麼我們可以通過-Xss配置來增大棧空間大小。

創建的線程過多

不斷地建立線程也可能導致棧內存溢出,因爲我們機器的總內存是有限制的,所以虛擬機棧和本地方法棧對應的內存也是有最大限制的。如果單個線程的棧空間越大,那麼整個應用允許創建的線程數就越少。異常信息java.lang.OutOfMemoryError: unable to create new native thread

虛擬機棧和本地方法棧內存 ≈ 操作系統內存限制 - 最大堆容量(Xmx) - 最大方法區容量(MaxPermSize) 

過多創建線程示例:

/**
 * java 虛擬機棧和本地方法棧內存溢出測試
 * <p>
 * 創建線程過多導致內存溢出異常
 * <p>
 * VM Args: -verbose:gc -Xss20M -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=D:\dump
 *
 * @author yuhao.wang3
 * @since 2019/11/30 17:09
 */
public class StackOutOfMemoryErrorTest {
    private static int threadCount;

    public static void main(String[] args) throws Throwable {
        try {
            while (true) {
                threadCount++;
                new Thread(new Runnable() {
                    @Override
                    public void run() {
                        try {
                            Thread.sleep(1000 * 60 * 10);
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }
                }).start();
            }
        } catch (Throwable e) {
            e.printStackTrace();
            throw e;
        } finally {
            System.out.println("threadCount=" + threadCount);
        }
    }
}

Java的線程是映射到操作系統的內核線程上,因此上述代碼執行時有較大的風險,可能會導致操作系統假死。

運行結果:

java.lang.OutOfMemoryError: unable to create new native thread
	at java.lang.Thread.start0(Native Method)
	at java.lang.Thread.start(Thread.java:717)
	at StackOutOfMemoryErrorTest.main(StackOutOfMemoryErrorTest.java:17)
threadCount=4131
Exception in thread "main" java.lang.OutOfMemoryError: unable to create new native thread
	at java.lang.Thread.start0(Native Method)
	at java.lang.Thread.start(Thread.java:717)
	at StackOutOfMemoryErrorTest.main(StackOutOfMemoryErrorTest.java:17)

需要重新上述異常,最好是在32位機器上,因爲我在64位機器沒有重現。

在有限的內存空間裏面,當我們需要創建更多的線程的時候,我們可以減少單個線程的棧空間大小。

元數據區域的內存溢出

元數據區域或方法區是用於存放Class的相關信息,如類名、訪問修飾符、常量池、字段描述、方法描述等。我們可以通過在運行時產生大量的類去填滿方法區,直到溢出,如:代理的使用(CGlib)、大量JSP或動態產生JSP文件的應用(JSP第一次運行時需要編譯爲Java類)、基於OSGi的應用(即使是同一個類文件,被不同的加載器加載也會視爲不同的類)等。

/**
 * java 元數據區域/方法區的內存溢出
 * <p>
 * VM Args JDK 1.6: set JAVA_OPTS=-verbose:gc -XX:PermSize=10m -XX:MaxPermSize=10m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=D:\dump
 * <p>
 * VM Args JDK 1.8: set JAVA_OPTS=-verbose:gc -Xmx20m -XX:MetaspaceSize=5m -XX:MaxMetaspaceSize=5m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=D:\dump
 *
 * @author yuhao.wang3
 * @since 2019/11/30 17:09
 */
public class MethodAreaOutOfMemoryErrorTest {

    static class MethodAreaOOM {
    }

    public static void main(String[] args) {
        while (true) {
            Enhancer enhancer = new Enhancer();
            enhancer.setSuperclass(MethodAreaOOM.class);
            enhancer.setCallback(new MethodInterceptor() {
                @Override
                public Object intercept(Object obj, Method method, Object[] params, MethodProxy proxy) throws Throwable {
                    return proxy.invokeSuper(obj, params);
                }
            });
            enhancer.create();
        }
    }
}

運行結果:

[GC (Last ditch collection)  1283K->1283K(16384K), 0.0002585 secs]
[Full GC (Last ditch collection)  1283K->1226K(19968K), 0.0075856 secs]
java.lang.OutOfMemoryError: Metaspace
Dumping heap to D:\dump\java_pid18364.hprof ...
Heap dump file created [2479477 bytes in 0.015 secs]
[GC (Metadata GC Threshold)  1450K->1354K(19968K), 0.0003906 secs]
[Full GC (Metadata GC Threshold)  1354K->976K(19968K), 0.0073752 secs]
[GC (Last ditch collection)  976K->976K(19968K), 0.0002921 secs]
[Full GC (Last ditch collection)  976K->973K(19968K), 0.0045243 secs]
Exception in thread "main" java.lang.OutOfMemoryError: Metaspace
	at java.lang.ClassLoader.defineClass1(Native Method)
	at java.lang.ClassLoader.defineClass(ClassLoader.java:763)
	at java.security.SecureClassLoader.defineClass(SecureClassLoader.java:142)
	at java.net.URLClassLoader.defineClass(URLClassLoader.java:467)
	at java.net.URLClassLoader.access$100(URLClassLoader.java:73)
	at java.net.URLClassLoader$1.run(URLClassLoader.java:368)
	at java.net.URLClassLoader$1.run(URLClassLoader.java:362)
	at java.security.AccessController.doPrivileged(Native Method)
	at java.net.URLClassLoader.findClass(URLClassLoader.java:361)
	at java.lang.ClassLoader.loadClass(ClassLoader.java:424)
	at sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:331)
	at java.lang.ClassLoader.loadClass(ClassLoader.java:357)
	at org.springframework.cglib.core.internal.LoadingCache.createEntry(LoadingCache.java:52)
	at org.springframework.cglib.core.internal.LoadingCache.get(LoadingCache.java:34)
	at org.springframework.cglib.core.AbstractClassGenerator$ClassLoaderData.get(AbstractClassGenerator.java:116)
	at org.springframework.cglib.core.AbstractClassGenerator.create(AbstractClassGenerator.java:291)
	at org.springframework.cglib.core.KeyFactory$Generator.create(KeyFactory.java:221)
	at org.springframework.cglib.core.KeyFactory.create(KeyFactory.java:174)
	at org.springframework.cglib.core.KeyFactory.create(KeyFactory.java:153)
	at org.springframework.cglib.proxy.Enhancer.<clinit>(Enhancer.java:73)
	at com.xiaolyuh.MethodAreaOutOfMemoryErrorTest.main(MethodAreaOutOfMemoryErrorTest.java:26)

運行時常量池的內存溢出

String.intern()是一個Native方法,它的作用是:如果字符串常量池中已經包含一個等於此String對象的字符串,則返回代表池中這個字符串的String對象;否則,將此String對象包含的字符串添加到常量池中,並且返回此String對象的引用。

在JDK 1.6的時候,運行時常量池是在方法區中,所以直接限制了方法區中大小就可以模擬出運行池常量池的內存溢出。

/**
 * java 方法區和運行時常量池溢出
 * <p>
 * VM Args JDK 1.6: set JAVA_OPTS=-verbose:gc -XX:PermSize10 -XX:MaxPermSize10m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=D:\dump
 *
 * @author yuhao.wang3
 * @since 2019/11/30 17:09
 */
public class RuntimeConstantOutOfMemoryErrorTest {

    public static void main(String[] args) {
        // 使用List保存着常量池的引用,避免Full GC 回收常量池行爲
        List<String> list = new ArrayList<>();
        for (int i = 0; ; i++) {
            list.add(String.valueOf(i).intern());
        }
    }
}

運行結果:

Exception in thread "main" java.lang.OutOfMemoryError: PermGen space
    at java.lang.String.intern(Native Method)
    at RuntimeConstantOutOfMemoryErrorTest.main(RuntimeConstantOutOfMemoryErrorTest.java:18)

直接內存溢出

DirectMemory容量可通過-XX:MaxDirectMemorySize指定,如果不指定,則默認與Java堆最大值(-Xmx指定)一樣。

/**
 * java 直接內存溢出
 * <p>
 * VM Args JDK 1.6: set JAVA_OPTS=-verbose:gc -Xms20m -XX:MaxDirectMemorySize=10m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=D:\dump
 *
 * @author yuhao.wang3
 * @since 2019/11/30 17:09
 */
public class DirectMemoryOutOfMemoryErrorTest {

    public static void main(String[] args) throws IllegalAccessException {
        int _1M = 1024 * 1024;
        Field unsafeField = Unsafe.class.getDeclaredFields()[0];
        unsafeField.setAccessible(true);
        Unsafe unsafe = (Unsafe) unsafeField.get(null);
        while (true) {
            unsafe.allocateMemory(_1M);
        }
    }
}

運行結果:

Exception in thread "main" java.lang.OutOfMemoryError
	at sun.misc.Unsafe.allocateMemory(Native Method)
	at com.xiaolyuh.DirectMemoryOutOfMemoryErrorTest.main(DirectMemoryOutOfMemoryErrorTest.java:23)

由DirectMemory導致的內存溢出,一個明顯的特徵是在Heap Dump文件中不會看見明顯的異常,如果讀者發現OOM之後Dump文件很小,而程序中又直接或間接使用了NIO,那就可以考慮檢查一下是不是這方面的原因。

解決方案

通過-XX:MaxDirectMemorySize指定直接內存大小。

源碼

https://github.com/wyh-spring-ecosystem-student/spring-boot-student/tree/releases

spring-boot-student-jvm工程

參考

《深入理解JAVA虛擬機》

JDK下載地址

JDK 下載地址

發佈了204 篇原創文章 · 獲贊 153 · 訪問量 86萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章