android進階一:程序運行時,內存到底是如何分配的?

之前有很多人將 Java 的內存分爲堆內存(heap)和棧內存(Stack),這種劃分方式在一定程度上體現了這兩塊區域是 Java 工程師最關注的內存區域。但是其實這種劃分方式並不完全準確。
Java的內存區域劃分實際上遠比這複雜:Java虛擬機在執行Java程序的過程中,會把它所管理的內存劃分爲不同的數據區域。下面這張圖描述了一個HelloWorld.java文件被JVM加載到內存中的過程:

  1. HelloWorld.java 文件首先需要經過編譯器編譯,生成 HelloWorld.class 字節碼文件。
  2. Java 程序中訪問HelloWorld這個類時,需要通過 ClassLoader(類加載器)將HelloWorld.class 加載到 JVM 的內存中。
  3. JVM 中的內存可以劃分爲若干個不同的數據區域,主要分爲:程序計數器、虛擬機棧、本地方法棧、堆、方法區。
    在這裏插入圖片描述

程序計數器(Program Counter Register)

Java程序是多線程的,CPU可以在多個線程中分配執行時間片段。當某一個線程被CPU掛起時,需要記錄代碼已經執行到的位置,方便CPU重新執行此線程時,知道從哪行指令開始執行。這就是程序計數器的作用。
“程序計數器”是虛擬機中一塊較小的內存空間,主要用於記錄當前線程執行的位置。
如下圖所示:每個線程都會記錄一個當前方法執行到的位置,當 CPU 切換回某一個線程上時,則根據程序計數器記錄的數字,繼續向下執行指令。
在這裏插入圖片描述
實際上除了上圖演示的恢復線程操作之外,其它一些我們熟悉的分支操作、循環操作、跳轉、異常處理等也都需要依賴這個計數器來完成。
關於程序計數器還有幾點需要格外注意:

  1. 在Java虛擬機規範中,對程序計數器這一區域沒有規定任何OutOfMemoryError情況(或許是感覺沒有必要吧)。
  2. 程序計數器是線程私有的,每條線程內部都有一個私有程序計數器。它的生命週期隨着線程的創建而創建,隨着線程的結束而死亡。
  3. 當一個線程正在執行一個Java方法的時候,這個計數器記錄的是正在執行的虛擬機字節碼指令的地址。如果正在執行的是Native方法,這個計數器值則爲空(Undefined)。

虛擬機棧

虛擬機棧也是線程私有的,與線程的生命週期同步。在Java虛擬機規範中,對這個區域規定了兩種異常情況:

  1. **StackOverflowError:**當線程請求棧深度超出虛擬機棧所允許的深度時拋出。
  2. **OutOfMemoryError:**當 Java 虛擬機動態擴展到無法申請足夠內存時拋出。

在我們學習Java虛擬機的過程當中,經常會看到一句話:

JVM是基於棧的解釋器執行的,DVM是基於寄存器解釋器執行的。

上面這句話裏的“基於棧”指的就是虛擬機棧。虛擬機棧的初衷是用來描述 Java 方法執行的內存模型,每個方法被執行的時候,JVM 都會在虛擬機棧中創建一個棧幀,接下來看下這個棧幀是什麼。

棧幀

棧幀(Stack Frame)是用於支持虛擬機進行方法調用和方法執行的數據結構,每一個線程在執行某個方法時,都會爲這個方法創建一個棧幀。

我們可以這樣理解:一個線程包含多個棧幀,而每個棧幀內部包含局部變量表、操作數棧、動態連接、返回地址等。如下圖所示:
在這裏插入圖片描述

局部變量表

局部變量表是變量值的存儲空間,我們調用方法時傳遞的參數,以及在方法內部創建的局部變量都保存在局部變量表中。在Java編譯成class文件的時候,就會在方法的Code屬性表中的 max_locals 數據項中,確定該方法需要分配的最大局部變量表的容量。如下代碼所示:

public static int add(int k) {
	int i = 1;
	int j = 2;
	return i + j + k;
}

使用 javap -v 反編譯之後,得到如下字節碼指令:

public static int add(int);
	descriptor: (I)I
	flags: ACC_PUBLIC, ACC_STATIC
	Code:
		stack=2, locals=3, args_size=1
			0: iconst_1
			1: istore_1
			2: iconst_2
			3: istore_2
			4: iload_1
			5: iload_2
			6: iadd
			7: iload_0
			8: iadd
			9: ireturn

上面的 locals=3 就是代表局部變量表長度是 3,也就是說經過編譯之後,局部變量表的長度已經確定爲3,分別保存:參數 k 和局部變量 i、j。

注意:系統不會爲局部變量賦予初始值(實例變量和類變量都會被賦予初始值),也就是說不存在類變量那樣的準備階段。這一點會在後續的 Class 初始化課時詳細介紹。

操作數棧

操作數棧(Operand Stack)也常稱爲操作棧,它是一個後入先出棧(LIFO)。

同局部變量表一樣,操作數棧的最大深度也在編譯的時候寫入方法的Code屬性表中的max_stacks數據項中。棧中的元素可以是任意Java數據類型,包括long和double。

當一個方法剛剛開始執行的時候,這個方法的操作數棧是空的。在方法執行的過程中,會有各種字節碼指令被壓入和彈出操作數棧(比如:iadd指令就是將操作數棧中棧頂的兩個元素彈出,執行加法運算,並將結果重新壓回到操作數棧中)。

動態鏈接

動態鏈接的主要目的是爲了支持方法調用過程中的動態連接(Dynamic Linking)。

在一個 class 文件中,一個方法要調用其他方法,需要將這些方法的符號引用轉化爲其所在內存地址中的直接引用,而符號引用存在於方法區中。

Java虛擬機棧中,每個棧幀都包含一個指向運行時常量池中該棧所屬方法的符號引用,持有這個引用的目的就是爲了支持方法調用過程中的動態連接(DynamicLinking)。

返回地址

當一個方法開始執行後,只有兩種方式可以退出這個方法:

  • 正常退出:指方法中的代碼正常完成,或者遇到任意一個方法返回的字節碼指令(如return)並退出,沒有拋出任何異常。
  • 異常退出:指方法執行過程中遇到異常,並且這個異常在方法體內部沒有得到處理,導致方法退出。

無論當前方法採用何種方式退出,在方法退出後都需要返回到方法被調用的位置,程序才能繼續執行。而虛擬機棧中的“返回地址”就是用來幫助當前方法恢復它的上層方法執行狀態。

一般來說,方法正常退出時,調用者的 PC 計數值可以作爲返回地址,棧幀中可能保存此計數值。而方法異常退出時,返回地址是通過異常處理器表確定的,棧幀中一般不會保存此部分信息。

實例講解

我用一個簡單的 add() 方法來演示, 代碼如下:

public int add() {
	int i = 1;
	int j = 2;
	int result = i + j;
	return result + 10;
}

我們經常會使用 javap 命令來查看某個類的字節碼指令,比如 add() 方法的代碼,經過 javap 之後的字節碼指令如下:

	0: iconst_1   (把常量 1 壓入操作數棧棧頂)
	1: istore_1   (把操作數棧棧頂的出棧放入局部變量表索引爲 1 的位置)
	2: iconst_2   (把常量 2 壓入操作數棧棧頂)
	3: istore_2   (把操作數棧棧頂的出棧放入局部變量表索引爲 2 的位置)
	4: iload_1   (把局部變量表索引爲 1 的值放入操作數棧棧頂)
	5: iload_2   (把局部變量表索引爲 2 的值放入操作數棧棧頂)
	6: iadd       (將操作數棧棧頂的和棧頂下面的一個進行加法運算後放入棧頂)
	7: istore_3   (把操作數棧棧頂的出棧放入局部變量表索引爲 3 的位置)
	8: iload_3   (把局部變量表索引爲 3 的值放入操作數棧棧頂)
	9: bipush 10 (把常量 10 壓入操作數棧棧頂)
	11: iadd     (將操作數棧棧頂的和棧頂下面的一個進行加法運算後放入棧頂)
	12: ireturn  (結束)

