JVM的內存結構(一) 運行時數據區


學習Java的過程, 一個繞不過去的樁便是Java虛擬機JVM. JVM的存在賦予了Java的一次編寫跨平臺部署強大特性, 程序員編寫的Java代碼通過編譯後形成字節碼, 直接由虛擬機執行, 程序員也只需要和JVM打交道, JVM負責下層操作系統和硬件架構的差異性. 而掌握Java的執行和對Java性能進行分析調優的前提, 都是對JVM內部的結構, 邏輯, 設計思想的瞭解.
本文帶大家一起梳理一下JVM的基礎部分, 掌握一個Java程序能夠在JVM上運行的基本流程.

內存結構VS內存模型

首先要明確兩個概念上的區別: 內存結構和內存模型.

  • 內存結構描述的是JVM進程的內存空間是如何佈置的, 包括了我們常常聽到的堆, 棧, 常量池等概念;
  • 內存模型則是一個和併發緊密相關的概念, 描述的是Java中如何保證緩存一致性的機制. 這部分內容會在未來的文章中分析.

JVM的內存結構

首先開局一張圖, 以下是JVM的內存結構的基本面貌, 主要分爲運行時數據區, 類加載器, 執行引擎幾個部分, 紅色的Math表示了一個類從外部加載到JVM的基本流程, 後面會對每個一部分進行更加詳細的分析.

在這裏插入圖片描述

運行時數據區

首先來聊最大的這塊運行時數據區,運行時數據區上的內存分爲兩類, 一類是線程共享的(黃色), 包括了堆和方法區; 另一類是線程私有的(淡紫), 包括了虛擬機棧, 本地方法棧和程序計數器. 這和操作系統中線程擁有的資源(TCB, 執行棧, 計數器與寄存器)是對應的, 至於線程本地的存儲空間, Java中這個部分是採用ThreadLocal類實現的, 關於ThreadLocal的細節與實現, 參考這篇資料. 上述五個區域, 除了程序計數器之外, 都可能會出現OOM. 以下對這五個部分分別進行分析.

虛擬機棧

在這裏插入圖片描述
上面說到, 每個線程都擁有自己的虛擬機棧, 這個棧是Java方法的執行棧, 在圖中左邊的綠色部分展示了一個虛擬機棧的內部結構. 棧的特點就是FILO, 這和方法執行和退出的邏輯關係是一致的【尾遞歸方法可以用棧代替】. 在虛擬機棧中, 執行的每個方法都是一個棧幀, 當調用一個新的方法時, 就向棧中壓入一個新的棧幀,當方法執行完成時, 棧幀從棧裏被彈出. 每一個棧幀包括了上圖中展示的四個部分, 操作數棧, 局部變量表, 動態鏈接和方法出口.

操作數棧

我們常聽到說Java的解釋執行引擎是基於棧的執行引擎, 這裏說的棧就是棧幀上的操作數棧. 操作棧上的元素同樣遵循FILO, 用於字節碼命令寫入和提取內容, 例如在做算術運算時, 棧中的就是數值, 調用其他方法時通過操作數棧傳參. 操作數棧的相關概念, 還有以下的注意點:

  • 操作數棧的元素可以是任意的Java數據類型, long和double佔兩個棧容量.
  • 概念上獨立的棧幀, 實際上有重合部分, 上一個棧的局部變量表會和下一個棧幀的操作數棧部分重合, 避免傳參時的重複複製.

局部變量表

局部變量變量表中存儲的當前方法用到的所有局部變量, 包括基本類型和引用, 在編譯時期方法的局部變量表的大小就能夠計算出來, 並且在運行時通常不發生改變. 局部變量表中包含8種數據類型, byte, short, int, long, float, double, char, boolean, reference和returnAddress. 其中returnAddress不太常用; reference的實現要求滿足:

  • 能夠指向所引用的對象的堆上起始地址
  • 能夠指向方法區的類型數據;

關於局部變量表的其他需要注意的點:

  • long和double佔兩個slot, ref的大小與虛擬機的位數有關
  • 局部變量表的空間是允許複用的
  • 基本類型的局部變量是直接分配在棧上的, 所以不進行初始化的話其值是未定義的

動態連接

動態連接是Java實現多態的基礎之一【重寫的底層實現】.

