Java-JVM-執行時資料區

羊37發表於2024-05-27

參考:

面試必問,JVM記憶體模型詳解

一篇文章掌握整個JVM,JVM超詳細解析!!!

JVM記憶體模型深度刨析

圖靈課堂-JVM極簡教程(影片)

0.是什麼

JVMJava Virtual Machine的縮寫,即Java虛擬機器。它能夠執行編譯後的 Java 位元組碼,使 Java 程式具有跨平臺的特性。

JVM 並不會在安裝JDK或JRE時自動啟動,當我們啟動一個Java程式的時候,JVM才會啟動並載入位元組碼檔案。

img

以hello world為例:

1.編寫Java程式碼

建立一個Java原始檔,例如 HelloWorld.java

public class HelloWorld {
    public static void main(String[] args) {
        System.out.println("Hello, World!");
    }
}

2.編譯Java程式碼

使用 javac 命令將Java原始檔編譯為位元組碼檔案(.class)。

javac HelloWorld.java

3.執行Java程式

使用 java 命令執行編譯後的位元組碼檔案,這時JVM會啟動並載入位元組碼檔案。

java HelloWorld

當執行 java HelloWorld 時,JVM的啟動過程如下:

  • 載入類檔案:JVM會從類路徑中查詢 HelloWorld 類,並將其載入到記憶體中。

  • 執行類初始化:執行類的靜態初始化塊和靜態變數的初始化。

  • 執行main方法:找到並執行 HelloWorld 類的 main 方法。

1.為什麼

JVM的存在主要是為了實現Java語言的跨平臺特性,使得編寫一次程式碼即可在任何安裝了JVM的平臺上執行,同時提供自動記憶體管理和垃圾回收機制,簡化了開發者的記憶體管理工作。

序號 優勢 描述
1 跨平臺性 實現了 "Write Once, Run Anywhere"(WORA)理念,確保 Java 程式可以在任何安裝了 JVM 的平臺上執行。
2 記憶體管理和垃圾回收 提供自動記憶體管理和垃圾回收機制,減少記憶體洩漏和其他記憶體管理錯誤的風險。
3 安全性 透過位元組碼驗證和沙箱機制,確保 Java 應用程式的執行安全。
4 高效的效能最佳化 包含即時編譯(JIT)、熱點程式碼最佳化和逃逸分析等多種效能最佳化技術,提高 Java 程式的執行效率。
5 多語言支援 支援多種程式語言(如 Scala、Kotlin、Groovy、Clojure 等),使得 JVM 成為一個多語言的執行時平臺。
6 良好的生態系統和社群支援 擁有龐大的開發者社群和成熟的生態系統,提供豐富的工具和資源。
7 方便的多執行緒支援 提供強大的多執行緒支援,簡化併發程式設計,管理執行緒的建立和排程。

2.JVM的邏輯結構

程式啟動了嘛,記憶體跑起來了,記憶體管理的時候,JVM定義了一些邏輯上的概念。

為了執行位元組碼,JVM在記憶體中定義了一系列的資料區,用於在執行時儲存各類資料,即執行時資料區(Runtime Data Areas)

2020122417315762

莫慌,大概就5個主要結構。

中文 英文 功能描述 執行緒共享情況
Heap Area 儲存所有Java物件例項,此區域是物件被分配記憶體和垃圾回收的場所。 共享
方法區 Method Area 儲存已被虛擬機器載入的類資訊、常量、靜態變數及即時編譯後的程式碼。 共享
程式計數器 Program Counter Register 記錄執行緒當前執行的指令地址。若執行Java方法,則指向當前指令的地址;若執行Native方法,則計數器值為空(Undefined)。 私有
虛擬機器棧 JVM Stack 儲存著該執行緒執行方法的所有棧幀。每個棧幀包含區域性變數表、運算元棧、動態連結資訊等。 私有
本地方法棧 Native Method Stack 用於支援Native方法的執行。服務於執行Native程式碼的棧,功能與虛擬機器棧類似。 私有

