Java虛擬機在執行Java程序的過程中,會把它所管理的內存劃分為不同的數據區域。下面這張圖描述了一個HelloWorld.java文件被JVM加載到內存中的過程。
1、HelloWorld.java文件首先需要經過編譯器編譯,生成HelloWorld.class字節碼文件。
2、Java程序中訪問HelloWorld這個類時,需要通過ClassLoader(類加載器)將HelloWorld.class加載到JVM的內存中。
3、JVM中的內存可以劃分為若干個不同的數據區域,主要分為:程序計數器、虛擬機棧、本地方法棧、堆、方法區。
圖1 JVM運行時內存分布
程序計數器
Java程序是多線程的,CPU可以在多個線程中分配執行時間片段。當某一個線程被CPU掛起時,需要記錄代碼已經執行到的位置,方便CPU重新執行此線程時,知道從哪行指令開始執行。這就是程序計數器的作用。
「程序計數器」是虛擬機中一塊較小的內存空間,主要用於記錄當前線程執行的位置。
如下圖所示:每個線程都會記錄一個當前方法執行到的位置,當CPU切換回某一個線程上時,則根據程序計數器記錄的數字,繼續向下執行指令。
圖2 程序計數器記錄位置
實際上除了上圖演示的恢復線程操作之外,其它一些我們熟悉的分支操作、循環操作、跳轉、異常處理等也都需要依賴這個計數器來完成。
關於程序計數器還有幾點需要格外注意:
1、在Java虛擬機規範中,對程序計數器這一區域沒有固定任何OutOfMemoryError情況。
2、線程私有的,每條線程內部都有一個私有程序計數器。它的生命周期隨著線程的創建而創建,隨著線程的結束而死亡。
3、當一個線程正在執行一個Java方法的時候,這個計數器記錄的是正在執行的虛擬機字節碼指令的地址。如果正在執行的是Native方法,這個計數器值則為空(Undefined)。
虛擬機棧
虛擬機棧也是線程私有的,與線程的生命周期同步。在Java虛擬機規範中,對這個區域規定了兩種異常情況:
1、StackOverflowError:當線程請求棧深度超出虛擬機所允許的深度時拋出。
2、OutOfMemoryError:當Java虛擬機動態擴展到無法申請足夠內存時拋出。
在我們學習Java虛擬機的過程中,經常會看到一句話:JVM是基於棧的解釋器執行的,DVM是基於寄存器的解釋器執行的。
上面這句話裡的「基於棧」指的就是虛擬機棧。虛擬機棧的初衷是用來描述Java方法執行的內存模型,每個方法執行被執行的時候,JVM都會在虛擬機棧中創建一個棧幀,接下來看下這個棧幀是什麼。
棧幀(Stack Frame)是用於支持虛擬機進行方法調用和方法執行的數據結構,每一個線程在執行某個方法時,都會為這個方法創建一個棧幀。
我們可以這樣理解:一個線程包含多個棧幀,而每個棧幀內部包含局部變量表、操作數棧、動態連結、返回地址等。如下圖所示:
圖3 棧幀
局部變量表局部變量表是變量值的存儲空間,我們調用方法時傳遞的參數,以及在方法內部創建的局部變量都保存在局部變量表中。在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。
注意:系統不會為局部變量賦予初始值(實例變量和類變量都會被賦予初始值),也就是說不存在類變量那樣的準備階段。
操作數棧操作數棧(Operand Stack)也常稱為操作棧,它是一個後入先出棧(LIFO)。
同局部變量表一樣,操作數棧的最大深度也在編譯的時候寫入方法的Code屬性表中的max_stacks數據項中。棧中的元素可以是任意Java數據類型,包括long和double。
當一個方法剛剛開始執行的時候,這個方法的操作數棧是空的。在方法執行的過程中,會有各種字節碼指令被壓入和彈出操作數棧(比如:iadd指令就是將操作數棧中的兩個元素彈出,執行加法運算,並將結果重新壓回到操作數棧中)。
動態連結動態連結的主要目的是為了支持方法調用過程中的動態連接(Dynamic Linking)。
在一個class文件中,一個方法要調用其它方法,需要將這些方法的符號引用轉化為其所在內存地址中的直接引用,而符號引用存在於方法區中。
Java虛擬機中,每個棧幀都包含一個指向運行時常量池中該棧所屬方法的符號引用,持有這個引用的目的就是為了支持方法調用過程中的動態連接(Dynamic Linking)
返回地址當一個方法開始執行後,只有兩種方法可以退出這個方法:
1、正常退出:指方法中的代碼正常完成,或者遇到任何一個方法返回的字節碼指令(如return)並退出,沒有拋出任何異常。
2、異常退出:指方法執行過程中遇到異常,並且這個異常在方法體內部沒有得到處理,導致方法退出。
無論當前方法採用何種方式退出,在方法退出後都需要返回到方法被調用的位置。程序才能繼續執行。而虛擬機棧中的「返回地址」就是用來幫助當前方法恢復它的上層方法執行狀態。
一般來說,方法正常退出時,調用者的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上的值壓入操作數棧頂。首先在Add.java被編譯成Add.class的時候,棧幀中需要多大的局部變量表,多深的操作數棧都已經完全確定了,並且寫入到了方法表的Code屬性中。因此這會局部變量表的大小是確定的,add()方法中有3個局部變量,因此局部變量表的大小是3,但是操作數棧此時為空。
字節碼指令執行過程
最後執行return指令,將操作數棧頂的元素13返回給上層方法。至此add()方法執行完畢。局部變量表和操作數棧也會相繼被銷毀。
本地方法棧
本地方法棧和上面介紹的虛擬機棧基本相同,只不過是針對本地(native)方法。在開發中如果涉及JNI可能接觸本地方法棧多一些,在遊戲虛擬機的實現中已經將兩個合二為一了(比如HotSpot)。
堆
關於堆的更多介紹參考這篇文章:Java垃圾回收機制(GC)-垃圾回收發生的地點在哪?
方法區
方法區(Method Area)也是JVM規範裡規定的一塊運行時區域。方法區主要是存儲已經被JVM加載的類信息(版本、方法、欄位、接口)、常量、靜態變量、即時編譯器編譯後的代碼和數據。該區域同堆一樣,也是被各個線程共享的內存區域。
注意:關於方法區,很多開發者會將其跟「永久區」混淆。所以我在這裡對這兩個概念進行對比一下:
方法區是JVM規範中規定的一塊區域,但是並不是實際實現,切忌將規範跟實現混為一談,不同的JVM廠商可以有不同版本的「方法區」的實現。HotSpot在JDK1.7以前使用「永久區」(Perm區)來實現方法區,在JDK1.8之後「永久區」就已經被移除了,取而代之的是一個叫做「元空間(metaspace)」的實現方式。方法區是規範層面的東西,規定了這一個區域要存放哪些數據。永久區或metaspace是對方法區的不同實現,是實現層面的東西。總結
對於 JVM 運行時內存布局,我們需要始終記住一點:上面介紹的這 5 塊內容都是在 Java 虛擬機規範中定義的規則,這些規則只是描述了各個區域是負責做什麼事情、存儲什麼樣的數據、如何處理異常、是否允許線程間共享等。千萬不要將它們理解為虛擬機的「具體實現」,虛擬機的具體實現有很多,比如 Sun 公司的 HotSpot、JRocket、IBM J9、以及我們非常熟悉的 Android Dalvik 和 ART 等。這些具體實現在符合上面 5 種運行時數據區的前提下,又各自有不同的實現方式。
最後我們藉助一張圖來概括一下本課時所介紹的內容:
圖4 JVM內存結構
總結來說,JVM 的運行時內存結構中一共有兩個「棧」和一個「堆」,分別是:Java 虛擬機棧和本地方法棧,以及「GC堆」和方法區。除此之外還有一個程序計數器,但是我們開發者幾乎不會用到這一部分,所以並不是重點學習內容。 JVM 內存中只有堆和方法區是線程共享的數據區域,其它區域都是線程私有的。並且程序計數器是唯一一個在 Java 虛擬機規範中沒有規定任何 OutOfMemoryError 情況的區域。