Java內存區域與內存溢出

內容參考《深入理解JVM虛擬機》,本文JVM均指HotSpot虛擬機。

Java與C語言針對“內存管理”有很大的不同。

在C語言中,開發者需要維護對象的出生和死亡,往往需要爲每個new出來的對象編寫配套的delete/free代碼來釋放內存,否則可能發生內存泄漏或溢出。

而在Java中,內存由JVM管理,垃圾回收器GC會幫助開發者自動回收不再被引用的對象來釋放內存,使得Java不太會像C語言那樣容易出現內存溢出異常。
雖然這樣看起來很美好,但是如果開發者不瞭解JVM是如何管理內存的,那麼在遇到內存溢出異常時,排查錯誤將毫無頭緒。

1、JVM內存模型

JVM在執行Java程序時,會將內存劃分成若干個數據區域。
不同的數據區域,用途不同、創建及銷燬時間也不盡相同。有的區域隨着JVM進程的啓動而創建,有的則依賴於用戶進程的啓動而創建。

在這裏插入圖片描述

1.1、程序計數器

程序計數器(Program Counter Register)是一塊非常小的內存空間,幾乎可以忽略不計。
它可以看做是線程所執行字節碼的行號指數器,指向當前線程下一條應該執行的指令。對於:條件分支、循環、跳轉、異常等基礎功能都依賴於程序計數器。

對於CPU的一個核心來說,同一時刻只能跑一個線程。
對於JVM的多線程,CPU通過輪流切換分配時間片的方式來實現,爲了使得線程在切換後可以快速定位到執行的指令,每個線程都需要維護一個私有的程序計數器。

如果線程在執行Java方法,計數器記錄的是JVM字節碼指令地址。如果執行的是Native方法,計數器值則爲Undefined

程序計數器是唯一一個沒有規定任何OutOfMemoryError情況的內存區域,意味着在該區域不可能發生OOM異常。

1.2、虛擬機棧

虛擬機棧(Java Virtual Machine Stacks)也是線程私有的,生命週期和線程相同。

虛擬機棧描述的是Java方法執行的內存模型,JVM要執行一個方法時,首先會創建一個棧幀(Stack Frame)用於存放:局部變量表、操作數棧、動態鏈接、方法出口等信息。棧幀創建完畢後開始入棧執行,方法執行結束後即出棧。
方法執行的過程就是一個個棧幀從入棧到出棧的過程。

局部變量表:存放編譯器可知的各種基本數據類型、對象引用、returnAddress類型。
局部變量表所需的內存空間在編譯時就已經確認,運行期間不會修改局部變量表的大小。

在JVM規範中,虛擬機棧規定了兩種異常:

  • StackOverflowError
    線程請求的棧深度大於JVM所允許的棧深度。
    棧的容量是有限的,如果線程入棧的棧幀超過了限制就會拋出StackOverflowError異常,例如:方法遞歸。
  • OutOfMemoryError
    虛擬機棧是可以動態擴展的,如果擴展時無法申請到足夠的內存,則會拋出OOM異常。

1.3、本地方法棧

本地方法棧(Native Method Stack)也是線程私有的,與虛擬機棧的作用非常類似。
區別是虛擬機棧是爲執行Java方法服務的,而本地方法棧是爲執行Native方法服務的。

與虛擬機棧一樣,JVM規範中對本地方法棧也規定了StackOverflowError和OutOfMemoryError兩種異常。

1.4、Java堆

Java堆(Java Heap)是線程共享的,一般來說也是JVM管理最大的一塊內存區域,同時也是垃圾收集器GC的主要管理區域。

Java堆在JVM啓動時創建,作用是:存放對象實例
幾乎所有的對象都在堆中創建,但是隨着JIT編譯器的發展和逃逸分析技術逐漸成熟,棧上分配、標量替換優化技術使得“所有對象都分配在堆上”不那麼絕對了。

由於是GC主要管理的區域,所以也被稱爲:GC堆。
爲了GC的高效回收,Java堆內部又做了如下劃分:

  • 新生代
    • Eden區
    • Survivor區
      • From區
      • To區
  • 老年代

JVM規範中,堆在物理上可以是不連續的,只要邏輯上連續即可。通過-Xms -Xmx參數可以設置最小、最大堆內存。

1.5、方法區

方法區(Method Area)與Java堆一樣,也是線程共享的一塊內存區域。
它主要用來存儲:被JVM加載的類信息,常量,靜態變量,即時編譯器產生的代碼等數據。
也被稱爲:非堆(Non-Heap),目的是與Java堆區分開來。

