深入學習JVM-記憶體架構圖(二)

柳~發表於2024-12-05

JVM深入學習-記憶體架構圖篇

本篇聚焦於對JVM記憶體架構圖的深度總結與解析。文中將逐一詳盡介紹記憶體架構圖中的各部分,並深入理解JVM執行機制與記憶體管理策略。

記憶體架構圖

image-20231102083110541

JVM架構圖中包含了 類載入子系統(上篇JVM詳細介紹了類載入系統)、執行時資料區、執行引擎、本地介面、本地方法庫。

  • 對於JVM記憶體模型,在jdk1.8時做了調整,將Method Area(方法區/永久代)轉換為元空間並放在本地記憶體中。
  • 類載入子系統負責將類的位元組碼載入至執行時資料區中的方法區中,並在堆記憶體中生成Class物件作為類資訊的訪問入口。

方法區

方法區(Method Area)是Java虛擬機器記憶體結構中的一個重要組成部分,它是執行緒共享的區域。這意味著多個執行緒可以同時訪問方法區中的資訊。

方法區的主要作用是儲存已被虛擬機器載入的類資訊、常量、靜態變數、即時編譯器(JIT)編譯後的程式碼等資料。它就像一個知識庫,為後續Java程式的執行提供各種資訊以確保程式可以執行。

  • 類資訊
    • 類全限定名:完整類名(包含包名和類名),用於在JVM中表示唯一的標識一個類。如com.example.MyClass這個類,com.example是包名,MyClass是類名,這個全限定名可以幫助 JVM 在眾多類中準確地定位和區分不同的類。
    • 欄位資訊:包含變數名稱、型別、訪問修飾符。用於JVM能夠了解類中包含那些欄位。
    • 方法資訊:包含方法的名稱、返回型別、引數列表(引數的型別和順序)以及方法的訪問修飾符等。JVM 透過這些資訊來確定如何呼叫方法以及方法的呼叫規則。
    • 介面呼叫:如果一個類實現了介面,方法區會儲存介面的相關資訊,包括介面的全限定名、介面中定義的方法簽名等。這有助於 JVM 檢查類是否正確地實現了介面以及在執行時實現介面相關的多型呼叫。
  • 靜態變數:類相關的靜態變數是在類初始化階段完成的。並且這些靜態變數在整個程式的生命週期內都儲存在方法區中,並可以被類的例實訪問
  • JIT最佳化程式碼:JIT(Just - In - Time)編譯是一種最佳化技術,JVM 在執行過程中會對頻繁執行的熱點程式碼(Hot - Spot Code)進行動態編譯。這些被 JIT 編譯後的程式碼會儲存在方法區中。JIT 編譯將位元組碼轉換為機器碼,提高了程式碼的執行效率。例如,對於一個頻繁執行的迴圈體或者經常呼叫的方法,JVM 可能會對其進行 JIT 編譯,使得後續的執行可以直接使用編譯後的機器碼,而不是每次都進行位元組碼解釋。
  • 執行時常量池:是方法區的一部分,它是在類載入過程中由位元組碼檔案中的常量池轉換而來的。常量池中的資訊包括字面量和符號引用,在類載入後,這些資訊會被解析並儲存到執行時常量池中。
    • 字面量:字面量是在程式碼中直接出現的常量值。例如,在String str = "hello";中,"hello"就是一個字面量。在類載入過程中,字面量會被儲存到執行時常量池中,並將其轉換為在記憶體中的實際表示形式,通常是一個指向常量值的記憶體地址。這樣,在程式執行過程中,當需要訪問這個字面量時,可以透過這個記憶體地址快速獲取。
    • 符號引用:符號引用是一種在編譯階段對類、方法、欄位等的引用表示方式。例如,在程式碼中的一個方法呼叫myMethod(),在編譯階段這只是一個符號引用,它表示需要呼叫一個名為myMethod的方法,但並沒有確定這個方法的實際記憶體地址。在類載入過程中,符號引用會被解析並轉換為直接引用,也就是確定方法的實際記憶體位置,這個過程也是在執行時常量池中完成的。這些直接引用資訊會被儲存在執行時常量池中,以便在程式執行時能夠準確地呼叫相應的方法或訪問相應的欄位。

