JVM面試問題系列:深入詳解JVM 記憶體區域及記憶體溢位分析

若小寒發表於2019-03-22

前言

在JVM的管控下,Java程式設計師不再需要管理記憶體的分配與釋放,這和在C和C++的世界是完全不一樣的。所以,在JVM的幫助下,Java程式設計師很少會關注記憶體洩露和記憶體溢位的問題。但是,一旦JVM發生這些情況的時候,如果你不清楚JVM記憶體的記憶體管理機制是很難定位與解決問題的。

一、JVM 記憶體區域

Java虛擬機器在執行時,會把記憶體空間分為若干個區域,根據《Java虛擬機器規範(Java SE 7 版)》的規定,Java虛擬機器所管理的記憶體區域分為如下部分:方法區、堆記憶體、虛擬機器棧、本地方法棧、程式計數器。

JVM面試問題系列:深入詳解JVM 記憶體區域及記憶體溢位分析

1、方法區

方法區主要用於儲存虛擬機器載入的類資訊、常量、靜態變數,以及編譯器編譯後的程式碼等資料。在jdk1.7及其之前,方法區是堆的一個“邏輯部分”(一片連續的堆空間),但為了與堆做區分,方法區還有個名字叫“非堆”,也有人用“永久代”(HotSpot對方法區的實現方法)來表示方法區。

從jdk1.7已經開始準備“去永久代”的規劃,jdk1.7的HotSpot中,已經把原本放在方法區中的靜態變數、字串常量池等移到堆記憶體中,(常量池除字串常量池還有class常量池等),這裡只是把字串常量池移到堆記憶體中;在jdk1.8中,方法區已經不存在,原方法區中儲存的類資訊、編譯後的程式碼資料等已經移動到了元空間(MetaSpace)中,元空間並沒有處於堆記憶體上,而是直接佔用的本地記憶體(NativeMemory)。根據網上的資料結合自己的理解對jdk1.3~1.6、jdk1.7、jdk1.8中方法區的變遷畫了張圖如下(如有不合理的地方希望讀者指出):

JVM面試問題系列:深入詳解JVM 記憶體區域及記憶體溢位分析

去永久代的原因有:

(1)字串存在永久代中,容易出現效能問題和記憶體溢位。

(2)類及方法的資訊等比較難確定其大小,因此對於永久代的大小指定比較困難,太小容易出現永久代溢位,太大則容易導致老年代溢位。

(3)永久代會為 GC 帶來不必要的複雜度,並且回收效率偏低。

2、堆記憶體

堆記憶體主要用於存放物件和陣列,它是JVM管理的記憶體中最大的一塊區域,堆記憶體和方法區都被所有執行緒共享,在虛擬機器啟動時建立。在垃圾收集的層面上來看,由於現在收集器基本上都採用分代收集演算法,因此堆還可以分為新生代(YoungGeneration)和老年代(OldGeneration),新生代還可以分為 Eden、From Survivor、To Survivor。

3、程式計數器

程式計數器是一塊非常小的記憶體空間,可以看做是當前執行緒執行位元組碼的行號指示器,每個執行緒都有一個獨立的程式計數器,因此程式計數器是執行緒私有的一塊空間,此外,程式計數器是Java虛擬機器規定的唯一不會發生記憶體溢位的區域。

4、虛擬機器棧

虛擬機器棧也是每個執行緒私有的一塊記憶體空間,它描述的是方法的記憶體模型,直接看下圖所示:

JVM面試問題系列:深入詳解JVM 記憶體區域及記憶體溢位分析

虛擬機器會為每個執行緒分配一個虛擬機器棧,每個虛擬機器棧中都有若干個棧幀,每個棧幀中儲存了區域性變數表、運算元棧、動態連結、返回地址等。一個棧幀就對應 Java 程式碼中的一個方法,當執行緒執行到一個方法時,就代表這個方法對應的棧幀已經進入虛擬機器棧並且處於棧頂的位置,每一個 Java 方法從被呼叫到執行結束,就對應了一個棧幀從入棧到出棧的過程。

5、本地方法棧

本地方法棧與虛擬機器棧的區別是,虛擬機器棧執行的是 Java 方法,本地方法棧執行的是本地方法(Native Method),其他基本上一致,在 HotSpot 中直接把本地方法棧和虛擬機器棧合二為一,這裡暫時不做過多敘述。

6、元空間

上面說到,jdk1.8 中,已經不存在永久代(方法區),替代它的一塊空間叫做 “ 元空間 ”,和永久代類似,都是 JVM 規範對方法區的實現,但是元空間並不在虛擬機器中,而是使用本地記憶體,元空間的大小僅受本地記憶體限制,但可以通過 -XX:MetaspaceSize 和 -XX:MaxMetaspaceSize 來指定元空間的大小。

