1. 程式人生 > >【Java虛擬機器】4、OutOfMemoryError異常

【Java虛擬機器】4、OutOfMemoryError異常

--------------------------------【Java虛擬機器】系列--------------------------------
1、Java技術體系
2、Java記憶體區域
3、虛擬機器物件
4、OutOfMemoryError異常
--------------------------------【Java虛擬機器】系列--------------------------------

Part II: 自動記憶體管理機制

第2章 Java記憶體區域與記憶體溢位異常

實戰:OutOfMemoryError異常

通過下圖回顧Java執行時的記憶體釋出圖*(引自文章參考1)*:
OOM


java.lang.OutOfMemoryError這個錯誤大部分開發人員都有遇到過,產生該錯誤的原因大都出於以下原因:JVM記憶體過小、程式不嚴密,產生了過多的垃圾。

導致OutOfMemoryError異常的常見原因有以下幾種:

  • 記憶體中載入的資料量過於龐大,如一次從資料庫取出過多資料;
  • 集合類中有對物件的引用,使用完後未清空,使得JVM不能回收;
  • 程式碼中存在死迴圈或迴圈產生過多重複的物件實體;
  • 使用的第三方軟體中的BUG;
  • 啟動引數記憶體值設定的過小;

此錯誤常見的錯誤提示:

  • tomcat:java.lang.OutOfMemoryError: PermGen space
  • tomcat:java.lang.OutOfMemoryError: Java heap space
  • weblogic:Root cause of ServletException java.lang.OutOfMemoryError
  • resin:java.lang.OutOfMemoryError
  • java:java.lang.OutOfMemoryError

解決java.lang.OutOfMemoryError的方法有如下幾種:
一、增加jvm的記憶體大小。方法有:

  1. 在執行某個class檔案時候,可以使用java -Xmx256M aa.class來設定執行aa.class時jvm所允許佔用的最大記憶體為256M
  2. 對tomcat容器,可以在啟動時對jvm設定記憶體限度。開啟$TOMCAT_HOME/bin/catalina.sh檔案,新增:
    JAVA_OPTS=-Xms256m -Xmx512m -XX:PermSize=256M -XX:MaxPermSize=512M
    二、 優化程式,釋放垃圾

    主要包括避免死迴圈,應該及時釋放種資源:記憶體, 資料庫的各種連線,防止一次載入太多的資料。導致java.lang.OutOfMemoryError的根本原因是程式不健壯。因此,從根本上解決Java記憶體溢位的唯一方法就是修改程式,及時地釋放沒用的物件,釋放記憶體空間。
1、Java堆溢位

Java堆用於儲存物件例項,只要不斷的建立物件,並且保證GC Roots到物件之間有可達路徑來避免垃圾回收機制清除這些物件,那麼在物件數量到達最大堆的容量限制後就會產生記憶體溢位異常。

/**
 * @Description: java 堆溢位 
 * VM Args:-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
 * 
 * -Xms引數初始分配的堆記憶體
 * -Xmx引數最大允許分配的堆記憶體
 * 若-Xms=-Xmx,則可避免堆自動擴充套件
 * XX:+HeapDumpOnOutOfMemoryError可以讓虛擬機器在出現記憶體溢位是dump出當前的記憶體堆轉儲快照
 * 
 */
public class HeapOOM {

	static class OOMObject {
	}

	public static void main(String[] args) {
		List<OOMObject> list = new ArrayList<>();
		while (true) {
			list.add(new OOMObject());
		}
	}
}

執行結果:

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.tyron.outofmemory.HeapOOM.main(HeapOOM.java:22)

解決方案:
要解決這個區域的異常,一般的手段是先通過記憶體映像分析工具(如Eclipse Memory Analyzer)對Dump出來的堆轉儲快照進行分析,重點是確認記憶體中的物件是否是必要的,也就是要先分清楚到底是出現了記憶體洩漏(Memory Leak)還是記憶體溢位(Memory Overflow)。
如果是記憶體洩漏,進一步檢視洩漏物件到GC Roots的引用鏈,從而確認為什麼無法回收;如果是記憶體溢位,則應當檢查虛擬機器堆引數(-Xmx與-Xmx)或檢查是否存在物件生命週期過長、持有狀態時間過長的情況;
Eclipse Memory Analyzer

2、虛擬機器棧和本地方法棧溢位