從上面字節碼指令也可以看到,其實局部變量表和操作數棧在代碼執行期間是協同合作來達到某一運算效果的。接下來通過圖示來看下這幾行代碼執行期間,虛擬機棧的實際情況。
首先說一下各個指令代表什麼意思:

  • iconst 和 bipush,這兩個指令都是將常量壓入操作數棧頂,區別就是:當 int 取值 -1~5 採用 iconst 指令,取值 -128~127 採用 bipush 指令。
  • istore 將操作數棧頂的元素放入局部變量表的某索引位置,比如 istore_5 代表將操作數棧頂元素放入局部變量表下標爲 5 的位置。
  • iload將局部變量表中某下標上的值加載到操作數棧頂中,比如 iload_2 代表將局部變量表索引爲 2 上的值壓入操作數棧頂。
  • iadd代表加法運算,具體是將操作數棧最上方的兩個元素進行相加操作,然後將結果重新壓入棧頂。

首先在Add.java被編譯成Add.class的時候,棧幀中需要多大的局部變量表,多深的操作數棧都已經完全確定了,並且寫入到了方法表的Code屬性中。因此這會局部變量表的大小是確定的,add() 方法中有 3 個局部變量,因此局部變量表的大小爲 3,但是操作數棧此時爲空。

所以代碼剛執行到 add 方法時,局部變量表和操作數棧的情況如下:
在這裏插入圖片描述
icons_1 把常量 1 壓入操作數棧頂,結果如下:
在這裏插入圖片描述
istore_1把操作數棧頂的元素出棧並放入局部變量表下標爲1的位置,結果如下:
在這裏插入圖片描述
可以看出此時操作數棧重新變爲空,並將出棧的元素1保存在局部變量表中

iconst_2把常量2壓入操作數棧頂,結果如下:
在這裏插入圖片描述
istore_2把操作數棧頂的元素出棧並放入局部變量下標爲2的位置,結果如下:
在這裏插入圖片描述
接下來的兩步iload操作,分別是iload_1和iload_2。分別代表的是將局部變量表中下標爲1和下標爲2的元素重新壓入操作數棧中,結果如下:
在這裏插入圖片描述
接下來進行iadd操作,這個操作會將棧頂最上方的兩個元素(也就是1、2)進行加法操作,然後將結果重新壓入到棧頂,執行完之後的結果如下:
在這裏插入圖片描述
istore_3將操作數棧頂的元素出棧,並保存在局部變量表下標爲3的位置。結果如下:
在這裏插入圖片描述
iload_3將局部變量表中下標爲3的元素重新壓入到操作數棧頂,結果如下:
在這裏插入圖片描述
bipush 10將常量10壓入到操作數棧中,結果如下:
在這裏插入圖片描述
再次執行iadd操作,注意此時棧頂最上面的兩個元素爲3和10,所以執行完結果如下:
在這裏插入圖片描述
最後執行return指令,將操作數棧頂的元素13返回給上層方法。至此add()方法執行完畢。局部變量表和操作數棧也會相繼被銷燬。

本地方法棧

本地方法棧和上面介紹的虛擬棧基本相同,只不過是針對本地(native)方法。在開發中如果涉及 JNI 可能接觸本地方法棧多一些,在有些虛擬機的實現中已經將兩個合二爲一了(比如HotSpot)。

Java堆(Heap)是JVM所管理的內存中最大的一塊,該區域唯一目的就是存放對象實例,幾乎所有對象的實例都在堆裏面分配,因此它也是Java垃圾收集器(GC)管理的主要區域,有時候也叫作“GC堆”。同時它也是所有線程共享的內存區域,因此被分配在此區域的對象如果被多個線程訪問的話,需要考慮線程安全問題。