JVM規範對方法區的限制比較寬鬆,JVM甚至可以不對方法區進行垃圾回收。這就導致在老版本的JDK中,方法區也別稱爲:永久代(PermGen)。

使用永久代來實現方法區不是個好主意,容易導致內存溢出,於是從JDK7開始有了“去永久代”行動,將原本放在永久代中的字符串常量池移出。到JDK8中,正式去除永久代,迎來元空間。

1.6、運行時常量池

運行時常量池(Runtime Constant Pool)是方法區的一部分,用於存放編譯器生成的各種字面量和符號引用。

在代碼中寫死的常量最終會被編譯到字節碼中由JVM加載並保存到常量池中,除了編譯時生成的常量,程序運行期間也可以產生常量,用的比較多的就是:String.intern()

1.7、直接內存

直接內存(Direct Memory)並不是JVM運行時數據區的一部分,說白了直接內存不由JVM管理,也稱:堆外內存。

直接內存不受JVM的限制,但是會受限於操作系統和機器的物理內存。

JDK4中的NIO使用Native函數庫直接分配堆外內存,避免了在Java堆和Native堆中來回複製數據,提高性能。

使用Unsafe類也可以直接操作堆外內存。


2、JVM對象

JVM對內存進行劃分之後,內存最終是用來存放對象的。
所以開發者還必須瞭解對象是如何被創建的?對象內存佈局是怎樣的?以及對象是如何被訪問到的?

2.1、對象的創建

Java程序在運行過程中,無時無刻不在創建對象。

當JVM遇到new指令時,首先去方法區中檢查,類是否存在常量池中?並且檢查類是否被加載、解析、初始化。
如果沒有則執行類的加載過程,反之則開始分配內存。

對象所需的內存在類加載後就已經確定,分配內存只需要從堆中劃分出一塊空閒區域即可。
分配內存有兩種方式,取決於GC算法是否帶有壓縮整理功能,如下:

  • 指針碰撞
    堆內存是絕對規整的,已使用的內存和未使用的內存分開放置,分界點有一個指針指示器,對象分配內存時只需要移動指針指示器即可。
  • 空閒列表
    堆內存不規整,已使用內存和未使用內存交錯排列,此時指針指示器就沒用了,JVM需要維護一個列表,記錄哪些內存已使用,哪些未使用。對象分配內存時從未使用的記錄中劃分出一塊足夠大的即可。

對象創建是非常頻繁的過程,分配內存在多線程下是不安全的,解決方案有兩種:

  • 分配內存進行同步處理
    JVM採用CAS操作保證更新操作的原子性。
  • 本地線程分配緩衝(TLAB)
    內存分配的動作按照不同線程劃分到不同的空間中進行。每個線程在Java堆中預先分配一塊內存,哪個線程要分配內存就在該線程的TLAB上進行分配。通過-XX:+/-UseTLAB來設置是否開啓。

內存分配完成後,JVM會對分配的內存進行初始化,如果開啓了TLAB,初始化動作會提前至TLAB內存分配時進行。

內存初始化後,JVM需要對對象進行一些必要的設置,例如:類型指針、哈希碼、GC年齡, 鎖標誌、偏向線程ID、偏向時間戳等,這些數據保存在對象的對象頭(Object Header)中。
這些工作全部完成以後,對於JVM而言一個對象就算創建完成了。但是對於Java程序而言,顯然對象此時還不可用,因爲構造方法還沒有執行,對象的屬性還沒有賦初始值。
一般來說,執行new指令後會緊接着執行構造方法。

2.2、對象的內存佈局

在JVM中,對象的內存佈局可以分爲三部分:對象頭、實例數據、對齊字節,如下圖所示:
在這裏插入圖片描述

對象頭中的Mark Word用於存儲對象自身的運行時數據,在32位和64位JVM(未開啓壓縮指針)中分別佔用4字節和8字節,即32bit和64bit。
實際上,對象運行時需要存儲的數據很多,32bit和64bit是不夠用的,但是對象頭信息是對象自身定義數據的額外存儲成本,考慮到JVM的空間效率,Mark Word被設計成一個非固定的數據結構以便在極小的空間內存儲儘量多的信息。
在這裏插入圖片描述
除了Mark Word,對象頭中還記錄了Klass Pointer類型指針,指向了對象的類元數據指針。JVM通過Klass Pointer來判斷對象是哪一個類的實例。

並不是所有的JVM實現都會在對象頭中保留類型指針,如果對象訪問通過句柄實現,類型指針會直接保存在句柄中。