元空間替代永久代?

image-20241205163305081

記憶體大小:在jdk1.8之前,永久代是放在堆記憶體中的,也就是說在JVM記憶體中,但是隨著專案的複雜度、框架使用、三方庫的使用,永久代中需要儲存的類越來越多,導致固定記憶體大小的永久代無法適用,所以這裡把永久代轉換成元空間,並把它放到本地記憶體中,不佔用jvm記憶體空間。

垃圾回收:永久代的垃圾回收相對複雜。因為它裡面儲存的類資訊等資料的生命週期和普通物件不同,垃圾回收器很難準確判斷哪些類資訊是可以回收的。例如,一個類載入後,即使沒有任何例項物件存在了,只要這個類還在被其他類引用(比如透過反射),它在永久代中的資訊就不能被回收。這種複雜的回收機制導致永久代的記憶體清理效率較低。

效能問題:永久代中的類資料和字串常量池等內容混在一起,當進行垃圾回收或者記憶體整理時,會對整個永久代進行操作。永久代的垃圾回收會觸發 Full GC,這是非常耗時的過程,在高負載系統中影響較大。而元空間獨立於堆記憶體,大大減少了永久代相關的 Full GC 次數,因此在執行時減少了長時間的中斷。

方法區和其他記憶體結構的關係?

  • 方法區與 Java 虛擬機器棧(Java Virtual Machine Stack)也有關聯。在方法呼叫過程中,Java 虛擬機器棧中的棧幀會包含對方法區中方法資訊的引用。例如,當一個方法被呼叫時,棧幀中的動態連結部分會根據方法的符號引用在方法區中查詢並確定方法的實際執行版本,從而實現方法的正確呼叫。
  • 方法區與堆區(Heap)關係:堆區是物件例項的儲存之地。當執行new操作建立物件時,JVM 需要依據方法區中儲存的類資訊來構建物件。在垃圾回收過程中,方法區中的類資訊對堆區物件的回收策略起到關鍵作用。堆區物件的可達性分析(判定物件是否可被回收的重要步驟)不僅考慮物件之間的引用關係,還涉及物件與方法區中類資訊的關聯。例如,當垃圾回收器在堆區回收物件時,它需要根據物件的類資訊(如是否有finalize方法等)來確定回收的方式和順序。

棧區

棧區是JVM記憶體結構中的一個重要組成部分,負責管理方法呼叫和執行時的資料儲存。

在Java虛擬機器中,棧與執行緒密切相關。每個執行緒在建立時都會分配一個JVM棧。這個棧用於儲存方法呼叫時的相關資訊,包括區域性變數表、運算元棧、動態連結、方法返回地址。

JVM棧中的每個方法呼叫都會建立一個棧幀(Stack Frame),棧幀是棧中的基本儲存單位,用於存放方法呼叫時的資訊。

image-20240522075754906

下面我將以一段程式碼簡述棧幀中儲存的各個部分的含義:

package focus.total;

public class JVMStudy {
    private static final int initData = 6;
    public static User user = new User();

    public int compute(){
        int a=1;
        int b=2;
        int c = (a+b)*10;
        return c;
    }

    public static void main(String[] args) {
        JVMStudy jvmStudy = new JVMStudy();
        jvmStudy.compute();
        System.out.println("計算完成");
    }
}

當上述程式被執行時,JVM虛擬機器會分配一個Main主執行緒,併為該執行緒分配一個棧記憶體空間,並建立main棧幀,程式計數器初始化到main方法位元組碼指令的第一條,並按照順序執行,當執行到compute()方法時,建立compute方法的棧幀。在cmopute方法中我將對棧幀中的儲存結構進行逐個介紹。

image-20240522083230466

反彙編JVMStudy.class程式碼觀察JVM執行的指令

下面關於位元組碼指令的解釋其實就已經把區域性變數表和運算元棧的含義解釋清楚了。

  • 位元組碼檔案本身是 Java 原始碼經過編譯後生成的中間表示形式,它包含了一系列按照特定順序排列的指令碼,這些指令碼就是 Java 程式在 JVM 中執行的基礎。
  • 程式計數器的核心作用就是記錄當前執行緒正在執行的位元組碼指令的地址。在上述反彙編得到的位元組碼執行順序指令碼中,程式計數器會依據位元組碼的執行流程依次指向對應的指令位置。