2.1 程式計數器

PC Register,Register註冊的意思嘛,意思是,這玩意是物理暫存器的一個邏輯抽象。

image-20240527190015009

簡單點理解,這玩意就是記錄下執行緒當前要執行的指令地址,別的啥都不幹。

那要這麼個玩意幹嘛?假設執行緒中斷或者切換了,好,gg了,回來的時候,從哪繼續?有這個就方便多了,別管,反正我按照這個地址搞就完事。

特點 描述
執行緒私有 每個執行緒都有自己的程式計數器,這是執行緒私有的記憶體區域。每個執行緒在建立時會分配一個程式計數器,初始值通常設定為0。
執行緒執行完了自己銷燬了,無需管理吧,沒有GC
指示待執行的指令地址 Java方法:記錄當前要執行的位元組碼指令的地址,根據這個執行完了後,被直譯器更新為下一條指令地址。
Native方法:程式計數器的值為未定義(Undefined)。
無需垃圾回收 程式計數器是一塊相對簡單的記憶體區域,在生命週期內不需要垃圾回收。
沒有OOM(OutOfMemoryError),操作嘛無非就是去更新覆蓋值,也不會追加千百個,怎麼個溢位法?

用程式碼說明下計數器的作用。

public class Za {
    public static void main(String[] args) {
        System.out.println("hello");
    }
}

看下位元組碼

0 getstatic #2 <java/lang/System.out : Ljava/io/PrintStream;>
3 ldc #3 <hello>
5 invokevirtual #4 <java/io/PrintStream.println : (Ljava/lang/String;)V>
8 return

PC幹嘛呢?

1. 初始化階段
PC初始值:程式計數器(PC)初始值為0,指向第一條指令getstatic。

2. 執行getstatic指令
PC = 0
當前指令:getstatic #2 <java/lang/System.out : Ljava/io/PrintStream;>
動作:將System.out載入到運算元棧。
執行完畢,PC更新為3,指向ldc指令。

3. 執行ldc指令
PC = 3
當前指令:ldc #3 <hello>
動作:將常量池中的字串"hello"載入到運算元棧。
執行完畢,PC更新為5,指向invokevirtual指令。

4. 執行invokevirtual指令
PC = 5
當前指令:invokevirtual #4 <java/io/PrintStream.println : (Ljava/lang/String;)V>
動作:呼叫PrintStream的println方法,列印棧頂的字串"hello"。
執行完畢,PC更新為8,指向return指令。

5. 執行return指令
PC = 8
當前指令:return
動作:從當前方法返回。
執行完畢,方法執行結束,PC的值不再指向當前方法的任何指令。

那麼PC這個值是誰來更新的呢?位元組碼直譯器

直譯器負責讀取、解釋和執行每一條位元組碼指令,並在執行完當前指令後,計算下一條指令的地址並更新程式計數器。以下是詳細的解釋和機制:

位元組碼直譯器的工作機制

  • 取指令:直譯器從程式計數器(PC)指向的位置讀取當前位元組碼指令。
  • 解釋指令:直譯器根據讀取的位元組碼指令(操作碼和運算元)進行相應的操作。
  • 更新PC:在執行完當前指令後,直譯器根據當前指令的長度計算下一條指令的地址,並更新程式計數器的值。

2.2 虛擬機器棧

Java棧、Java方法棧,都是說的這玩意,注意啊,方法棧。

image-20240527160205078

棧呢,就是你想的那個棧,執行緒裡無非就是執行方法吧?每個方法就是所謂的棧幀,疊在一起就是所謂的方法棧。

有啥用?執行方法的時候入棧,執行完了出棧。哈,那為啥不用佇列?m1方法沒執行完你main方法還想跑出來啊。