class文件中的方法名作爲符號引用放在文件的常量池位置, 方法調用指令就以指向方法的符號引用作爲參數, 這些符號引用一部分在編譯時或第一次運行時轉換爲直接引用【靜態連接】, 另一部分在運行時轉換爲直接引用【動態連接】. 關於靜態連接, 動態連接的細節可以查看資料, 我們目前需要知道:

  • 通過靜態連接轉換爲直接引用的主要包括構造器, 靜態方法, 私有方法這些無法被覆蓋的方法, 方法的特點是編譯期可知, 運行期不變. 這種方法的調用稱爲解析調用resolution
  • 在動態連接過程轉換爲直接引用的是虛方法, 這種方法的調用成爲動態分派dispatch
  • 採用靜態還是動態連接的依據是是否在編譯期完成解析; 採用靜態分派/動態分派的依據是按照靜態類型還是實際類型分派
  • 調用的方法是在jvm中通過invokevirtual調用, 則會按照實際類類型從實際類到父類查找能夠匹配的方法, 因爲invokevirtrual會去查看Receiver的實際類型; 通過invokespecialinvokestatic調用則會按照變量的靜態類型進行方法匹配
  • 解析和分派並不是二選一, 例如靜態方法重載就是靜態分派, 解析調用

關於什麼時候按照靜態類型分派什麼時候按照實際類型分派, 可以舉一個靜態分派的例子:

public class TestStatic {
	public static class Human {
	}
	public static class Man extends Human {
	}
	public static class Woman extends Human {
	}
	public static void sayHello(Human m) {
		System.out.println("hello human");
	}
	public static void sayHello(Man m) {
		System.out.println("hello man");
	}
	public static void sayHello(Woman m) {
		System.out.println("hello woman");
	}
	public static void main(String[] args) {
		Human hm1 = new Man();
		Human hm2 = new Woman();
		Human hm  = new Human();
		sayHello(hm); // j結果: hello human
		sayHello(hm1);// 結果: hello human
		sayHello(hm2);// 結果: hello human
	}
}

上述代碼的解釋: 首先來看下字節碼(下方), 調用的是invokestatic, 靜態方法不會被繼承重寫, 所以一旦類確定了就可以確定調用的是哪個類的方法, 滿足編譯期可知, 運行期不變, 在編譯期轉換符號引用轉換爲符號引用【解析】, 但是因爲該方法有重載, 因此還需要選擇一個方法關聯【分派】,因爲是在編譯期進行的, 所以這個過程稱爲靜態分派. 在編譯時我們並不知道hm1, hm2的實際類型, 只能夠知道它的靜態類型(也稱外觀類型), 因此這時最匹配的方法是sayHello(Human).

24 aload_3
25 invokestatic #13 <TestStatic.sayHello>
28 aload_1
29 invokestatic #13 <TestStatic.sayHello>
32 aload_2
33 invokestatic #13 <TestStatic.sayHello>
36 return

方法出口

方法出口就是該方法執行完時從哪裏繼續執行. 方法出口包括了正常完成出口和異常退出出口. 正常完成出口通常是調用者的程序計數器指向的下一行指令, 而異常退出出口一般被被保存在棧幀, 直接由異常處理器處理.

本地方法棧

Java 方法的執行棧稱爲虛擬機棧, 而本地方法的執行棧則稱爲本地方法棧, 本地方法棧是個邏輯概念, 例如Hotspot中就將本地方法棧和虛擬機棧合併實現.

程序計數器

程序計數器類似cpu執行中的pc寄存器, 指向的是當前所執行的字節碼的行號.

堆是存放Java對象的區域, 這裏存放着線程共享的幾乎所有對象, 對象在堆上的空間分配有可行的兩種方案, 當對象內存申請是按照順序申請時, 只需要用一個指針指向當前使用的內存的末尾, 當新對象申請時, 則將指針向後移動一個對象的內存大小, 這種方式稱爲指針碰撞; 但是如果內存使用空間並不是連續的, 則無法使用上述方法, 轉而可以採用空閒列表的方式.