Compiled from "JVMStudy.java"
public class focus.total.JVMStudy {
  public static focus.total.User user;

  public focus.total.JVMStudy();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public int compute();
    Code:
    //iconst_1 : 將int型別的1壓入運算元棧中
    //istore_1 : 將int型別的值存入區域性變數表中
    //兩個組合起來其實就是我們首先在我們的運算元棧中儲存int值1,然後在區域性變數表中存入a,然後將運算元棧中的值取出,賦給區域性變數表中的a  ----  int a=1;
       0: iconst_1
       1: istore_1
    //iconst_2 : 同上,區別是值為2
    //istore_2 : 同上
    // 這裡思考一個問題,如果此時發生了執行緒切換,那麼當重新返回這個執行緒時,如何知道從哪裡繼續執行?
    //程式計數器:記錄下一行即將執行的程式碼的記憶體地址。
    // 程式計數器是每個程式在執行時都會給他分配的一段記憶體空間程式碼,存放下一次即將執行的指令記憶體地址。
    // 那麼當我們程式在執行3的這行指令時,來了一個優先順序高的執行緒將cpu搶佔過去,那麼此時該執行緒會執行完3指令之後,在程式計數器+1,然後讓出CPU,並掛起。當搶佔的執行緒執行完畢之後,該執行緒重新拿到cpu使用權,此時就會按照程式計數器中儲存的位置區執行。
       2: iconst_2
       3: istore_2
   // iload_1 : 將區域性變數表中第1個位置的整數值載入到運算元棧頂
   // iload_2 : 從區域性變數2中裝載int型別值
   // iadd :執行int型別的加法
   // 這三個指令其實就是 ,從區域性變數表中分別取出a的值和b的值放入運算元棧,然後呼叫iadd命令,將兩個運算元取出運算元棧並完成加法指令。把結果重新壓回我們的運算元棧。
   // 此時我們的運算元棧中存放了 int 值 3
       4: iload_1
       5: iload_2
       6: iadd
    // bipush :向運算元棧中放入 int 值 10
    // imul : 乘法 3 * 10
       7: bipush        10
       9: imul
    // istore_3 : 將棧頂的整數值儲存到區域性變數表的第3個位置。
    // iload_3 :  將區域性變數表中第3個位置的整數值載入到運算元棧頂。
      10: istore_3
      11: iload_3
      12: ireturn

  public static void main(java.lang.String[]);
    Code:
       0: new           #2                  // 建立一個新的JVMStudy物件
       3: dup                                // 複製棧頂的JVMStudy物件引用
       4: invokespecial #3                  // 呼叫JVMStudy的構造方法 "<init>":()V
       7: astore_1                           // 將棧頂的JVMStudy物件引用儲存到區域性變數1
       8: aload_1                            // 將區域性變數1中的JVMStudy物件引用載入到棧頂
       9: invokevirtual #
           
           
           4                  // 呼叫JVMStudy物件的compute方法,返回一個int
      12: pop                                // 彈出棧頂的int返回值(不使用)
      13: getstatic     #5                  // 獲取System類的out欄位(PrintStream物件)
      16: ldc           #6                  // 將字串 "計算完成" 壓入棧頂
      18: invokevirtual #7                  // 呼叫PrintStream的println方法,列印字串
      21: return                             // 從main方法返回

  static {};
    Code:
       0: new           #8                  // 建立一個新的User物件
       3: dup                                // 複製棧頂的User物件引用
       4: invokespecial #9                  // 呼叫User的構造方法 "<init>":()V
       7: putstatic     #10                 // 將棧頂的User物件引用儲存到靜態欄位user
      10: return                             // 從靜態初始化塊返回

動態連結:在 Java 虛擬機器的執行機制中起著關鍵作用。在之前闡述對方法區的理解時,就已經涉及到棧與方法區之間存在的動態連結關係。我們都知道,在類載入階段的解析過程中,會完成符號引用到直接引用的轉換,這一轉換實際上就是將方法區中的常量池轉變為執行時常量池的過程。而這裡所說的動態連結,其核心操作便是把方法的符號引用藉助動態連結這種方式,準確地連結到方法在記憶體中的實際地址,從而為方法的成功呼叫奠定基礎,確保在程式執行過程中,當需要呼叫某個方法時,能夠透過這種動態連結機制迅速定位到方法的實際執行程式碼所在的記憶體位置並順利執行。

那麼此時就有一個疑問,在類載入階段就已經完成轉換了,為什麼這裡還需要進行轉換?

那是因為這裡的動態連結,主要用於處理在編譯時無法確定具體呼叫目標型別的情況,特別是在多型(虛方法呼叫)下發揮作用。

多型場景下的動態連結示例(以Animal為例)

