初識JVM
JVM(Java Virtual Machine)即 Java 虛擬機,是 Java 技術的核心組件之一。JVM的本質就是運行在計算機上的一個程序,通過軟件模擬實現了一臺抽象的計算機的功能。JVM是Java程序的運行環境,負責加載字節碼文件,解釋并執行字節碼文件,同時有著內存管理、垃圾回收等功能。不同系統上的JVM將同一份字節碼文件解釋為該系統能執行的機器碼是Java能一次運行到處編譯的關鍵。
一、Class字節碼文件
Java字節碼文件(.class
文件)是Java編譯器將Java源文件(.Java
文件)編譯后生成的二進制文件。
1.字節碼文件的組成
字節碼文件由基本信息、常量池、字段、方法、屬性組成。
基本信息
- 魔數:字節碼文件標識。
- 版本號:包括主副版本號,是編譯字節碼文件時JDK的版本號。JVM會根據版本號判斷能否運行該字節碼文件。
- 訪問標識:類或接口的訪問權限或屬性(
public
、final
、abstract
等)。 - 類、父類、接口索引:都指向常量池中的一個常量,都是全限定名。
常量池
- 文件字符串:如代碼中用雙引號括起來的字符串,例如
String str = "hello";
里的"hello"
。 - 基本數據類型常量:包括被
final
修飾的基本數據類型常量,像final int num = 10;
中的10
。 - 類和接口的全限定名:如
java/lang/String
表示 java.lang.String 類。 - 字段的名稱及類型的描述符:描述字段的名稱、類型等信息,例如
name:Ljava/lang/String;
表示名為name
的String
類型字段。 - 方法名及參數類型放回類型的描述符:描述方法的名稱、參數類型和返回值類型,例如
main:([Ljava/lang/String;)V
表示main
方法,參數為String
數組,返回值為void
。
字段表集合
字段表集合整體結構包含兩部分:
fields_count
:字段數量。fields
:描述一個具體字段的field_info數組。
field_info
結構:
field_info {u2 access_flags; // 字段訪問標志u2 name_index; // 字段名稱索引u2 descriptor_index; // 字段描述符索引u2 attributes_count; // 字段屬性數量attribute_info attributes[attributes_count]; // 字段屬性表
}
1. access_flags
(訪問標志)
用于標識字段的訪問權限和屬性。
2. name_index
(字段名稱索引)
指向常量池的索引,常量池對應位置存儲著字段的名稱。
3. descriptor_index
(字段描述符索引)
指向常量池,常量池對應位置存儲著字段的描述符。描述符用于表示字段的數據類型。
4. attributes_count
(字段屬性數量)
字段的屬性表的屬性數量。
5. attributes
(字段屬性表)
長度為 attributes_count
的數組,每個元素是一個 attribute_info
結構,用于存儲字段的額外信息,常見的屬性有 ConstantValue
(用于表示 final
靜態字段的常量值)。
示例
以下是一段 Java 代碼:
public class FieldExample {private int num;public static final String MESSAGE = "Hello";
}
使用 javap -v FieldExample.class
查看字節碼文件信息,其中字段表集合部分如下:
// 字段數量fields_count: 2// 字段表項fields:// 第一個字段:private int num#0:access_flags: 0x0002 // ACC_PRIVATEname_index: #2 // 指向常量池中的 "num"descriptor_index: #3 // 指向常量池中的 "I"attributes_count: 0attributes:// 第二個字段:public static final String MESSAGE#1:access_flags: 0x0019 // ACC_PUBLIC | ACC_STATIC | ACC_FINALname_index: #4 // 指向常量池中的 "MESSAGE"descriptor_index: #5 // 指向常量池中的 "Ljava/lang/String;"attributes_count: 1attributes:#0:attribute_name_index: #6 // 指向常量池中的 "ConstantValue"attribute_length: 2constantvalue_index: #7 // 指向常量池中的 "Hello"
方法表集合
方法表集合整體結構包含兩部分:
methods_count
:類或接口中聲明的方法數量。methods
:長度為methods_count
的數組,數組每個元素是method_info
結構,描述一個具體方法。
method_info
結構:
method_info {u2 access_flags; // 方法訪問標志u2 name_index; // 方法名稱索引u2 descriptor_index; // 方法描述符索引u2 attributes_count; // 方法屬性數量attribute_info attributes[attributes_count]; // 方法屬性表
}
1. access_flags
(訪問標志)
標識方法的訪問權限和屬性。
2. name_index
(方法名稱索引)
指向常量池的索引,常量池對應位置存著方法名稱。構造方法名稱是 <init>
,類初始化方法是 <clinit>
。
3. descriptor_index
(方法描述符索引)
指向常量池,常量池對應位置存儲著字段的描述符。描述符用于表示字段的數據類型。
4. attributes_count
(方法屬性數量)
表示該方法屬性表的屬性數量。
5. attributes
(方法屬性表)
元素是 attribute_info
結構的數組。
Code
:存儲方法的字節碼指令、操作數棧深度、局部變量表大小等信息。Exceptions
:列出方法可能拋出的異常。LineNumberTable
:記錄字節碼行號和 Java 源代碼行號的對應關系。
屬性表集合
類的屬性,比如源碼的文件名,內部類列表等。
二、類的生命周期
在Java中,類的生命周期指的是從一個類被加載到虛擬機內存開始,到卸載出內存的全過程,按執行順序依次分為七個階段:加載、驗證、準備、解析、初始化、使用和卸載。
1.加載
類的加載階段
1.獲取二進制流
- 本地文件系統中的
.class
文件。 - 網絡中的
.class
文件。 - ZIP 包(如 JAR、WAR 等)。
- 數據庫中的二進制數據。
- 運行時動態生成(如使用動態代理技術生成字節碼)。
2.轉換成運行時數據結構
將字節碼中的靜態存儲結構轉換為方法區的運行時數據結構。這一步會把類的各種信息(如類的字段、方法、接口等)按照虛擬機的內部格式存儲在方法區中。
3.生成Class對象
在內存中生成一個代表這個類的 java.lang.Class 對象,作為方法區這個類的各種數據的訪問入口。這個 Class 對象是后續反射操作的基礎,程序可以通過它來獲取類的各種信息。
類加載器
類加載器(Class Loader)負責完成類加載階段的所有工作,即根據類的全限定名來加載對應的二進制字節流。
類加載器分為以下幾種:
1.啟動類加載器
啟動類加載器(Bootstrap ClassLoader)是 Java 類加載器體系中最頂層的類加載器,以下從多個方面詳細介紹。
- 實現方式
啟動類加載器由本地代碼(通常是 C++)實現,并非 Java 類。因此在 Java 代碼里無法直接獲取其引用,調用 getClassLoader()
方法返回 null
就代表該類由啟動類加載器加載。
- 加載路徑
啟動類加載器主要負責加載 Java 的核心類庫,這些類庫是 Java 運行環境必不可少的部分,通常位于 JDK 安裝目錄下的 lib
目錄
- 示例代碼:
public class ClassLoaderExample {public static void main(String[] args) {// 獲取 String 類的類加載器ClassLoader classLoader = String.class.getClassLoader();System.out.println("String 類的類加載器: " + classLoader); }
}
-
輸出結果:
String 類的類加載器: null
由于啟動類加載器由本地代碼實現,在 Java 中用 null 表示。
2.拓展類加載器
- 實現方式:在
Java 8
及以前,拓展類加載器由sun.misc.LauncherExtClassLoader
實現;從Java 9
開始,拓展類加載器更名為平臺類加載器(Platform ClassLoader
),由jdk.internal.loader.ClassLoadersPlatformClassLoader
實現。 - 加載路徑:
Java 8
及以前,負責加載JDK
的lib/ext
目錄下的拓展類庫;Java 9
及以后,平臺類加載器加載一些標準擴展模塊。
3.應用類加載器
應用類加載器主要負責加載用戶類路徑下的類和資源。用戶在編譯和運行Java程序時指定的類路徑下的.Class文件、JAR文件等。
類加載器的雙親委派機制
基本概念
在 Java 中,類加載器被組織成樹形結構,存在不同層級的類加載器,如啟動類加載器、拓展類加載器(Java 9 及以后為平臺類加載器)、應用類加載器等。當一個類加載器收到類加載請求時,它不會立即嘗試加載該類,而是先將請求委派給父加載器去處理。只有當父加載器無法加載該類時,子加載器才會嘗試自己加載。
工作流程
- 接收請求:當某個類加載器接收到類加載請求時,會先檢查該類是否已經被加載過。如果已經加載,直接返回該類的
Class
對象;如果未加載,則進入下一步。 - 委派父加載器:將類加載請求委派給父加載器,父加載器繼續重復上述步驟,直到到達啟動類加載器。
- 嘗試加載:從啟動類加載器開始,依次嘗試加載該類。如果啟動類加載器無法加載,再由拓展類加載器(或平臺類加載器)嘗試加載,若還是無法加載,最后由應用類加載器嘗試加載。
- 拋出異常:如果所有類加載器都無法加載該類,會拋出
ClassNotFoundException
異常。
向上委托,向下加載
優點
- 安全性:防止惡意代碼替換 Java 核心類。例如,用戶自定義一個
java.lang.String
類,由于雙親委派機制,啟動類加載器會優先加載 JDK 中的String
類,避免惡意代碼生效。 - 避免重復加載:如果父加載器已經加載了某個類,子加載器就不需要再加載,避免了類的重復加載,提高了系統性能。
- 一致性:保證 Java 核心類在所有應用中使用的是同一版本,避免類沖突。
缺點
雙親委派機制并非完美無缺,在某些場景下會帶來限制。為了解決這些問題,出現了破壞雙親委派機制的情況。
打破雙親委派機制
打破雙親委派機制的場景
- 類隔離需求:
- 場景: 需要在一個JVM實例中,同時加載并存在多個不同版本的同一個類(或具有相同全限定名的類),且這些版本需要互不干擾,分別服務于不同的模塊或應用。
- 沖突: 雙親委派機制默認會保證一個類在同一個類加載器命名空間中只被加載一次。父加載器一旦加載了某個類,其所有子加載器都會看到同一個類,無法實現版本隔離。
- 逆向依賴需求:
- 場景: 由上層類加載器(如啟動類加載器) 加載的基礎框架代碼(如SPI接口),需要動態加載或調用由下層類加載器(如系統/應用類加載器) 加載的具體實現類。
- 沖突: 根據雙親委派機制,上層加載器加載的類無法“看到”或直接請求下層加載器加載的類(委托方向是單向向上的)。基礎框架代碼無法找到并加載應用提供的實現類。
- 動態性與熱部署需求:
- 場景: 需要在應用程序運行期間動態加載、卸載或替換某些類或模塊的代碼,而不需要重啟JVM。
- 沖突: 雙親委派機制下,一個類一旦被父加載器加載,通常在整個JVM生命周期內都會被使用,且同一個類加載器對同一個類名只會加載一次。無法簡單地用新版本的類替換已加載的舊版本類。
打破雙親委派機制的方法
- 自定義類加載器重寫
loadClass()
方法:- 邏輯: 在自定義類加載器的
loadClass(String name, boolean resolve)
方法實現中,改變默認的委托流程。 - 常見策略:
- 優先自加載: 在嘗試將加載請求委派給父加載器之前,先嘗試自己根據特定規則(如從特定路徑、JAR文件)查找并加載目標類。只有在自己找不到時,才調用
super.loadClass()
進入雙親委派流程。(實現類隔離) - 完全控制: 完全接管類的查找和加載邏輯,可能只在加載特定基礎類時才委托給父加載器,或者構建自己的委派規則。(實現高度動態性/模塊化)
- 優先自加載: 在嘗試將加載請求委派給父加載器之前,先嘗試自己根據特定規則(如從特定路徑、JAR文件)查找并加載目標類。只有在自己找不到時,才調用
- 效果: 打破了“總是先委派給父加載器”的默認規則,允許子加載器優先加載或獨立加載類。
- 邏輯: 在自定義類加載器的
- 使用線程上下文類加載器:
- 邏輯: 當上層加載器加載的框架代碼需要加載下層實現類時:
- 框架代碼獲取當前執行線程的上下文類加載器(
Thread.currentThread().getContextClassLoader()
)。這個加載器通常在應用啟動時被設置為應用類加載器或其子類(即下層加載器)。 - 框架代碼直接使用這個上下文類加載器去加載所需的實現類(如調用其
loadClass()
或Class.forName()
方法)。
- 框架代碼獲取當前執行線程的上下文類加載器(
- 效果: 繞過了雙親委派鏈的層級限制,使上層加載器加載的代碼能夠間接地使用下層加載器加載類,實現了“父調用子加載”的逆向委托。(解決SPI等逆向依賴)
- 邏輯: 當上層加載器加載的框架代碼需要加載下層實現類時:
- 構建網狀類加載器模型:
- 邏輯: 摒棄傳統的樹狀父子層級結構,設計一個平級或網狀結構的類加載器關系。
- 加載規則: 每個類加載器在收到加載請求時:
- 首先嘗試自己加載(查找自身管理的模塊或Bundle)。
- 如果自身無法加載,則根據預定義的模塊間依賴關系(如
Import-Package
),直接將請求轉發給能夠提供該類的另一個平級或特定的類加載器(它所依賴的模塊的類加載器),而非其父加載器。
- 效果: 徹底打破了“父->子”的線性委派鏈,類加載決策基于模塊契約和依賴關系,而非固定的繼承層級,實現了高度的動態性、隔離性和熱部署能力。
2.驗證
驗證階段在加載后、準備前執行,目的是保證字節碼文件符合 JVM 規范,保障虛擬機安全。
驗證內容
- 文件格式驗證:檢查字節碼文件格式。如確認魔數為
0xCAFEBABE
,驗證版本號在 JVM 處理范圍,檢查常量池常量類型和引用。 - 元數據驗證:對字節碼語義分析,確保符合 Java 語言規范。包括類繼承關系、接口實現、方法簽名等檢查。
- 字節碼驗證:分析方法體,保證運行安全。驗證操作數棧和局部變量表使用、跳轉指令目標位置、方法調用合法性等。
- 符號引用驗證:在符號引用轉直接引用時,驗證指向的類、方法、字段等是否存在,訪問權限是否合法。
3.準備
準備階段的核心任務是將靜態變量(static修飾)分配內存,并設置初始值(數據類型的零值0、0.0f、0.0d、0L、false),特殊的 static final
常量會在此時被賦顯式值。。這些內存被分配在方法區(方法區在JDK8之前在永久代中,JDK8之后方法區在元空間中)。
4.解析
解析階段的任務是將運行時常量池中的符號引用替換成直接引用。
符號引用:以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可。符號引用和虛擬機的內存布局無關,引用的目標不一定已經加載到內存中。在編譯時,Java 類并不知道所引用的類、方法或字段的實際內存地址,因此會用符號引用來表示。
直接引用:可以直接指向目標的指針、相對偏移量或者是一個能間接定位到目標的句柄。直接引用和虛擬機的內存布局相關,引用的目標必定已經在內存中存在。
類或接口解析
當虛擬機遇到一個類的符號引用時,會根據這個符號引用嘗試找到對應的類。如果該類還未被加載,會觸發類的加載過程。解析過程會檢查該類是否能被當前類訪問,若無法訪問則會拋出 IllegalAccessError 異常。
public class MyClass {private OtherClass obj; // 需解析OtherClass的符號引用
}
字段解析
在解析字段符號引用時,會先對字段所屬的類或接口進行解析,然后按照繼承關系從下往上搜索該字段。如果在搜索過程中找到了同名且類型相同的字段,就將符號引用替換為直接引用;若找不到則拋出 NoSuchFieldError 異常。
public class MyClass {String s = ParentClass.FIELD; // 需解析ParentClass的字段FIELD
}
- 若
ParentClass
未加載,JVM會立即觸發其加載→驗證→準備→解析→初始化的全過程(遞歸執行類加載生命周期)。
類方法解析
類方法解析同樣先解析類或接口符號引用,接著在類的方法表中查找匹配的方法。如果找到符合條件的方法,就將符號引用替換為直接引用;若找不到則拋出 NoSuchMethodError 異常。
public class MyClass {void run() {Utility.doSomething(); // 需解析Utility類的doSomething()方法}
}
接口方法解析
接口方法解析與類方法解析類似,不過是在接口的方法表中查找匹配的方法。若找不到相應方法,也會拋出 NoSuchMethodError 異常。
5.初始化
執行類的初始化代碼,為類的靜態變量賦予在代碼中顯式指定的初始值,同時執行性靜態代碼塊。
觸發時機(嚴格規范)
初始化在以下6種場景立即觸發(若類尚未初始化):
-
new
實例化new MyClass(); // 觸發MyClass初始化
-
訪問/修改靜態字段(非
final
常量)int x = MyClass.staticField; // 觸發 MyClass.staticField = 10; // 觸發
-
調用靜態方法
MyClass.staticMethod(); // 觸發
-
反射調用(
Class.forName()
)Class.forName("com.example.MyClass"); // 觸發
-
子類初始化觸發父類初始化
class Child extends Parent {} new Child(); // 先初始化Parent,再初始化Child
-
JVM啟動時的主類
java MyApp // MyApp類首先初始化
不觸發初始化的場景:
訪問
final
靜態常量(編譯期優化)final static int MAX = 100; // 不觸發初始化
通過數組定義引用類
MyClass[] arr = new MyClass[10]; // 不觸發
實例分析
public class Test1 {public static void main(String[] args) {System.out.println("A");new Test1();new Test1();}public Test1(){System.out.println("B");}{System.out.println("C");}static {System.out.println("D");}}
執行順序分析:
- 類初始化階段(靜態部分):
- 加載
Test1
類時,先執行靜態代碼塊static { System.out.println("D"); }
- 輸出:
D
- 加載
- main 方法執行:
- 執行
System.out.println("A")
- 輸出:
A
- 執行
- 第一次實例化
new Test1()
:- 執行實例初始化塊
{ System.out.println("C"); }
→ 輸出:C
- 執行構造方法
public Test1() { System.out.println("B"); }
→ 輸出:B
- 執行實例初始化塊
- 第二次實例化
new Test1()
:- 再次執行實例初始化塊
→ 輸出:C
- 再次執行構造方法
→ 輸出:B
- 再次執行實例初始化塊
關鍵說明:
- 靜態代碼塊(
static{}
):- 在類加載時執行(首次使用類之前)
- 只執行一次(無論創建多少對象)
- 實例初始化塊(
{}
):- 在每次創建對象時執行
- 先于構造方法執行
public class DemoG2 {public static void main(String[] args) {new B02();System.out.println(B02.a);}
}class A02 {static int a = 0;static {a = 1;}
}class B02 extends A02 {static {a = 2;}}
執行流程分析(含 new B02()
)
- 執行
new B02()
- 觸發
B02
類初始化(父類優先) - 初始化父類
A02
:- 靜態變量賦值:
a = 0
- 執行靜態塊:
a = 1
→ 此時a = 1
- 靜態變量賦值:
- 初始化子類
B02
:- 執行靜態塊:
a = 2
→ 此時a = 2
- 執行靜態塊:
- 創建
B02
實例(無實例初始化塊/構造器輸出)
- 觸發
- 執行
System.out.println(B02.a)
- 輸出靜態變量
a
的值:2
- 輸出靜態變量
輸出結果:2
執行流程分析(去掉 new B02()
)
public class DemoG2 {public static void main(String[] args) {// new B02(); // 被注釋掉System.out.println(B02.a);}
}
- 訪問
B02.a
- 觸發
A02
的初始化(父類靜態字段屬于父類):- 靜態變量賦值:
a = 0
- 執行靜態塊:
a = 1
→ 此時a = 1
- 靜態變量賦值:
B02
類不會被初始化:- 訪問的是父類字段
B02.a
(實際是A02.a
) - 子類
B02
的靜態塊不會執行
- 訪問的是父類字段
- 觸發
- 執行
System.out.println(B02.a)
- 輸出靜態變量
a
的值:1
- 輸出靜態變量
輸出結果:1