特點 描述
執行緒私有 每個執行緒都有自己的Java棧,與執行緒的生命週期同步建立和銷燬。執行緒執行完事,對應的棧就消失了,無需GC
生命週期 Java棧的生命週期與其所屬執行緒的生命週期相同。
儲存結構 由多個棧幀組成,每個棧幀包含區域性變數表、運算元棧和動態連結資訊等。
固定大小 區域性變數表的大小在編譯時確定,執行時不變。
異常處理 可能丟擲StackOverflowError(棧深度過大)和OutOfMemoryError(記憶體不足或棧擴充套件失敗)。
功能 是執行Java方法的工作區,負責方法呼叫的執行和完成。

2.2.1 棧的棧幀

上面提到了虛擬機器棧是由棧幀組成的,棧幀呢,內部結構是這樣。

image-20240527161859509

元件 描述
區域性變數表 (Local Variables) 儲存方法引數和方法內定義的區域性變數。
根據資料型別,每個變數可能佔用一個或兩個槽位(如longdouble型別)。
區域性變數表的大小在編譯時已確定,並在執行時不變。
運算元棧 (Operand Stack) 棧結構,故是後進先出(LIFO)的,用於存放操作指令過程中的輸入和輸出引數。
運算元棧的最大深度在編譯時確定。
動態連結 (Dynamic Linking) 每個棧幀內部包含對執行時常量池的引用,支援方法、欄位或類的動態連結。
方法返回地址 (Return Address) 當方法呼叫完成後,控制權需返回到呼叫者。方法返回地址記錄了這一跳轉位置的程式計數器(PC)值。
附加資訊 (Frame Data) 可包括除錯資訊和異常處理表,後者用於在方法執行中處理異常情況。

下面介紹兩個比較重點的區域性變數表和運算元棧

2.2.1.1 區域性變數表

區域性變數表,就是存放區域性變數用的,程式碼編譯完了,我們就能知道哪些是區域性變數。

在載入區域性變數表時,區域性變數沒有靜態變數那樣的初始化操作,故區域性變數沒有初值,需手動設定初值,否則編譯不透過。

image-20240527191154563

區域性變數表中每個變數的值咋來的?

  • 執行緒在執行Java方法時,首先會將方法引數放入到區域性變數表。

  • 根據執行時的需要,進行賦值。

    • 基本型別:值直接儲存在區域性變數表中。
    • 引用型別:先在堆中建立物件,再將引用放入到區域性變數表中。

槽位是什麼?

那上面的表格裡不是又在扯啥垃圾話槽位,啥是槽位,就是一個邏輯上的單元,那我說上面的key-value結合起來構成一個槽位也沒問題吧?再來個屬性也沒問題吧,只是一個概念性的東西。

區域性變數表是一個陣列,其中每個槽位可以儲存一個intfloatreference(引用型別),或者是一個returnAddress型別的資料。

對於longdouble型別的資料,因為它們佔用兩個單位的儲存空間,所以會佔用連續的兩個槽位。

img

物件很大時值存的下嗎?

  • 基本型別,如intdouble,區域性變數表直接儲存它們的值。

  • 物件型別,區域性變數表儲存指向堆中物件的引用。

2.2.1.2 運算元棧

為計算而生,儲存計算過程中用到的臨時資料。

運算元棧,就是放執行中的實際資料的,比如說像a=10這裡,10就是我們的運算元。

image-20240527191909202

2.2.2 執行例項

現在結合一段程式碼來理解下:

public class Za {
    public static void main(String[] args) {
        int a = 10;
        int b = 20;
        int c = a + b;
        System.out.println(c);
    }
}

注意啊,我們現在每個方法對應的是棧幀,此處的位元組碼如下。

 0 bipush 10
 2 istore_1
 3 bipush 20
 5 istore_2
 6 iload_1
 7 iload_2
 8 iadd
 9 istore_3
10 getstatic #2 <java/lang/System.out : Ljava/io/PrintStream;>
13 iload_3
14 invokevirtual #3 <java/io/PrintStream.println : (I)V>
17 return

你看到上面,很容易能意識到,a、b、c和形參的args都是當前方法的,區域性變數表如下。

image-20240527163234579

好,注意,這是Class檔案編譯後的區域性變數表,不是執行時結構,你看不到具體的值是啥。

結合這個方法的位元組碼來看下執行過程。

  • 最開始的時候,區域性變數表中沒有值,運算元棧也是空的。

image-20240527193011723

  • 0 bipush 10

bipush(byte push)指令用於將一個單位元組(-128至127)的常量值立即推送到運算元棧上。

image-20240527193025495

  • 2 istore_1

istore(integer store)指令用於將棧頂的整數值儲存到區域性變數表中指定的索引位置,這裡是位置1。

image-20240527193229512

  • 3 bipush 20

image-20240527193308021

  • 5 istore_2

存到2號位置

image-20240527193335469

  • 6 iload_1

iload(integer load)指令則用於將指定區域性變數表中的整數值載入到運算元棧上,這裡是載入1號位置。

image-20240527193458420

  • 7 iload_2

image-20240527193516107

  • 8 iadd

iadd(integer add)是一個執行整數加法的指令,它從運算元棧中彈出兩個整數,將它們相加,然後將結果再次推送到棧頂。

image-20240527193637344

  • 9 istore_3

存到3號位置

image-20240527193657467

  • 10 getstatic #2 <java/lang/System.out : Ljava/io/PrintStream;>

    • 獲取指定類的靜態欄位值,並將其推送到運算元棧頂。
    • 這裡,它從java.lang.System類獲取靜態欄位out
    • 欄位的描述符為Ljava/io/PrintStream;,表示這是PrintStream類的一個物件引用。

    總結下就是:將 PrintStream 類的靜態欄位 System.out 對應的引用(一個指向 PrintStream 物件的地址)壓入運算元棧頂。

    image-20240527194148025

  • 13 iload_3

image-20240527194212102

  • 14 invokevirtual #3 <java/io/PrintStream.println : (I)V>

    • invokevirtual 呼叫例項方法
    • 呼叫之前壓入棧頂的PrintStream物件對應的例項方法println,該方法接受一個整型引數(從運算元棧頂獲取,這裡是iload_3載入的值)
    • 執行列印操作
  • 17 return

    使用無返回值的return指令

就這麼兩個玩意就能玩出花,值也存了,計算也ok了,太厲害啦。

同樣的,可以類比,比如我形參是3個,壓棧的時候是不是可以壓棧3個引數進來。

public class Za {
    public static void main(String[] args) {
        System.out.println(m1(10, 20, 30));
    }

    public static int m1(int a, int b, int c) {
        return a + b + c;
    }
}

看下main方法的位元組碼。

 0 getstatic #2 <java/lang/System.out : Ljava/io/PrintStream;>
 3 bipush 10
 5 bipush 20
 7 bipush 30
 9 invokestatic #3 <cn/yang37/za/Za.m1 : (III)I>
12 invokevirtual #4 <java/io/PrintStream.println : (I)V>
15 return

載入方法,壓棧3個引數,呼叫Za.m1。

那麼m1方法呢,原來連加就是套用兩個數的加法呀。

0 iload_0
1 iload_1
2 iadd
3 iload_2
4 iadd
5 ireturn

2.2.3 棧中常見的異常

  • StackOverflowError:方法太多啦,棧放不下了,死迴圈遞迴分分鐘打滿。-Xss引數,設定虛擬機器棧大小,eg:-Xss256k
  • OutOfMemoryError:執行緒搞的太多了,每個執行緒都想摟一個自己的棧,摟不出來了,沒記憶體用了。

2.3 本地方法棧

本地方法棧用於管理本地方法(Native方法)呼叫,本地方法棧也是執行緒私有的

額,不多寫了,可以參考這個:理解JVM執行時資料區(四)本地方法棧

2.4 方法區

用於儲存已被虛擬機器載入的類資訊、常量、靜態變數,以及即時編譯器編譯後的程式碼等。

在HotSpot JVM中,方法區是一個邏輯上的概念,也被稱為非堆(Non-Heap),一般用來儲存類載入資訊、static變數、JIT實時編譯快取的程式碼、常量池(Constants Pool)等。