  • 假設存在一個Animal類,它有一個虛方法makeSound()。有兩個子類DogCat,它們分別重寫了makeSound()方法。

  • 當我們在程式碼中有這樣的語句:Animal animal = new Dog(); animal.makeSound();,在編譯階段,編譯器看到的是透過Animal型別的引用animal呼叫makeSound()方法,它生成的位元組碼中對於這個呼叫只有一個符號引用,這個符號引用指向Animal類的makeSound()方法。

  • 但是在執行時,因為animal實際指向的是Dog物件,動態連結就會發揮作用。它會根據物件頭確定animal指向的是Dog型別,然後查詢Dog類的虛方法表,在虛方法表中找到Dog類重寫後的makeSound()方法的實際記憶體地址(直接引用),最後執行Dog類的makeSound()方法。這就是動態連結在多型場景下將符號引用轉換為直接引用的具體過程,確保了根據物件的實際型別呼叫正確的方法。

動態連結與其他概念的關聯

  • 動態連結與棧幀密切相關。在每個棧幀中,都有一個指向執行時常量池該棧幀所屬方法的引用,這個引用用於支援動態連結。當一個方法被呼叫時,會建立棧幀,棧幀中的動態連結部分參與到尋找實際要呼叫的方法的過程中。
  • 動態連結也和類載入過程相互補充。類載入過程中的解析階段主要處理那些在編譯期就能確定唯一呼叫版本的方法(如靜態方法、私有方法等),將它們的符號引用轉換為直接引用。而動態連結側重於在執行時處理虛方法和介面方法等需要根據實際情況確定呼叫版本的方法的符號引用到直接引用的轉換。

image-20241205171601514

方法返回地址

就如下,我們呼叫compute方法時,就會在方法返回地址中記錄,用於儲存compute方法返回後的地址。

public static void main(String[] args) {
    JVMStudy jvmStudy = new JVMStudy();
    jvmStudy.compute();
    System.out.println("計算完成");
}

程式計數器

程式計數器是JVM記憶體模型中的一個重要部分,它是執行緒私有的,也就是說每個執行緒都會按照自己的程式計數器指向指令去按順序執行。

程式計數器的主要職責是告訴JVM接下來應該執行哪條位元組碼指令。

在類載入階段,程式計數器尚未發揮作用。而當程式啟動時,主執行緒的程式計數器會初始化為指向 Main 方法位元組碼指令的首條。隨後,隨著位元組碼指令的逐步執行,它持續更新,每執行完一條指令,便精準指向下一條指令地址,以此確保方法中的位元組碼指令依序執行。若遭遇 if - else 分支語句,程式計數器會依據判斷結果跳轉至相應的位元組碼指令地址;當遇到方法呼叫時,它會先留存當前方法當前位元組碼指令的地址,而後跳轉至被呼叫方法內繼續執行,待被呼叫方法執行完畢,再重新回到之前留存的位置,從而保障程式執行流程的連貫性與準確性。

堆區

堆區(Heap Area)是JVM記憶體模型中的一個重要部分,它是執行緒共享的,這意味著多個執行緒可以同時訪問該區域,獲取物件、陣列等相關資訊。

堆區主要是用於儲存Java物件例項(包括陣列物件),在程式執行過程中,透過new關鍵字建立的物件都會在堆區種分配記憶體空間。

示例程式碼:

Person person = new Person(); 
person.setName("張三")
person.setAge(22);

當程式執行完這段程式碼後,就會在堆種儲存person物件及name和age屬性資訊,在棧種儲存Person型別的物件引用,該引用指向堆記憶體種實際的儲存地址。

關於堆的記憶體模型:

在Java8中可以看到堆記憶體被劃分為年輕代和老年代

  • 年輕代被劃分為三部分,Eden區和兩個大小嚴格相同的Survivor區,根據JVM策略,在經過幾次垃圾回收之後,任然存活魚Survivor區的物件將會被移動到老年代中。
  • 老年代:主要儲存生命週期常的物件,一般是經歷過多次gc都沒有被回收的物件。
  • 年輕代和老年代(1:2),其中年輕代又分為 Eden 、s0 、s1(8:1:1)

image-20240522133556348

簡述物件在堆中的一個簡單歷程:

當程式new一個新的物件,就會把它放在堆中的Eden區,但是當Eden區域放滿之後,就需要進行GC -- (minor gc)

image-20240522153328985

這個gc是由執行引擎後臺發起一個垃圾收集執行緒,去堆Eden中的物件進行回收(可達性演算法),在回收的過程中如果仍有物件被引用那麼就將這些物件複製到 倖存區(其中一個空的,這兩者肯定有一個或者兩個都是空的) ,然後就這樣gc回收,如果一個物件在經過 15 次垃圾回收後依然存活於倖存區中,那麼就會將這個物件放到老年代中。此後GC -- (full gc)也會對老年代的垃圾進行回收。

一段程式碼觀察堆記憶體溢位的情況

下面這段程式碼肯定會記憶體溢位,因為我們新new的物件都是存放在lists集合中,而lists又是在main方法棧幀中的變數,是一個GC Root,所以這些新new的物件都不會被回收!!

public class HeapTest{
	public static void main(String[] args){
		ArrayList<HeapTest> lists = new ArrayList<>();
		while(true){
			lists.add(new HeapTest());
			Thread.sleep(5);
		}
	}
}

使用jvisualvm進行檢測

本地方法棧

在Java虛擬機器(JVM)中,本地方法棧(Native Method Stack)是專門為本地方法(native methods)服務的記憶體區域。當一個執行緒呼叫本地方法時,會使用本地方法棧來執行這些方法。

當Java程式呼叫本地方法時,JVM會儲存當前棧幀,然後在本地方法棧空間中建立當前本地方法的棧幀,透過JNI呼叫本地方法,本地方法執行完畢之後,JVM回到之前的棧幀,繼續執行Java程式碼。

簡單一句話就是執行本地方法的。

示例:本地方法棧的使用
以下是一個簡單的本地方法示例,展示瞭如何使用本地方法棧:

public class NativeExample {
    // 宣告本地方法
    public native void nativePrint();

    static {
        // 載入本地庫
        System.loadLibrary("NativeExample");
    }

    public static void main(String[] args) {
        new NativeExample().nativePrint();
    }
}

假設對應的C程式碼如下:
#include <jni.h>
#include <stdio.h>
#include "NativeExample.h"

// 實現本地方法
JNIEXPORT void JNICALL Java_NativeExample_nativePrint(JNIEnv *env, jobject obj) {
    printf("Hello from native code!\n");
}

執行引擎

執行引擎中包含了直譯器、JIT即時編譯器、垃圾回收

直譯器:直譯器是執行引擎的一個重要組成部分,它的主要工作方式是逐行讀取位元組碼指令並進行解釋執行。例如,當遇到位元組碼指令中的iload(將區域性變數載入到運算元棧)時,直譯器會根據指令的引數,從區域性變數表中找到對應的變數並將其載入到運算元棧中,這個過程是一個一個指令依次進行的。

JIT即時編譯器:JIT 即時編譯器是為了提高 Java 程式的執行效率而引入的。它會在程式執行過程中,對那些頻繁執行的熱點程式碼(透過一些動態監測機制確定)進行編譯。這個編譯過程是將位元組碼轉換為機器碼,這樣在後續執行這些程式碼時,就可以直接執行已經編譯好的機器碼,而不是每次都透過直譯器解釋位元組碼。

垃圾回收:這塊主要是針對堆記憶體中的垃圾物件回收,以免隨著程式的執行物件越來越多導致OOM,具體的GC會在下一篇JVM深入學習中提到。

相關文章