按照對象存儲時間的不同,堆中的內存可以劃分爲新生代(Young)和老年代(Old),其中新生代又被劃分爲 Eden 和 Survivor 區。具體如下圖所示:在這裏插入圖片描述

圖中不同的區域存放具有不同生命週期的對象。這樣可以根據不同的區域使用不同的垃圾回收算法,從而更具有針對性,進而提高垃圾回收效率。

方法區

方法區(MethodArea)也是JVM規範裏規定的一塊運行時數據區。方法區主要是存儲已經被JVM加載的類信息(版本、字段、方法、接口)、常量、靜態變量、即時編譯器編譯後的代碼和數據。該區域同堆一樣,也是被各個線程共享的內存區域。
注意:關於方法區,很多開發者會將其跟“永久區”混淆。所以我們在這裏對這兩個概念進行一下對比:

  • 方法區是 JVM 規範中規定的一塊區域,但是並不是實際實現,切忌將規範跟實現混爲一談。不同的 JVM 廠商可以有不同版本的“方法區”的實現。
  • HotSpot在JDK1.7以前使用“永久區”(或者叫Perm區)來實現方法區,在JDK1.8之後“永久區”就已經被移除了,取而代之的是一個叫作“元空間(metaspace)”的實現方式。

總結一下就是

  • 方法區是規範層面的東西,規定了這一個區域要存放哪些數據。
  • 永久區或者是 metaspace 是對方法區的不同實現,是實現層面的東西。

異常再現

StackOverflowError 棧溢出異常

遞歸調用是造成StackOverflowError的一個常見場景,比如以下代碼:
在這裏插入圖片描述
在method方法中,遞歸調用了自身,並且沒有設置遞歸結束條件。運行上述代碼時,則會產生StackOverflowError。
原因就是每調用一次method方法時,都會在虛擬機棧中創建出一個棧幀。因爲是遞歸調用,method方法並不會退出,也不會將棧幀銷燬,所以必然會導致StackOverflowError。因此當需要使用遞歸時,需要格外謹慎。

OutOfMemoryError 內存溢出異常

理論上,虛擬機棧、堆、方法區都有發生OutOfMemoryError的可能。但是實際項目中,大多發生於堆當中。比如以下代碼:
在這裏插入圖片描述
在一個無限循環中,動態的向ArrayList中添加新的HeapError對象。這會不斷的佔用堆中的內存,當堆內存不夠時,必然會產生OutOfMemoryError,也就是內存溢出異常。
在這裏插入圖片描述
上圖中的Xms和Xmx是虛擬機運行參數,將會在下一節垃圾回收中詳細介紹。

總結

對於 JVM 運行時內存佈局,我們需要始終記住一點:上面介紹的這 5 塊內容都是在 Java 虛擬機規範中定義的規則,這些規則只是描述了各個區域是負責做什麼事情、存儲什麼樣的數據、如何處理異常、是否允許線程間共享等。千萬不要將它們理解爲虛擬機的“具體實現”,虛擬機的具體實現有很多,比如Sun公司的HotSpot、JRocket、IBMJ9、以及我們非常熟悉的AndroidDalvik和ART等。這些具體實現在符合上面5種運行時數據區的前提下,又各自有不同的實現方式。

最後我們藉助一張圖來概括一下本課時所介紹的內容:
在這裏插入圖片描述
總結來說,JVM 的運行時內存結構中一共有兩個“棧”和一個“堆”,分別是:Java 虛擬機棧和本地方法棧,以及“GC堆”和方法區。除此之外還有一個程序計數器,但是我們開發者幾乎不會用到這一部分,所以並不是重點學習內容。 JVM 內存中只有堆和方法區是線程共享的數據區域,其它區域都是線程私有的。並且程序計數器是唯一一個在 Java 虛擬機規範中沒有規定任何 OutOfMemoryError 情況的區域。

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