不同版本的Java其方法區的實現方式不同,在JDK 8之前,採用的是“永久代”來實現方法區,而在JDK 8之後則是採用MetaSpace的方式來實現。

特徵 描述
類資訊 儲存每個類的結構資訊,包括類的名稱、父類、介面、方法和變數的資料,以及位元組碼。
執行時常量池 方法區的一部分,包含編譯期生成的字面量和符號引用,這些內容在類載入後進入方法區的執行時常量池。
靜態變數 儲存類的所有靜態變數,包括基本資料型別和引用型別的靜態變數。
編譯後的程式碼 儲存即時編譯器(JIT)編譯後的程式碼,以提高程式執行的效能。
實現依賴 方法區的具體實現依賴於JVM的供應商,如HotSpot的永久代(PermGen)被Java 8中的元空間(MetaSpace)替代。
記憶體管理 元空間使用本地記憶體,其大小僅受本地記憶體的限制,這有助於減少OutOfMemoryError的可能性。
異常情況 當方法區記憶體不足時,可能會丟擲OutOfMemoryError

參考下這篇文章:【JVM系列】執行時Java類的記憶體營地——方法區詳解

2.4.1 Java6到7

在Java 6中,方法區主要是透過永久代(PermGen)實現的,這個區域儲存了類資訊、靜態變數、執行時常量池等。

由於永久代的大小是固定的,當載入大量類或大字串時,容易造成記憶體溢位(OutOfMemoryError)。

到了Java 7,為了減輕這種溢位的風險,做了以下兩個主要的調整:

  • StringTable的移動
  • 靜態變數的移動

img

img

2.4.2 Java7到8

Java 8中的最大變革是完全去除了永久代,引入了元空間(Metaspace)。

  • 元空間的引入:元空間使用本地記憶體(native memory),而不是虛擬機器的記憶體。這意味著元空間的大小不再由Java堆的大小直接限制,而是受到系統可用記憶體的限制,從而大大提高了靈活性和可擴充套件性。
  • 記憶體管理的改進:元空間的動態擴充套件能力減少了因類後設資料過多導致的記憶體溢位的可能性。同時,類後設資料的回收變得更加依賴於類載入器的生命週期,而不是簡單的垃圾收集週期。

img

img

2.4.3 方法區和永久代?

注意啊,這裡很繞。

方法區實在虛擬機器規範裡面被定義的,不同的虛擬機器對這個定義的實現不同。

在HotSpot 虛擬機器中在 jdk1.7 版本之前的方法區實現叫永久代(PermGen space),jdk1.8 之後叫做元空間(Metaspace)。

方法區是JVM規範中定義的,永久代是JVM實現(HotSpot)中對於方法區的實現。

2.5 堆

在Java虛擬機器(JVM)中,堆(Heap)是Java記憶體管理的核心區域,主要用於儲存Java應用程式建立的物件和陣列。

這部分記憶體是由所有執行緒共享的,並且是垃圾收集器進行活動的主要區域

堆的主要組成:

永久代在Java 8以後由元空間Metaspace取代

  • 年輕代:新建立的物件首先被分配到年輕代。年輕代包含一個或多個Eden區以及兩個Survivor空間。

  • 老年代:存活時間較長的物件會從年輕代晉升到老年代。老年代的大小和生存週期比年輕代大和長得多。

  • 永久代/元空間

    • 1.7 永久代:永久代主要用於儲存Java類和方法的後設資料,還包括字串常量池和靜態變數(看2.4節的JDK7)。永久代有固定的物理大小,容易在載入大量類和大量字串時溢位,導致OutOfMemoryError: PermGen space錯誤。
    • 1.8 元空間:元空間的最大空間受本地記憶體限制,不再受Java堆的大小限制,從而避免了永久代的溢位問題。可以動態調整大小,減少了記憶體溢位的風險。

image-20240527205227721

堆的具體劃分和垃圾回收,單獨放另一篇文章說。

相關文章