JVM虛擬機-內存分佈

JVM虛擬機-內存分佈

1.內存區域劃分

Java虛擬機在執行Java程序的過程中,會把它所管理的內存劃分爲不同的數據區域。下面一張圖描述了一個HelloWorld.java文件被JVM加載到內存中的過程。

a. HelloWorld.java文件首先需要經過編譯器編譯,生成HelloWorld.class字節碼文件

b. Java程序中訪問HelloWorld這個類時,需要通過ClassLoader(類加載器)將HelloWorld.class加載到JVM內存中。

c. JVM 中的內存可以劃分爲若干個不同的數據區域,主要分爲:程序計數器、虛擬機棧、本地方法棧、堆、方法區

img

1.1程序計數器(Program Counter Register)

Java程序是多線程的,CPU可以在多個線程中分配執行時間片段。當某一個線程被CPU掛起時,需要記錄代碼已經執行到的位置,方便CPU重新執行此線程時,知道從哪行指令開始執行。這就是程序計數器的作用。

“程序計數器”是虛擬機中一塊較小的內存空間,主要用於記錄當前線程執行的位置。

每個線程都會記錄一個當前方法執行到的位置,當 CPU 切換回某一個線程上時,則根據程序計數器記錄的數字,繼續向下執行指令。

img

實際上除了上圖演示的恢復線程操作之外,其它一些我們熟悉的分支操作、循環操作、跳轉、異常處理等也都需要依賴這個計數器來完成。

關於程序計數器還有幾點需要格外注意:

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

1.2虛擬機棧

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

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

虛擬機的初衷是用來描述Java方法執行的內存模型,每個方法被執行的時候,JVM都會在虛擬機棧中創建一個棧幀。

棧幀

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

一個線程包含多個棧幀,而每個棧幀都包含局部變量表,操作數棧,動態連接,返回地址等。

img

局部變量表

局部變量表是變量值的存儲空間,我們調用方法時傳遞的參數,以及在方法內部創建的局部變量都保存在局部變量表中。在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_2
         5: iload_1
         6: iadd
         7: iload_0
         8: iadd
         9: ireturn

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

注意:系統不會爲局部變量賦予初始值(實例變量和類變量都會被賦予初始值),也就是說不存在類變量那樣的準備階段。

操作數棧

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

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

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

動態連接

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

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

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

返回地址

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

  • 正常退出:指方法中的代碼正常完成,或者遇到任意一個方法返回的字節碼指令(如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_2    (把局部變量表索引爲 2 的值放入操作數棧棧頂)
5: iload_1     (把局部變量表索引爲 1 的值放入操作數棧棧頂)
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 代表加法運算,具體是將操作數棧最上方的兩個元素進行相加操作,然後將結果重新壓入棧頂。

1.3 本地方法棧

本地方法棧和上面介紹的虛擬機棧基本相同,只不過是針對本地(native)方法。在研發過程中如果涉及JNI可能接觸本地方法棧多一些。

1.4 堆

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

1.5 方法區

方法區(MethodArea)也是JVM規範裏規定的一塊運行時數據區。方法區主要是存儲已經被JVM加載的類信息(版本、字段、方法、接口)、常量、靜態變量、即時編譯器編譯後的代碼和數據。該區域同堆一樣,也是被各個線程共享的內存區域。

1.6 異常演示

StackOverflowError棧溢出異常

遞歸調用是造成StackOverflowError的一個常見場景,比如如下代碼:

public class Test {

    public static void main(String[] args) {
        Test t = new Test();
        t.method();
    }

    public  void method(){
        method();
    }
}

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-VYJWz1DC-1585921201928)(C:\Users\大狼狗skr~\AppData\Roaming\Typora\typora-user-images\1585920587043.png)]

在method方法中,遞歸調用了自身,並且沒有設置遞歸結束條件。運行上述代碼時,則會產生StackOverflowError。

原因就是每調用一次method方法時,都會在虛擬機棧中創建出一個棧幀。因爲是遞歸調用,method方法並不會退出,也不會將棧幀銷燬,所以必然會導致StackOverflowError。因此當需要使用遞歸時,需要格外謹慎。

OutOfMemoryError內存溢出異常

理論上,虛擬機棧、堆、方法區都有發生OutOfMemoryError的可能。但是實際項目中,大多發生於堆當中。比如以下代碼:

public class Test {

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

}

在一個無限循環中,動態的向ArrayList中添加新的HeapError對象。這會不斷的佔用堆中的內存,當堆內存不夠時,必然會產生OutOfMemoryError,也就是內存溢出異常。

img

總結

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

img

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

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