在對象空間申請這個問題上, 當多個線程併發的申請空間時, 如何保證指針能夠正確移動也是問題. 同樣, 這裏有兩種方法, 一種是通過CAS的方式移動指針, 保證操作的原子性, 另一種是爲每個線程預先分配一個區域(本地線程分配緩衝TLAB), 線程申請空間放新的對象時優先使用自己的區域, 使用自己的區域則不會有競爭.

方法區

方法區用於存放加載的類的類信息, 常量, 靜態變量和jit編譯的代碼等數據, 具體包括:

  • JVM中類的元數據在Java堆中的存儲區域。
  • Java類對應的HotSpot虛擬機中的內部表示也存儲在這裏。
  • 類的層級信息,字段,名字。
  • 方法的編譯信息及字節碼。
  • 變量
  • 常量池和符號解析

通過反射能夠拿到的數據都存儲在方法區. 常聽到的永久代是JDK1.6以前Hotspot虛擬機對方法區的實現, 目的是將這部分內存納入統一的垃圾回收管理中, 方法區的回收的目標是常量池和類的卸載. 但是採用永久代管理方法區存在以下問題: 永久代區是有大小限制的, 當有大量常量或運行時會產生大量新類(代理類)時, 會導致這個區域更容易OOM. 而其他一些虛擬機的實現採用了保證這個區域沒有觸碰到以使用堆內存等方案來處理.

JDK8以後的元數據區

從JDK8開始, Hotspot徹底告別了永久代, 將字符串常量移到了Heap中, 方法區移到了元數據區metaspace. metaspace存放在本地內存, 是進程內存的一部分, 擺脫了堆大小的限制, 其大小隻受到操作系統的限制. 從StackOverflow上搬了一張圖過來, 這個Native memory和NIO中的DirectByteBuffer使用的區域是同一個區域.
在這裏插入圖片描述
我們已經弄清楚了元數據區在內存中的位置,接下來討論一下元數據區裏有什麼.

首先插入一小段題外話, class文件的加載. JVM規範中定義了class文件加載的至少三個步驟:

  • 通過一個類的全限定名獲得這個類的二進制字節流
  • 將這個字節流代表的靜態存儲結構轉化爲運行時數據結構(klass)
  • 在內存中生成一個代表這個類的java.lang.Class對象, 作爲方法區這個類的各個數據的訪問入口

在JDK1.6以前運行時數據結構和Class對象都是存放在永久代中的, 但是JDK1.8之後, Class對象存放到了heap中, klass數據和其他和該類相關的數據存放在元數據區.

元數據區分爲兩個部分:

  • klass Metaspace: 非必須, 緊接着heap的連續空間, 目的是提高性能, 當不開啓壓縮指針開關時或-Xmx大於32G(自動關閉壓縮指針)時, 這個空間都不存在, klass數據也放到no-klass中.
  • no-klass Metaspace: 必須, 在native memory中的非連續空間, 存放klass以外的其他數據(比如method,constantPool等), 也可以存放klass

元數據空間的大小可以通過以下參數調整:

參數 說明
-XX:MetaspaceSize 初始空間大小,達到該值就會觸發垃圾收集進行類型卸載,同時GC會對該值進行調整:如果釋放了大量的空間,就適當降低該值;如果釋放了很少的空間,那麼在不超過MaxMetaspaceSize時,適當提高該值。
-XX:MaxMetaspaceSize 最大空間,默認是沒有限制的
-XX:MinMetaspaceFreeRatio 在GC之後,最小的Metaspace剩餘空間容量的百分比,減少爲分配空間所導致的垃圾收集
-XX:MaxMetaspaceFreeRatio 在GC之後,最大的Metaspace剩餘空間容量的百分比,減少爲釋放空間所導致的垃圾收集

這些參數的具體調優策略可以參考這個資料.

總結一下metaspace的特點和好處:

  • 位置移到了堆外使用native memory, 取消了原來的大小限制, 採用新的參數進行控制
  • 字符串常量和Class對象移到了heap中管理
  • 類和類加載器的生命週期一致, 不需要單獨回收某個類的空間, 當類加載器被回收時, 所屬metaspace的內存一併回收

以上總結了JVM內存結構的運行時數據區的結構, 各個部分的功能和特點, 接下來還有兩篇文章, 分別從類加載的角度和GC的角度分析總結JVM的內存結構和管理, 最後以一個類從加載到實例化到銷燬的過程進行總結.

參考資料:

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