HotSpot 虛擬機器中並不區分虛擬機器棧和本地方法棧。棧容量只由-Xss引數設定。
關於虛擬機器棧和本地方法棧,在Java虛擬機器規範中描述了兩種異常:

  • 如果執行緒請求的棧深度大於虛擬機器所允許的最大深度,將丟擲 StackOverflowError 異常;
  • 如果虛擬機器棧可以動態擴充套件(當前大部分的 Java 虛擬機器都可動態擴充套件,只不過 Java 虛擬機器規範中也允許固定長度的虛擬機器棧),當擴充套件時無法申請到足夠的記憶體時會丟擲 OutOfMemoryError 異常;
/**
 * @Description: 棧溢位
 * 
 * -Xss引數設定棧容量
 * 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:18935
Exception in thread "main" java.lang.StackOverflowError
	at com.tyron.outofmemory.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:16)
	at com.tyron.outofmemory.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:16)
	at com.tyron.outofmemory.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:16)
	at com.tyron.outofmemory.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:16)
	at com.tyron.outofmemory.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:16)
	at com.tyron.outofmemory.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:16)

結果表明:在單個執行緒下,無論是由於棧幀太大,還是虛擬機器棧容量太小,當記憶體無法分配的時候,虛擬機器丟擲的都是 StackOverflowError 異常,而不是OutOfMemoryError。

如果測試時不限於單執行緒,通過不斷地建立執行緒的方式倒是可以產生記憶體溢位異常。自己可以建立多個執行緒,進行測試,在這種情況下,給每個執行緒的棧分配的記憶體越大,反而越容易產生記憶體溢位異常但是:由於在Windows 平臺的虛擬機器中, Java 的執行緒是對映到作業系統的核心執行緒上的,所以多執行緒程式碼執行時有較大的風險,可能會導致作業系統假死。

原理解析:
作業系統分配給每個程序的記憶體是有限制的,譬如 32 位的 Windows 限制為 2GB。虛擬機器提供了引數來控制 Java 堆和方法區的這兩部分記憶體的最大值。剩餘的記憶體為 2GB(作業系統限制)減去 Xmx(最大堆容量),再減去 MaxPermSize(最大方法區容量),程式計數器消耗記憶體很小,可以忽略掉。如果虛擬機器程序本身耗費的記憶體不計算在內,剩下的記憶體就由虛擬機器棧和本地方法棧“瓜分”了。每個執行緒分配到的棧容量越大,可以建立的執行緒數量自然就越少,建立執行緒時就越容易把剩下的記憶體耗盡。
如何解決:
如果是建立過多執行緒導致的記憶體溢位,在不能減少執行緒數或者更換 64 位虛擬機器的情況下,就只能通過減少最大堆和減少棧容量來換取更多的執行緒。如果沒有這方面的經驗,這種通過“減少記憶體”的手段來解決記憶體溢位的方式會比較難以想到。

3、方法區和執行時常量池溢位
3.1執行時常量池導致的記憶體溢位異常

如果要向執行時常量池中新增內容,最簡單的做法就是使用 String.intern()這個 Native 方法。該方法的作用是:如果字串常量池中已經包含一個等於此 String 物件的字串,則返回代表池中這個字串的String 物件;否則,將此 String 物件包含的字串新增到常量池中,並且返回此 String 物件的引用。在JDK1.6及之前的版本中,由於常量池分配在永久代內,我們可以通過-XX:PermSize 和-XX:MaxPermSize 限制方法區的大小,從而間接限制其中常量池的容量程式碼執行時常量池導致的記憶體溢位異常。而使用JDK1.7執行這段程式就不會得到相同的結果,while迴圈將一直進行下去,JDK1.8也是。

/**
 * @Description: 執行時常量池溢位 
 * VM args -XX:PermSize=10M -XX:MaxPermSize=10M -XX:MaxMetaspaceSize=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 = 111;
		while (true) {
			list.add(String.valueOf(i++).intern());
			System.out.println(list.size());
		}
	}
}

JDK1.6執行結果:
常量池OOM
JDK1.7執行結果:
JDK1.7
JDK1.8執行結果和JDK1.7一樣。

執行結果的不同,是由於不同的JDK版本在字串常量池的實現方式不同:

// String.intern()返回引用的測試
public class RuntimeConstantPoolOOM {
	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);
	}
}
// 執行結果(JDK1.6及以前版本)
false
false
// 執行結果(JDK1.7及以前版本)
true
false

產生差異的原因是:在JDK 1.6中,intern()方法會把首次遇到的字串例項複製到永久代中,返回的也是永久代中這個字串例項的引用,而由StringBuilder建立的字串例項在Java堆上,所以必然不是同一個引用,將返回false。而JDK 1.7(以及部分其他虛擬機器,例如JRockit)的intern()實現不會再複製例項,只是在常量池中記錄首次出現的例項引用,因此intern()返回的引用和由StringBuilder建立的那個字串例項是同一個。對str2比較返回false是因為“java”這個字串在執行StringBuilder.toString()之前已經出現過,字串常量池中已經有它的引用了,不符合“首次出現”的原則,而“計算機軟體”這個字串則是首次出現的,因此返回true。
關於JDK1.7下第二個執行結果的更詳細解釋,可以參看連結6

3.2 方法區的記憶體溢位異常

方法區用於存放Class的相關資訊,如類名、訪問修飾符、常量池、欄位描述、方法描述等。對於這些區域的測試,基本的思路是執行時產生大量的類去填滿方法區,直到溢位。雖然直接使用Java SE API也可以動態產生類(如反射時的GeneratedConstructorAccessor和動態代理等),但在本次實驗中操作起來比較麻煩。故藉助CGLib直接操作位元組碼執行時生成了大量的動態類。

import java.lang.reflect.Method;
import net.sf.cglib.proxy.Enhancer;
import net.sf.cglib.proxy.MethodInterceptor;
import net.sf.cglib.proxy.MethodProxy;

/**
 * @Description: 藉助CGLib使方法區出現記憶體溢位異常
 *               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 {
	}

}

執行該程式碼需要匯入兩個jar包:程式碼清單2-8jar包
執行結果:
方法區OOM
方法區溢位也是一種常見的記憶體溢位異常,一個類要被垃圾收集器回收掉,判定條件是比較苛刻的。在經常動態生成大量Class的應用中,需要特別注意類的回收狀況。這類場景除了上面提到的程式使用了CGLib位元組碼增強和動態語言之外,常見的還有:大量JSP或動態產生JSP檔案的應用(JSP第一次執行時需要編譯為Java類)、基於OSGi的應用(即使是同一個類檔案,被不同的載入器載入也會視為不同的類)等。

4、本地直接記憶體溢位

DirectMemory容量可通過-XX:MaxDirectMemorySize指定,如果不指定,則預設與Java堆最大值(-Xmx指定)一樣,下面程式碼中越過了DirectByteBuffer類,直接通過反射獲取Unsafe例項進行記憶體分配(Unsafe類的getUnsafe()方法限制了只有引導類載入器才會返回例項,也就是設計者希望只有rt.jar中的類才能使用Unsafe的功能)。因為,雖然使用DirectByteBuffer分配記憶體也會丟擲記憶體溢位異常,但它丟擲異常時並沒有真正向作業系統申請分配記憶體,而是通過計算得知記憶體無法分配,於是手動丟擲異常,真正申請分配記憶體的方法是unsafe.allocateMemory()。

// 使用unsafe分配本機記憶體
import java.lang.reflect.Field;
import sun.misc.Unsafe;

/**
 * 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);
	}
}
Exception in thread "main" java.lang.OutOfMemoryError
	at sun.misc.Unsafe.allocateMemory(Native Method)
	at com.tyron.outofmemory.DirectMemoryOOM.main(DirectMemoryOOM.java:24)

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

Java程式碼導致OutOfMemoryError錯誤的解決:
需要重點排查以下幾點

檢查程式碼中是否有死迴圈或遞迴呼叫。
檢查是否有大迴圈重複產生新物件實體。
檢查對資料庫查詢中,是否有一次獲得全部資料的查詢。一般來說,如果一次取十萬條記錄到記憶體,就可能引起記憶體溢位。這個問題比較隱蔽,在上線前,資料庫中資料較少,不容易出問題,上線後,資料庫中資料多了,一次查詢就有可能引起記憶體溢位。因此對於資料庫查詢儘量採用分頁的方式查詢。
檢查List、MAP等集合物件是否有使用完後,未清除的問題。List、MAP等集合物件會始終存有對物件的引用,使得這些物件不能被GC回收。

文章參考: