Java OutOfMemory異常清單 —— 在自己的機器上製造內存溢出

帶着問題閱讀

  • 如何製造OutOfMemory
  • jvm啓動參數怎麼設置?
  • 如何根據異常信息判斷哪個區域發生內存溢出?
  • 發生內存溢出後如何解決?
  • 方法區從JDK1.6到JDK1.8都經歷了什麼?
  • JDK1.8新增的Metaspace是什麼東西?


導語

上一講我們已經瞭解了Java虛擬機的內存模型,既然我們知道各個內存區域存儲的內容,那麼只要在代碼上做一些“手腳”,就可以製造出內存溢出(OutOfMemory)異常,這就是我們這一講要做的事。

在機器上製造各種OutOfMemory異常,目的有三:

  • 通過代碼驗證上一講所講的各個運行時區域所存儲的內容;
  • 幫助讀者在實際項目中遇到內存溢出異常時,能夠根據異常信息快速判斷是哪個區域的內存溢出,知道什麼樣的代碼可能導致這些區域內存溢出,以及出現異常後該如何處理。
  • 以後大家遇到OutOfMemory,把異常環境信息在這篇文章搜索一下,就可以找到分析和解決的思路。

本文是Effective Java專欄Java虛擬機專題的第三講,如果你覺得看完之後對你有所幫助,歡迎訂閱本專欄,也歡迎您將本專欄分享給你身邊的工程師同學。

在學習本節課程之前,建議您瞭解一下以下知識點:


下文中代碼的開頭的註釋都說明了執行代碼時要設置的虛擬機啓動參數。如果您使用控制檯命令來執行,直接在java命令後面加上啓動參數即可;如果是通過Eclipse IDE來執行,則在Debug/Run Configuration - Arguments頁籤進行設置:



Java堆溢出

通過上一講的講解,我們已經知道,Java堆是用於存儲對象實例的,因此,只要我們不斷創建對象,並且保證對象不被垃圾回收機制清除,那麼當堆中對象的大小超過了最大堆的容量限制,就會出現堆內存溢出。

下面這段代碼,將Java堆的大小設置爲20MB,並且不可擴展(通過將堆的最小值-Xms參數和最大值-Xmx參數設置爲相等的20MB);通過參數-XX:+HeapDumpOnOutOfMemoryError可以讓虛擬機在出現內存溢出異常時生成當前內存堆的快照,以便事後進行分析。

/**
 * VM Args:-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());
		}
	}
}

運行結果:

java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid10284.hprof ...
Heap dump file created [27866984 bytes in 0.144 secs]
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
    at java.util.Arrays.copyOf(Arrays.java:2245)
    at java.util.Arrays.copyOf(Arrays.java:2219)
    at java.util.ArrayList.grow(ArrayList.java:242)
    at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:216)
    at java.util.ArrayList.ensureCapacityInternal(ArrayList.java:208)
    at java.util.ArrayList.add(ArrayList.java:440)
    at com.hzy.jvm.chp02.HeapOOM.main(HeapOOM.java:18)

控制檯打印了OutOfMemoryError,並且提示是Java heap發生的內存溢出,同時生成了dump文件。

對於Java堆溢出,一般通過內存映像分析工具(如Eclipse Memory Analyzer)對dump文件進行堆快照分析,確認內存中的對象是不是必要的:

  • 如果對象不是必要的,那就屬於內存泄漏,需要分析爲什麼對象沒有被回收;
  • 如果對象確實有必要繼續存在,那就屬於內存溢出,需要通過將堆的大小調高(-Xmx和-Xms)來避免內存溢出。

這些都只是Java堆內存問題的簡單思路,後面的課程將會教大家如何使用內存分析工具進行分析。


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

由於HotSpot虛擬機不區分虛擬機棧和本地方法棧,因此,對於HotSpot來說,-Xoss參數(設置本地方法棧大小)是無效的,棧容量只由-Xss設置。

在Java虛擬機規範中,這個區域有兩種異常情況:

  • 如果線程運行時的棧幀(什麼是棧幀?)的總大小超過虛擬機限制的大小,會拋出StackOverflow異常,這一點通常發生在遞歸運行時;
  • 如果虛擬機棧設置爲可以動態擴展,並且在擴展時無法申請到足夠內存,則會拋出OutOfMemory異常。

StackOverflowError

首先我們來寫一個遞歸的程序,驗證第一點:

/**
 * VM Args:-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;
		}
	}
}

運行結果:

stack length:1001
Exception in thread "main" java.lang.StackOverflowError

控制檯打印了異常發生時的棧的深度,1001,並且拋出StackOverflowError。這裏的棧的深度,取決於棧中每個棧幀的大小,這個在不同機器上是不一樣的,棧幀越大,棧所能達到的深度就越小。

我們可以簡單理解爲:

if(棧的深度*棧幀的平均大小 > -Xss的值)

then StackOverflowError

因此,當發生StackOverflowError時,我們要檢查這些棧的創建都是有必要的嗎:

  • 如果是程序的原因導致代碼不停的遞歸,那麼就是bug;
  • 如果確實需要遞歸這麼多次,需要這麼大的棧容量,那麼就要調高-Xss的值,獲取更大的棧容量。

OutOfMemoryError

對於棧的OutOfMemory異常,這裏引用周老師的結論,用自己的話轉述一下:

在單個線程下,當棧的大小超過-Xss設置的大小限制時,拋出的都是StackOverflowError;

在多線程的情況下,由於每創建一個線程,都需要劃分一部分的內存,因此當機器內存已經被消耗乾淨時,再去創建線程,由於已經無法劃分內存給新的線程,因此會導致OutOfMemory異常。

下面這段代碼可以產生OutOfMemory異常(不建議執行,本人執行時電腦直接死機了)

/**
 * VM Args:-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) throws Throwable {
              JavaVMStackOOM oom = new JavaVMStackOOM();
              oom.stackLeakByThread();
       }
}


報錯信息類似於:

java.lang.OutOfMemoryError: unable to create new native thread

對於這種異常,我們需要確認是否有必要創建這麼多線程,如果真的有必要,那麼我們可以通過減少最大堆容量和減少棧容量,來讓虛擬機佔用的內存更小,有更多的內存可以用來創建線程。


方法區溢出

方法區存儲的是虛擬機加載的類信息、常量、靜態變量、JIT編譯器編譯後的代碼等數據,在JDK1.7之前,HotSpot都是使用“永久代”來管理這些數據,也就是說,方法區的數據,其實也是屬於堆內存的,會佔用堆內存

因此:方法區的數據大小 < -XX:MaxPermSize < -Xmx

我們只要限制一下永久代的大小(-XX:MaxPermSize),很容易就會發生方法區溢出。


常量溢出

下面來演示一下常量過多導致的方法區溢出,這裏用到了String.intern()方法,它的作用是:如果字符串常量池已經包含了這個String對象的字符串,則直接返回,否則,將此String對象添加到常量池中,並返回此String對象的引用。

首先請在JDK1.6的環境下運行下面這段代碼,我們將永久代的大小限制爲10MB:

/**
 * VM Args:-XX:PermSize=10M -XX:MaxPermSize=10M
 */
public class RuntimeConstantPoolOOM {

	public static void main(String[] args) {
		// 使用List保持着常量池引用,避免Full GC回收常量池行爲
		List<String> list = new ArrayList<String>();
		// 10MB的PermSize在integer範圍內足夠產生OOM了
		int i = 0; 
		while (true) {
			list.add(String.valueOf(i++).intern());
		}
	}
}

運行結果:

Exception in thread "main" java.lang.OutOfMemoryError: PermGen space
    at java.lang.String.intern(Native Method)
    at com.hzy.jvm.chp02.RuntimeConstantPoolOOM.main(RuntimeConstantPoolOOM.java:18)


接着,我們在JDK1.7的環境下執行同樣的代碼,會發現while循環一直執行下去。原因是在JDK1.7中,HotSpot已經不再將常量放入永久代中進行管理,而是放到內存上限是本機內存的Native Memory中,這樣做的好處就是減少了內存溢出的機率(沒有了-XX:MaxPermSize的限制),同時常量不再佔用堆的內存。這種“去永久代”的做法,從JDK1.7開始進行。


類信息溢出

當然,JDK1.7並沒有完成地“去永久代”,因此還是會出現OutOfMemoryError:PermGen space. 尤其是當運行時產生大量的類的時候,當前很多主流框架,如Spring、Hibernate,都會在對類進行增強時,使用到CGLib這樣的字節碼技術,動態產生大量的Class,因此如果配置不當,很容易出現永久代溢出。

運行下面代碼(需要下載cglib.lib, 我用的是3.2.5,另外還需要下載cglib所依賴的asm.jar,3.2.5的cglib配套的是5.2的asm):

/**
 * VM Args: -XX:PermSize=10M -XX:MaxPermSize=10M
 */
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);
				}
			});
			enhancer.create();
		}
	}

	static class OOMObject {

	}
}