二、JVM 記憶體溢位

1、堆記憶體溢位

堆記憶體中主要存放物件、陣列等,只要不斷地建立這些物件,並且保證 GC Roots 到物件之間有可達路徑來避免垃圾收集回收機制清除這些物件,當這些物件所佔空間超過最大堆容量時,就會產生 OutOfMemoryError 的異常。堆記憶體異常示例如下:

/**
 *
 * 設定最大堆最小堆:-Xms20m -Xmx20m
 *
 * 執行時,不斷在堆中建立OOMObject類的例項物件,且while執行結束之前,GC Roots(程式碼中的oomObjectList)到物件(每一個OOMObject物件)之間有可達路徑,垃圾收集器就無法回收它們,最終導致記憶體溢位。
 *
 */

public class HeapOOM {
	static class OOMObject {
	}

	public static void main( String[] args )
	{
		List<OOMObject> oomObjectList = new ArrayList<>();

		while ( true )
		{
			oomObjectList.add( new OOMObject() );
		}
	}
}複製程式碼

執行後會報異常,在堆疊資訊中可以看到:

java.lang.OutOfMemoryError: Java heap space 的資訊,說明在堆記憶體空間產生記憶體溢位的異常。

新產生的物件最初分配在新生代,新生代滿後會進行一次 Minor GC,如果 Minor GC 後空間不足會把該物件和新生代滿足條件的物件放入老年代,老年代空間不足時會進行 Full GC,之後如果空間還不足以存放新物件則丟擲 OutOfMemoryError 異常。

常見原因:記憶體中載入的資料過多如一次從資料庫中取出過多資料;集合對物件引用過多且使用完後沒有清空;程式碼中存在死迴圈或迴圈產生過多重複物件;堆記憶體分配不合理;網路連線問題、資料庫問題等。

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

(1)StackOverflowError:當執行緒請求的棧的深度大於虛擬機器所允許的最大深度,則丟擲StackOverflowError,簡單理解就是虛擬機器棧中的棧幀數量過多(一個執行緒巢狀呼叫的方法數量過多)時,就會丟擲StackOverflowError異常。

最常見的場景就是方法無限遞迴呼叫,如下:

/**
 *
 * 設定每個執行緒的棧大小:-Xss256k
 *
 * 執行時,不斷呼叫doSomething()方法,main執行緒不斷建立棧幀併入棧,導致棧的深度越來越大,最終導致棧溢位。
 *
 */

public class StackSOF {
	private int stackLength = 1;

	public void doSomething()
	{
		stackLength++;

		doSomething();
	}


	public static void main( String[] args )
	{
		StackSOF stackSOF = new StackSOF();

		try {
			stackSOF.doSomething();
		}catch ( Throwable e ) { /* 注意捕獲的是Throwable */
			System.out.println( "棧深度:" + stackSOF.stackLength );

			throw e;
		}
	}
}複製程式碼

上述程式碼執行後丟擲:

Exception in thread "Thread-0" java.lang.StackOverflowError 的異常。

(2)OutOfMemoryError:如果虛擬機器在擴充套件棧時無法申請到足夠的記憶體空間,則丟擲 OutOfMemoryError。

我們可以這樣理解,虛擬機器中可以供棧佔用的空間≈可用實體記憶體 - 最大堆記憶體 - 最大方法區記憶體,比如一臺機器記憶體為 4G,系統和其他應用佔用 2G,虛擬機器可用的實體記憶體為 2G,最大堆記憶體為 1G,最大方法區記憶體為 512M,那可供棧佔有的記憶體大約就是 512M,假如我們設定每個執行緒棧的大小為 1M,那虛擬機器中最多可以建立 512個執行緒,超過 512個執行緒再建立就沒有空間可以給棧了,就報 OutOfMemoryError 異常了。

JVM面試問題系列:深入詳解JVM 記憶體區域及記憶體溢位分析

棧上能夠產生 OutOfMemoryError 的示例如下:

/**
 *
 * 設定每個執行緒的棧大小:-Xss2m
 *
 * 執行時,不斷建立新的執行緒(且每個執行緒持續執行),每個執行緒對一個一個棧,最終沒有多餘的空間來為新的執行緒分配,導致OutOfMemoryError
 *
 */

public class StackOOM {
	private static int threadNum = 0;