對齊字節:HotSpot虛擬機規定對象的大小必須是8字節的整數倍,對象頭剛好是8字節的整數倍(1倍或2倍),如果實例數據不是8字節的整數倍則需要對齊字節來補齊,它沒有什麼實際用處,僅僅起到佔位符的作用。如果實例數據剛好是8字節的整數倍,則不會有對齊字節。

如果對象是數組,那麼對象頭中還會記錄數組的長度,因爲JVM可以根據對象的元數據判斷對象的大小,但是從數組的元空間中無法判斷數組的大小。

2.3、對象的訪問定位

創建對象是爲了使用對象,Java程序通過棧上的reference對象引用來操作堆上的具體對象。

主流的對象訪問方式有兩種:

  • 句柄
    在Java堆中劃分一塊區域作爲句柄池,reference存儲的是句柄的地址,句柄中保存了對象實例和對象類型指針。通過句柄訪問對象,對象頭中可以不保存類型指針Klass Pointer。
  • 直接指針
    reference存儲的直接是對象的內存地址,通過這種方式訪問對象就必須在對象頭中保存對象的類型指針Klass Pointer。

句柄訪問
好處是:reference存儲的是穩定的句柄地址,對象頻繁移動時不用修改reference的值,只需要修改句柄中實例數據的地址。例如:使用複製算法進行垃圾回收時。

直接指針訪問
好處是:速度快,節省了一次指針定位的開銷,由於JVM對對象的訪問是非常頻繁的,所以訪問速度快顯得非常重要,所以HotSpot採用的是直接指針訪問。


3、OOM異常實戰

在JVM規範中,除了程序計數器,其他區域都有可能發生OutOfMemoryError。

3.1、堆溢出

Java堆用來存放對象實例,只要不斷創建對象,且保證不會被GC回收就會導出OOM。

/**
 * VMArgs: -Xms10m -Xmx10m
 */
public static void main(String[] args) {
	List list = new LinkedList();
	while (true) {
		list.add(new Object());
	}
}
//java.lang.OutOfMemoryError: Java heap space

一般來說,程序拋出OOM異常存在兩種情況:

  • 內存泄漏
    本應該被GC回收的對象,仍然存活。
  • 內存溢出
    對象確實需要存活,但是內存不夠。

對於內存泄漏,需要分析Java堆的快照文件,檢查對象爲什麼沒有被GC回收。一般來說,將不會再用到的對象手動賦值爲null可以有效幫助GC回收。

對於內存溢出,如果物理機內存不夠則擴容硬件,物理內存夠則可以通過-Xmx適當調整堆最大內存。

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

HotSpot虛擬機不區分虛擬機棧和本地方法棧,-Xoss參數設置本地方法棧大小將會失效,棧容量只由-Xss設定。

如下代碼,將棧容量設置爲160K,遞歸調用772次時拋出StackOverflowError異常。

class StackDemo{
	int stackLength = 0;

	void func(){
		stackLength++;
		this.func();
	}

	/**
	 * VM Args: -Xss160k
	 */
	public static void main(String[] args) {
		StackDemo stackDemo = new StackDemo();
		try {
			stackDemo.func();
		}catch (Throwable e){
			System.out.println("stackLength:"+stackDemo.stackLength);
			throw e;
		}
	}
}
/*
stackLength:772
Exception in thread "main" java.lang.StackOverflowError
 */

OS分配給每個進程的內存是有限的,如32位Windows限制爲2GB,減去堆內存、方法區、程序計數器所耗內存,剩餘內存被虛擬機棧和本地方法棧瓜分。在多線程程序下,線程開的越多,每個線程被分配的棧容量就越少,越容易出現異常。

3.3、方法區溢出

在JDK8中,HotSpot方法區的實現是:元空間。
通過參數-XX:MetaspaceSize -XX:MaxMetaspaceSize設置元空間內存大小。

類信息存放在方法區,要想把方法區撐滿只需要不斷產生大量的類即可,可以使用CGLIB動態生成類。
如下代碼,設置元空間最大10M,運行不久就會導致OOM,錯誤信息會指明:Metaspace。

class MetaSpaceDemo{

	static class MyClass{}

	/**
	 * VM Args: -XX:MetaspaceSize=10m -XX:MaxMetaspaceSize=10m
	 */
	public static void main(String[] args) {
		while (true) {
			Enhancer enhancer = new Enhancer();
			enhancer.setSuperclass(MyClass.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, args);
				}
			});
			enhancer.create();
		}
	}
}
/*
Exception in thread "main" java.lang.OutOfMemoryError: Metaspace
 */
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章