運行結果:

Exception in thread "main"
Exception: java.lang.OutOfMemoryError thrown from the UncaughtExceptionHandler in thread "main"


JDK1.8的Metaspace溢出和堆溢出

從JDK1.7開始的“去永久代”化,終於在JDK1.8中,被徹底的執行了,永久代被徹底removed,同時HotSpot新增了一塊叫做Mataspace的區域,並提供了-XX:MetaspaceSize-XX:MaxMetaspaceSize參數,來設置運行Java虛擬機使用的Metaspace的初始容量和最大容量。

不過並不是所有永久代的數據都放到Metaspace,根據Oracle上一篇文章的介紹以及我在本機做的實驗,對於方法區裏的這些數據:類信息、常量、靜態變量、JIT編譯器編譯後的代碼只有類信息(Classes Metadata)是放到Metaspace了,其他的數據,都被放到到Java堆上,而我們知道,常量在jdk1.7是放在Native Memory的,因此,如果你是從jdk1.7升級到jdk1.8,有可能會發現Java堆的內存壓力變大了。

要驗證Metaspace主要存儲的是類信息,只需要把上面兩段代碼(RuntimeConstantPoolOOM和JavaMethodAreaOOM)在jdk1.8的環境下執行一遍即可,記得將啓動參數設置修改爲:-XX:MetaspaceSize=10M -XX:MaxMetaspaceSize=10M

RuntimeConstantPoolOOM的運行結果:

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
    at java.util.Arrays.copyOf(Arrays.java:3332)
    at java.lang.AbstractStringBuilder.ensureCapacityInternal(AbstractStringBuilder.java:124)
    at java.lang.AbstractStringBuilder.append(AbstractStringBuilder.java:448)
    at java.lang.StringBuilder.append(StringBuilder.java:136)
    at com.hzy.understandjvm.RuntimeConstantPoolOOM.main(RuntimeConstantPoolOOM.java:16)

Java堆溢出,說明常量確實放到堆中。


JavaMethodAreaOOM的運行結果:

Exception in thread "main" java.lang.OutOfMemoryError: Metaspace
    at java.lang.Class.forName0(Native Method)
    at java.lang.Class.forName(Class.java:348)
    at net.sf.cglib.core.ReflectUtils.defineClass(ReflectUtils.java:467)
    at net.sf.cglib.core.AbstractClassGenerator.generate(AbstractClassGenerator.java:336)
    at net.sf.cglib.proxy.Enhancer.generate(Enhancer.java:492)
    at net.sf.cglib.core.AbstractClassGenerator$ClassLoaderData.get(AbstractClassGenerator.java:114)
    at net.sf.cglib.core.AbstractClassGenerator.create(AbstractClassGenerator.java:291)
    at net.sf.cglib.proxy.Enhancer.createHelper(Enhancer.java:480)
    at net.sf.cglib.proxy.Enhancer.create(Enhancer.java:305)
    at com.hzy.understandjvm.JavaMethodAreaOOM.main(JavaMethodAreaOOM.java:24)

Metaspace溢出,說明類信息確實放在Metaspace.


本機直接內存溢出

本機直接內存的容量可以通過-XX:MaxDirectMemorySize指定,如果不指定,默認與Java堆最大值一樣。

本機直接內存溢出的一個明顯特徵是,dump文件很小,因爲主要對象都在direct memory了,並且異常信息也不會說明是在哪個區域發生內存溢出,就像這樣:java.lang.OutOfMemoryError

可以通過下面這段代碼製造本機直接內存溢出:

/**
 * VM Args:-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);
		}
	}
}


總結

這一講,在自己的機器上做了大量的破壞性試驗,演示了各個內存區域的內存溢出異常時如何發生的,同時也給了大家定位和修復問題的一些建議。其中,方法區存儲的對象類型在jdk 1.6、1.7、1.8都有變化,希望大家多看幾遍,牢記於心。

下一講,將講解自動內存管理模塊的一個非常重要的知識點——GC。


課後思考

除了本文所講的異常,你還見過什麼其他的內存溢出異常?歡迎在評論區留言,O(∩_∩)O謝謝。


上一講課後思考題的答案

上一講的問題是——“如果要你在自己的機器上模擬Java堆的內存溢出,你會怎麼做?”。

答案已經在本講裏了,既然Java堆是存放的對象實例,那麼只要做兩個動作,

1: 把-Xms -Xmx參數調小

2: 在程序中不停地new對象


參考資料




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