	public void doSomething()
	{
		try {
			Thread.sleep( 100000000 );
		} catch ( InterruptedException e ) {
			e.printStackTrace();
		}
	}


	public static void main( String[] args )
	{
		final StackOOM stackOOM = new StackOOM();

		try {
			while ( true )
			{
				threadNum++;

				Thread thread = new Thread( new Runnable()
							    {
								    @Override

								    public void run()
								    {
									    stackOOM.doSomething();
								    }
							    } );

				thread.start();
			}
		} catch ( Throwable e ) {
			System.out.println( "目前活動執行緒數量:" + threadNum );

			throw e;
		}
	}
}複製程式碼

上述程式碼執行後會報異常

在堆疊資訊中可以看到java.lang.OutOfMemoryError: unable to create new native thread的資訊,無法建立新的執行緒,說明是在擴充套件棧的時候產生的記憶體溢位異常。

總結:線上程較少的時候,某個執行緒請求深度過大,會報 StackOverflow 異常,解決這種問題可以適當加大棧的深度(增加棧空間大小),也就是把 -Xss 的值設定大一些,但一般情況下是程式碼問題的可能性較大;在虛擬機器產生執行緒時,無法為該執行緒申請棧空間了。

會報 OutOfMemoryError 異常,解決這種問題可以適當減小棧的深度,也就是把 -Xss 的值設定小一些,每個執行緒佔用的空間小了,總空間一定就能容納更多的執行緒,但是作業系統對一個程式的執行緒數有限制,經驗值在 3000~5000 左右。

在 jdk1.5 之前 -Xss 預設是 256k,jdk1.5 之後預設是 1M,這個選項對系統硬性還是蠻大的,設定時要根據實際情況,謹慎操作。

3、方法區溢位

前面說到,方法區主要用於儲存虛擬機器載入的類資訊、常量、靜態變數,以及編譯器編譯後的程式碼等資料,所以方法區溢位的原因就是沒有足夠的記憶體來存放這些資料。

由於在 jdk1.6 之前字串常量池是存在於方法區中的,所以基於 jdk1.6 之前的虛擬機器,可以通過不斷產生不一致的字串(同時要保證和 GC Roots 之間保證有可達路徑)來模擬方法區的 OutOfMemoryError 異常;但方法區還儲存載入的類資訊,所以基於 jdk1.7 的虛擬機器,可以通過動態不斷建立大量的類來模擬方法區溢位。

/**
 *
 * 設定方法區最大、最小空間:-XX:PermSize=10m -XX:MaxPermSize=10m
 *
 * 執行時,通過cglib不斷建立JavaMethodAreaOOM的子類,方法區中類資訊越來越多,最終沒有可以為新的類分配的記憶體導致記憶體溢位
 *
 */

public class JavaMethodAreaOOM {
	public static void main( final String[] args )
	{
		try {
			while ( true )
			{
				Enhancer enhancer = new Enhancer();

				enhancer.setSuperclass( JavaMethodAreaOOM.class );

				enhancer.setUseCache( false );

				enhancer.setCallback( new MethodInterceptor()
						      {
							      @Override

							      public Object intercept( Object o, Method method, Object[] objects, MethodProxy methodProxy ) throws Throwable {
								      return(methodProxy.invokeSuper( o, objects ) );
							      }
						      } );

				enhancer.create();
			}
		}catch ( Throwable t ) {
			t.printStackTrace();
		}
	}
}複製程式碼

上述程式碼執行後會報:

java.lang.OutOfMemoryError: PermGen space 的異常,說明是在方法區出現了記憶體溢位的錯誤。

4、本機直接記憶體溢位

本機直接記憶體(DirectMemory)並不是虛擬機器執行時資料區的一部分,也不是 Java 虛擬機器規範中定義的記憶體區域,但 Java 中用到 NIO 相關操作時(比如 ByteBuffer 的 allocteDirect 方法申請的是本機直接記憶體),也可能會出現記憶體溢位的異常。

總結

JVM記憶體區域劃分,便於它能夠更加高效的管理自身的記憶體。當程式中出現這種由於JVM造成的記憶體溢位的情況的時候,需要根據不同的情況做不同的分析與處理。

JVM系列:

深入詳解JVM 記憶體區域及記憶體溢位分析

JVM的判斷物件是否已死和四種垃圾回收演算法

JVM 配置常用引數和常用 GC 調優策略

7種JVM垃圾收集器特點,優劣勢、及使用場景!

最後

後續會持續更新效能優化專題知識,寫的不好的地方也希望大牛能指點一下,大家覺得不錯可以點個贊在關注下我,剛剛入駐,以後還會分享更多文章!


相關文章