深入解析 JVM 類加載機制:從字節(jié)碼到運行時對象
一、概述:為什么需要類加載?
Java 語言的核心特性之一是"一次編寫,到處運行",這背后的關(guān)鍵在于 Java 虛擬機(JVM)和其類加載機制。當(dāng)我們編寫好 Java 代碼并將其編譯為 .class 字節(jié)碼文件后,這些靜態(tài)的字節(jié)碼需要被加載到 JVM 中才能變?yōu)榭蓤?zhí)行的動態(tài)對象。類加載就是這個轉(zhuǎn)換過程的核心環(huán)節(jié)。
理解類加載機制能幫助我們:
- 深入理解 Java 動態(tài)擴展機制(如 SPI、熱部署等技術(shù)原理)
- 優(yōu)化程序性能,理解哪些階段耗時及如何調(diào)整參數(shù)優(yōu)化
- 解決實際開發(fā)中遇到的
ClassNotFoundException、NoSuchMethodError、IllegalAccessError等異常 - 實現(xiàn)高級技巧,如編寫自定義類加載器實現(xiàn)模塊化、代碼加密等功能
類加載的完整生命周期包括加載、驗證、準(zhǔn)備、解析、初始化、使用和卸載七個階段。其中前五個階段是類加載的核心過程,下面我們將詳細(xì)解析每個階段。
二、加載 (Loading) - "采購與入庫"階段
核心思想
加載階段是類加載過程的第一步,它的核心任務(wù)非常明確:找到類的字節(jié)碼,并以 JVM 內(nèi)部規(guī)定的格式把它存起來,同時創(chuàng)建一個訪問入口。
將這個階段比喻為一家公司的采購和入庫部門非常貼切:
| 加載階段步驟 | 公司比喻 | 技術(shù)對應(yīng) |
|---|---|---|
| 1. 獲取二進制流 | 采購部門尋找貨源 | 從JAR、網(wǎng)絡(luò)、動態(tài)生成等處獲取字節(jié)碼 |
| 2. 轉(zhuǎn)化存儲結(jié)構(gòu) | 入庫部門按標(biāo)準(zhǔn)存放 | 將字節(jié)流轉(zhuǎn)化為方法區(qū)的運行時數(shù)據(jù)結(jié)構(gòu) |
| 3. 生成Class對象 | 創(chuàng)建庫存查詢目錄 | 在Java堆中創(chuàng)建 java.lang.Class 對象 |
詳細(xì)過程
1. "采購" - 通過類名獲取二進制字節(jié)流
任務(wù):根據(jù)類的全限定名(如 java.lang.String),去找到并拿到這個類的"原始產(chǎn)品"——二進制字節(jié)流(符合 Class 文件格式的二進制數(shù)據(jù))。
關(guān)鍵點:《Java虛擬機規(guī)范》只規(guī)定了要拿到什么,但沒規(guī)定從哪里拿、怎么拿。這個開放性設(shè)計是 Java 強大擴展能力的基石。
"采購"渠道的多樣性:
- 從本地倉庫拿:從 ZIP、JAR、EAR、WAR 等壓縮包中讀取(最常見的方式)
- 從網(wǎng)絡(luò)上訂貨:從網(wǎng)絡(luò)上下載(如早期的 Web Applet)
- 自己生產(chǎn)(OEM):在運行時動態(tài)計算生成(動態(tài)代理技術(shù)是典型例子)
- 由其他原材料加工:由 JSP 文件生成對應(yīng)的 Class 文件
- 從加密倉庫取:從加密的文件中讀取,讀取時再實時解密(常見代碼保護手段)
- 從數(shù)據(jù)庫里讀:特定中間件服務(wù)器(如 SAP Netweaver)會把程序代碼存到數(shù)據(jù)庫里
// 自定義類加載器示例:從特定路徑加載類
public class CustomClassLoader extends ClassLoader {
private String classPath;
public CustomClassLoader(String classPath) {
this.classPath = classPath;
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
// 1. "采購":根據(jù)類名找到文件,并讀取為字節(jié)數(shù)組 byte[]
byte[] classData = loadClassData(name);
if (classData == null) {
throw new ClassNotFoundException();
}
// 2. "質(zhì)檢與入庫":調(diào)用defineClass將字節(jié)數(shù)組轉(zhuǎn)換為Class對象
// 此方法會完成驗證、準(zhǔn)備等后續(xù)步驟
return defineClass(name, classData, 0, classData.length);
}
private byte[] loadClassData(String className) {
// 實現(xiàn)從特定路徑(如加密文件)讀取類文件的邏輯
// 將類名轉(zhuǎn)換為文件路徑
String path = classNameToPath(className);
try {
InputStream is = new FileInputStream(path);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
int bufferSize = 4096;
byte[] buffer = new byte[bufferSize];
int bytesNumRead;
while ((bytesNumRead = is.read(buffer)) != -1) {
baos.write(buffer, 0, bytesNumRead);
}
return baos.toByteArray();
} catch (IOException e) {
return null;
}
}
private String classNameToPath(String className) {
// 將類名轉(zhuǎn)換為文件路徑
return classPath + File.separatorChar +
className.replace('.', File.separatorChar) + ".class";
}
}
2. "質(zhì)檢與入庫" - 轉(zhuǎn)化存儲結(jié)構(gòu)
任務(wù):把上一步拿到的"原始產(chǎn)品"(字節(jié)流),轉(zhuǎn)換成 JVM 方法區(qū)這個"中央倉庫"所能識別的內(nèi)部數(shù)據(jù)結(jié)構(gòu)。
比喻:采購回來的貨物可能有各種包裝(不同來源的字節(jié)流),入庫部門需要把它們拆包,按照公司倉庫(方法區(qū))自己的貨架標(biāo)準(zhǔn)和分類方式重新擺放好。
注意:方法區(qū)內(nèi)部的具體數(shù)據(jù)結(jié)構(gòu)完全由各個 JVM 實現(xiàn)自行決定,《規(guī)范》不做要求。就像不同公司的倉庫管理系統(tǒng)可以完全不同。
3. "創(chuàng)建庫存目錄" - 生成 Class 對象
任務(wù):在 Java 堆 中創(chuàng)建一個 java.lang.Class 對象。
作用:這個對象就像倉庫的總目錄或訪問接口。程序想要訪問方法區(qū)中關(guān)于這個類的所有元數(shù)據(jù)信息(比如有哪些方法、哪些字段),都必須通過這個 Class 對象來進行。
比喻:貨物已經(jīng)按規(guī)則存入了大型立體倉庫(方法區(qū)),為了方便大家查找,我們在辦公室的電腦(Java 堆)里建了一個數(shù)據(jù)庫條目(Class 對象),通過它就能查到貨物在哪、有多少。
兩種特殊的"貨物":數(shù)組 vs 非數(shù)組類
非數(shù)組類
加載方式:就是我們上面說的"采購入庫"流程。開發(fā)人員可以高度控制這個過程,通過自定義類加載器(重寫 findClass() 方法)來決定如何獲取字節(jié)流,從而實現(xiàn)熱部署、模塊化等高級特性。
數(shù)組類
加載方式:數(shù)組類比較特殊,它不是通過類加載器"采購"回來的,而是由 JVM 直接在生產(chǎn)線上"組裝"出來的。
規(guī)則:
- 如果數(shù)組的元素類型是引用類型(比如
String[],Object[]),那么 JVM 會先去加載這個元素類型。這個數(shù)組類會被標(biāo)記為與加載該元素類型的類加載器關(guān)聯(lián)。 - 如果數(shù)組的元素類型是基本類型(比如
int[],boolean[]),那么 JVM 會直接把數(shù)組類標(biāo)記為與啟動類加載器關(guān)聯(lián)。
訪問性:數(shù)組的訪問權(quán)限和它的元素類型一致。int[] 的訪問性是 public。
重要細(xì)節(jié)
- 交叉進行:加載階段和連接階段(尤其是驗證)并不是完全割裂的。為了性能,JVM 可能在拿到一部分字節(jié)流后就開始進行文件格式驗證(比如檢查魔數(shù)),但主體上仍然保持"先加載,后連接"的順序。
- 可控性:在"加載"階段的"獲取二進制字節(jié)流"這個動作上,開發(fā)人員擁有最大的控制權(quán),這是通過自定義類加載器實現(xiàn)的。
簡單來說,"加載"階段就是 JVM 的物資準(zhǔn)備階段,它為后續(xù)的校驗、初始化等步驟準(zhǔn)備好了最重要的原材料——類的二進制數(shù)據(jù),并建立了訪問這些數(shù)據(jù)的基礎(chǔ)設(shè)施。它的開放性設(shè)計為 Java 的繁榮生態(tài)奠定了堅實的基礎(chǔ)。
三、驗證 (Verification) - "嚴(yán)格安檢"階段
核心思想
驗證階段就像是 JVM 的超級嚴(yán)格的安全檢查站。它的唯一目的就是:確保你要加載的 Class 文件是個"良民",而不是一個攜帶病毒或邏輯炸彈的"黑客",從而保證 JVM 自身的安全。
因為 Class 文件不一定來自 Java 編譯器,它可能被篡改或惡意生成,所以 JVM 絕不能信任任何外部傳來的字節(jié)流,必須進行徹底檢查。
安全檢查的四個關(guān)卡(四大驗證)
驗證過程非常復(fù)雜,但總體上會按順序通過四個關(guān)卡的檢查。只有全部通過,Class 才能被成功加載。
1. 文件格式驗證 - "文件格式與合法性檢查"
檢查什么? 檢查字節(jié)流是否符合 Class 文件格式規(guī)范。這是基于二進制字節(jié)流本身的檢查。
比喻:就像海關(guān)檢查護照。官員會看:
- 護照的封面對不對?(魔數(shù)是不是
0xCAFEBABE?) - 護照的版本是否有效?(主次版本號是否支持?)
- 護照里的欄目填寫是否正確無誤?(常量池里的常量類型、索引值是否合法?UTF-8編碼是否正確?)
目的:保證這個字節(jié)流能被正確解析并存入方法區(qū)。只有通過此關(guān)檢查,數(shù)據(jù)才會被存入方法區(qū),后續(xù)檢查都將基于方法區(qū)內(nèi)的結(jié)構(gòu)進行,而不再直接操作字節(jié)流。
2. 元數(shù)據(jù)驗證 - "語義檢查"
檢查什么? 對類的元信息進行語義分析,看是否符合 Java 語言規(guī)范。
比喻:就像公司HR進行背景調(diào)查。HR會核實:
- 你的簡歷上寫了你爸的名字(父類),這是真的嗎?(除了Object,所有類都應(yīng)有父類)
- 你爸是最高法院的終身大法官(final 類)嗎?如果是,那就不能聲稱繼承了他。
- 你聲稱掌握所有必要技能(非抽象類),那你是否真的實現(xiàn)了你爸或你接口要求的所有方法?
- 你的經(jīng)歷描述有沒有和你爸的簡歷沖突?(比如重寫了父類的 final 方法,或者方法重載不符合規(guī)則)
目的:保證類的元數(shù)據(jù)信息沒有語義上的矛盾。
3. 字節(jié)碼驗證 - "邏輯檢查"(最復(fù)雜的一關(guān))
檢查什么? 對方法體中的代碼進行邏輯校驗。這是最復(fù)雜、最耗時的部分。
比喻:就像電影審查部門審核劇本。審查員要確保劇本邏輯通順,不會導(dǎo)致演員(JVM)在表演時出事故:
- 演員的道具使用是否合理?會不會出現(xiàn)"拿起一把手槍(int類型),卻當(dāng)火箭筒(long類型)來用"這種類型錯誤?
- 劇本里的跳轉(zhuǎn)指令(如 goto)會不會讓演員跳下舞臺(跳到方法體之外)?
- 類型轉(zhuǎn)換是否合理?能讓一個普通人(子類)扮演超人(父類),但不能讓超人(父類)強行扮演一個具體的普通人(子類),更不能讓一棵樹(無關(guān)類)來扮演超人。
著名的"停機問題":理論上,無法通過程序100%準(zhǔn)確地判斷另一段程序是否包含所有類型的邏輯錯誤(就像無法通過算法判斷任何程序是否會無限循環(huán)下去)。所以,通過字節(jié)碼驗證的程序未必絕對安全,但沒通過的一定有問題!
性能優(yōu)化 - StackMapTable:為了加速這個耗時的過程,JDK 6 之后,編譯器(javac)會在編譯時預(yù)先計算好很多驗證信息(記錄每個關(guān)鍵點的變量類型和操作數(shù)棧狀態(tài)),并保存在 Class 文件的 StackMapTable 屬性中。JVM 驗證時只需要對照檢查這些預(yù)先生成的記錄即可,大大提高了效率。
public class VerificationExample {
public void method(boolean flag) {
// 字節(jié)碼驗證會分析出,無論走哪個分支,操作數(shù)棧在方法返回前都是平衡的
if (flag) {
System.out.println("True");
} else {
System.out.println("False");
}
// 如果這里缺少 return 語句,字節(jié)碼驗證會失敗
// 編譯器會報錯:Missing return statement
}
// 可能引起驗證問題的示例
public void problematicMethod() {
// 理論上,這里可能包含驗證器無法通過的復(fù)雜控制流
// 但現(xiàn)代編譯器通常會在編譯期就阻止這樣的代碼
}
}
4. 符號引用驗證 - "外部依賴檢查"
檢查什么? 發(fā)生在解析階段(將符號引用轉(zhuǎn)換為直接引用時)。檢查類是否能夠成功訪問到它所引用的外部類、方法、字段等資源。
比喻:就像項目啟動前的最終資源確認(rèn)。你要開始一個項目,需要確認(rèn):
- 你依賴的其他公司(外部類) 真的存在嗎?
- 那家公司的某個部門(方法/字段) 真的存在嗎?
- 你有權(quán)限訪問那個部門的資源嗎?(訪問權(quán)限檢查,比如不能訪問別人的
private方法)
目的:確保解析動作能夠正常執(zhí)行。如果失敗,會拋出 NoSuchFieldError、NoSuchMethodError、IllegalAccessError 等異常。
總結(jié)與要點
| 驗證階段 | 核心問題 | 比喻 | 失敗后果 |
|---|---|---|---|
| 1. 文件格式驗證 | "這是一個合法的Class文件嗎?" | 海關(guān)檢查護照 | 拋出 VerifyError |
| 2. 元數(shù)據(jù)驗證 | "這個類的描述信息自洽嗎?" | HR背景調(diào)查 | 拋出 VerifyError |
| 3. 字節(jié)碼驗證 | "這個類的方法邏輯正確嗎?" | 劇本審查 | 拋出 VerifyError |
| 4. 符號引用驗證 | "這個類能訪問到它需要的所有資源嗎?" | 項目資源確認(rèn) | 拋出 IncompatibleClassChangeError 等 |
重要提示:
- 非常耗時:驗證階段是類加載過程中工作量最大、最耗性能的部分之一。
- 可以關(guān)閉:如果確認(rèn)所有代碼都是可靠且反復(fù)驗證過的(例如生產(chǎn)環(huán)境),可以使用
-Xverify:none參數(shù)來關(guān)閉大部分驗證措施,以顯著提高類加載速度。但這會帶來安全風(fēng)險。 - 設(shè)計演進:驗證規(guī)則在《Java虛擬機規(guī)范》中變得越來越具體和復(fù)雜,體現(xiàn)了對安全性日益增長的要求。
總而言之,驗證階段是 JVM 抵御惡意代碼的第一道也是最重要的一道防線,它通過層層遞進的嚴(yán)格檢查,確保了后續(xù)操作的基礎(chǔ)安全。
四、準(zhǔn)備 (Preparation) - "賦默認(rèn)值"階段
核心思想
準(zhǔn)備階段是 JVM 為類變量(static 變量) "分配房間并給每個房間貼上默認(rèn)值標(biāo)簽"的階段。此時尚未執(zhí)行任何Java代碼,所以程序員指定的值還不會被賦予。
一個比喻:布置新房
想象一下,JVM 正在為一個新來的"類"布置它的靜態(tài)區(qū)域(方法區(qū))。
- 加載階段:已經(jīng)確定了這個"類"需要多大的靜態(tài)空間(有多少個
static變量)。 - 準(zhǔn)備階段:JVM 開始分配這些空間,并在每個空間里放上一個默認(rèn)的初始值。
- 初始化階段:后面才會執(zhí)行程序員編寫的
static代碼塊或賦值語句,把這些默認(rèn)值替換成程序員真正想要的值。
兩個關(guān)鍵要點
1. 分配誰?不分配誰?
- 僅分配類變量 (Class Variables):即被
static修飾的變量。準(zhǔn)備階段只處理它們。 - 不分配實例變量 (Instance Variables):沒有被
static修飾的變量。它們要等到將來創(chuàng)建對象實例時,才會隨著對象一起在 Java 堆中分配內(nèi)存和初始化。
比喻:這就像給一個公司布置辦公室。
static變量是公司的公共財產(chǎn)(如前臺電話、會議室投影儀)。公司一注冊成立(類被加載),這些就要準(zhǔn)備好。- 實例變量是員工的個人辦公用品(如員工的電腦、筆記本)。要等員工入職(對象被
new出來)才會分配。
2. "初始值"是什么?零值!
在準(zhǔn)備階段,JVM 會給類變量賦予一個系統(tǒng)默認(rèn)的"零值",而不是程序員在代碼中寫的值。
為什么? 因為此時還沒有開始執(zhí)行任何 Java 方法(包括 <clinit> 類構(gòu)造器),賦值語句自然也不會執(zhí)行。
例子與對比:
| Java 代碼 | 準(zhǔn)備階段后的值 | 原因 |
|---|---|---|
public static int value = 123; |
0 (int的零值) |
賦值 123 的 putstatic 指令在 <clinit> 方法中,尚未執(zhí)行。 |
public static boolean enabled; |
false (boolean的零值) |
尚未被顯式初始化。 |
public static Object obj; |
null (reference的零值) |
尚未被顯式初始化。 |
基本數(shù)據(jù)類型的零值表:
| 數(shù)據(jù)類型 | 零值 |
|---|---|
int, byte, short, char |
0 |
long |
0L |
float |
0.0f |
double |
0.0d |
boolean |
false |
reference (引用類型) |
null |
特殊情況:常量 (static final)
有一種特殊情況,它打破了"準(zhǔn)備階段總是賦零值"的規(guī)則。
規(guī)則:如果類字段的字段屬性表中存在 ConstantValue 屬性,那么在準(zhǔn)備階段,變量值就會直接被初始化為 ConstantValue 屬性所指定的值,而不是零值。
何時生成 ConstantValue 屬性?
當(dāng)變量同時被 static 和 final 修飾,并且它的值是編譯期常量時,編譯器 (javac) 會為它生成 ConstantValue 屬性。
例子:
public static final int CONST_VALUE = 123; // 編譯期常量
對于這行代碼,在準(zhǔn)備階段結(jié)束后,CONST_VALUE 的值就是 123,而不是 0。
為什么?
因為 123 是一個編譯期就能確定的常量,JVM 認(rèn)為沒有必要先賦零值,再在初始化階段改為 123。直接在準(zhǔn)備階段一步到位,更高效。
public class PreparationExample {
public static int normalStatic = 123; // 準(zhǔn)備階段后值為 0
public static final int CONST_STATIC = 456;// 準(zhǔn)備階段后值為 456
// 非常量final字段,準(zhǔn)備階段后仍為0
public static final int NON_CONST_STATIC;
static {
NON_CONST_STATIC = 789; // 在初始化階段才賦值
}
}
內(nèi)存位置的演變
- 邏輯概念:類變量在方法區(qū)分配。
- 物理實現(xiàn):
- 在 JDK 7 及之前,HotSpot 使用永久代來實現(xiàn)方法區(qū),類變量確實在永久代。
- 在 JDK 8 及之后,永久代被移除,類變量隨著
Class對象一起存放在 Java 堆 中。 - 但"方法區(qū)"這個邏輯概念依然存在,所以我們從邏輯上仍然說類變量屬于方法區(qū)。
總結(jié)
準(zhǔn)備階段是一個承上啟下的簡單階段,它只做兩件事:
- 分配內(nèi)存:為
static變量在方法區(qū)(邏輯上)分配空間。 - 賦系統(tǒng)初始值:為這些變量賦上對應(yīng)數(shù)據(jù)類型的零值 (
0,false,null等)。
記住那個例外:被 static final 修飾的編譯期常量,會在準(zhǔn)備階段直接賦值為代碼中寫的值。
這個過程完成后,類變量就都有了"默認(rèn)值",等待著在初始化階段被程序員寫的代碼賦予"真正的值"。
五、解析 (Resolution) - "查地址"階段
核心思想
解析階段是 JVM 的 "查地址" 階段。它的任務(wù)非常明確:將常量池中的符號引用(一個名字)替換為直接引用(一個具體的地址或句柄)。
核心概念:符號引用 vs. 直接引用
理解這兩個概念是理解解析階段的關(guān)鍵。
| 特性 | 符號引用 (Symbolic Reference) | 直接引用 (Direct Reference) |
|---|---|---|
| 是什么 | 一個名字、一個描述 | 一個指針、一個偏移量、一個句柄 |
| 內(nèi)容 | 用文本形式描述目標(biāo)(如 java/lang/Object) |
直接指向目標(biāo)在內(nèi)存中的位置 |
| 例子 | 像通訊錄里的 "張三" | 像張三的 "手機號碼" 或 "家庭住址" |
| 與內(nèi)存的關(guān)系 | 無關(guān)。它只是一個字符串,不關(guān)心目標(biāo)是否已加載到內(nèi)存。 | 緊密相關(guān)。直接指向內(nèi)存中的具體位置,目標(biāo)必須已存在。 |
| 特點 | 統(tǒng)一:所有JVM實現(xiàn)的Class文件中的符號引用格式都是一樣的。 | 不統(tǒng)一:不同JVM實現(xiàn)的內(nèi)存布局不同,翻譯出的直接引用也不同。 |
簡單比喻:
- 編譯時:你的代碼里寫
user.getName()。編譯器只知道你要調(diào)用一個叫getName的方法,它把這個方法名(符號引用)寫在Class文件的常量池里。 - 解析時:JVM 在加載類后,需要真正執(zhí)行
user.getName()了。這時,它就去常量池找到getName這個名字(符號引用),然后查表,找到這個方法在內(nèi)存中的實際入口地址(直接引用),并將常量池中的記錄替換成這個地址。以后每次調(diào)用,就直接使用這個地址,不再需要查找。
解析的時機
《Java虛擬機規(guī)范》沒有嚴(yán)格規(guī)定解析發(fā)生的確切時間,只要求在執(zhí)行某些特定字節(jié)碼指令(如 getfield, invokevirtual, new 等)之前,必須先對它們用到的符號引用進行解析。
因此,JVM 有兩種策略:
- eager resolution (急切解析):在類加載完成后,立刻解析所有符號引用。
- lazy resolution (懶惰解析):等到第一次使用某個符號引用時,才去解析它。
現(xiàn)在的主流JVM(如HotSpot)默認(rèn)使用懶惰解析,這可以提升性能,避免加載一個類時就去解析它所有可能還不會用到的其他類。
解析的內(nèi)容(四大類)
解析動作主要針對類或接口、字段、類方法、接口方法等符號引用進行。其核心邏輯可以概括為:先解析所有者,再在其基礎(chǔ)上查找目標(biāo)成員。
1. 類或接口的解析 (從 CONSTANT_Class_info 解析)
目標(biāo):將類似 java/lang/Object 這樣的符號引用,解析為JVM內(nèi)部表示該類的數(shù)據(jù)結(jié)構(gòu)(如Klass)的直接引用。
步驟:
- 加載:如果符號引用代表的是一個普通類(非數(shù)組),JVM 會將這個全限定名交給當(dāng)前類的類加載器去加載這個類。這個過程會觸發(fā)該類自身的加載、驗證、準(zhǔn)備等階段。
- 權(quán)限檢查:檢查當(dāng)前類
D是否有權(quán)訪問這個被解析的類C。如果沒有(例如,C不是public且也不和D在同一個包內(nèi)),則拋出IllegalAccessError。
// 類解析示例
public class ClassResolutionExample {
public void createObject() {
// 這里會觸發(fā)對java.util.ArrayList類的解析
// 1. 檢查常量池中的符號引用"java/util/ArrayList"
// 2. 使用當(dāng)前類加載器加載ArrayList類(如果尚未加載)
// 3. 檢查訪問權(quán)限
// 4. 將符號引用替換為直接引用
java.util.ArrayList list = new java.util.ArrayList();
}
}
2. 字段解析 (從 CONSTANT_Fieldref_info 解析)
目標(biāo):解析一個字段,例如 java/lang/System.out。
步驟:
- 解析所有者:先解析字段所屬的類或接口的符號引用(即先完成上一步的類解析)。
- 字段查找:在成功解析出的類或接口
C中,按以下順序自下而上地查找匹配的字段:- 步驟1:在
C自身中查找。 - 步驟2:如果
C實現(xiàn)了接口,會從上至下遞歸搜索它的所有接口。 - 步驟3:如果
C不是Object,則自下而上地遞歸搜索它的父類。
- 步驟1:在
- 如果找到,返回字段的直接引用;如果找不到,拋出
NoSuchFieldError。 - 權(quán)限檢查:檢查當(dāng)前類是否有權(quán)訪問該字段(如不能訪問
private字段),否則拋出IllegalAccessError。
3. 方法解析 (從 CONSTANT_Methodref_info 解析)
目標(biāo):解析一個類的方法(非接口方法)。
步驟:
- 解析所有者 & 合法性檢查:解析方法所屬的類
C。如果發(fā)現(xiàn)C是一個接口,直接拋出IncompatibleClassChangeError(因為invokevirtual指令不能調(diào)用接口方法)。 - 方法查找:在類
C中查找:- 步驟1:在
C自身中查找。 - 步驟2:在
C的父類中遞歸查找。 - 步驟3:在
C實現(xiàn)的接口列表中查找(這一步不會找到具體方法,只會用于錯誤檢查)。如果在這里找到,說明C是一個抽象類但沒有實現(xiàn)接口的方法,拋出AbstractMethodError。
- 步驟1:在
- 找到則返回直接引用,否則拋出
NoSuchMethodError。 - 權(quán)限檢查:檢查訪問權(quán)限,失敗則拋出
IllegalAccessError。
// 方法解析示例
public class MethodResolutionExample {
public void callMethod() {
// 這里會觸發(fā)對toString()方法的解析
// 1. 解析當(dāng)前類 -> Object類
// 2. 在Object類中查找toString方法
// 3. 檢查訪問權(quán)限(public方法,可訪問)
// 4. 將符號引用替換為直接引用
String str = toString();
}
}
4. 接口方法解析 (從 CONSTANT_InterfaceMethodref_info 解析)
目標(biāo):解析一個接口的方法。
步驟:
- 解析所有者 & 合法性檢查:解析方法所屬的接口
C。如果發(fā)現(xiàn)C是一個類,直接拋出IncompatibleClassChangeError。 - 方法查找:
- 步驟1:在接口
C自身中查找。 - 步驟2:在接口
C的父接口中遞歸查找,直到Object類。
- 步驟1:在接口
- 找到則返回直接引用,否則拋出
NoSuchMethodError。 - 權(quán)限檢查:在 JDK 9 之前,接口方法都是
public,無需檢查。JDK 9 引入私有靜態(tài)方法后,也需要進行權(quán)限檢查。
緩存
為了提升性能,除 invokedynamic 指令外,解析結(jié)果會被緩存。一旦一個符號引用被成功解析,下次再遇到它時就會直接使用緩存的直接引用,避免重復(fù)解析。
特殊的 invokedynamic 指令
invokedynamic 是為動態(tài)語言(如 JavaScript)支持而設(shè)計的,它的解析邏輯是"一次解析,僅一次有效"。它的解析結(jié)果不會被緩存供其他 invokedynamic 指令使用,因為每次調(diào)用都可能是動態(tài)的、不同的。
總結(jié)
解析階段是連接符號世界和現(xiàn)實世界的橋梁。它通過一系列精心設(shè)計的步驟,將Class文件中的文本名字(符號引用)轉(zhuǎn)換為JVM內(nèi)存中的具體地址(直接引用),同時確保了Java語言的安全性(權(quán)限檢查)和一致性(繼承規(guī)則)。這個過程是Java實現(xiàn)動態(tài)擴展和多態(tài)特性的底層基石。
六、初始化 (Initialization) - "執(zhí)行構(gòu)造代碼"階段
核心思想
初始化階段是類加載的最后一步,也是真正開始執(zhí)行程序員編寫的 Java 代碼的一步。在這一步,JVM 會將靜態(tài)變量和靜態(tài)代碼塊中你寫的邏輯付諸實施。
你可以把它想象成一個設(shè)備的最終啟動和自檢程序。之前加載、驗證、準(zhǔn)備階段只是把設(shè)備(類)運進工廠、拆箱、檢查零件、裝上貨架(賦零值)。而現(xiàn)在,要按下電源開關(guān),執(zhí)行制造商(程序員)設(shè)定的啟動指令了。
主角:<clinit>() 方法
初始化階段就是執(zhí)行一個叫做 <clinit>() 方法的過程。這個方法不是程序員手寫的,而是由 javac 編譯器自動生成的。
<clinit>代表 class initialization。- 它是由編譯器自動收集類中的所有靜態(tài)變量的賦值語句和靜態(tài)代碼塊 (
static {}塊) 中的語句合并而成的。 - 收集的順序就是這些語句在源文件中出現(xiàn)的順序。
舉個例子:
public class Test {
static int i = 1; // 賦值語句1
static { // 靜態(tài)代碼塊
i = 2;
j = 3;
// System.out.println(j); // 這里如果訪問j,就是非法前向引用!
}
static int j = 4; // 另一個賦值語句2
// 編譯器生成的 <clinit>() 方法邏輯順序:
// i = 1;
// i = 2;
// j = 3;
// j = 4;
}
// 最終 i=2, j=4
關(guān)鍵特性與規(guī)則
1. 順序重要性與"非法前向引用"
- 編譯器收集語句的順序就是源碼中的順序。
- 靜態(tài)代碼塊中只能訪問定義在它之前的靜態(tài)變量。
- 對于定義在它之后的變量,靜態(tài)代碼塊可以為其賦值,但不能訪問其值(讀?。?。如果嘗試訪問,編譯器會報"非法前向引用"錯誤。
- 為什么? 因為雖然
j的內(nèi)存空間在準(zhǔn)備階段已經(jīng)分配好(初始值為0),但在<clinit>()方法中,j = 4的賦值操作還沒執(zhí)行。如果你在之前的靜態(tài)塊中讀取它,邏輯上是混亂的。
2. 父類優(yōu)先原則
- JVM 會保證在子類的
<clinit>()方法執(zhí)行前,其父類的<clinit>()方法已經(jīng)執(zhí)行完畢。 - 這意味著父類的靜態(tài)代碼塊和靜態(tài)變量賦值會先于子類的執(zhí)行。
- 因此,整個 JVM 中第一個被執(zhí)行
<clinit>()方法的類肯定是java.lang.Object。
例子:
class Parent {
public static int A = 1; // 1. 先執(zhí)行這個賦值
static {
A = 2; // 2. 再執(zhí)行這個,A 最終為 2
}
}
class Sub extends Parent {
public static int B = A; // 3. 最后執(zhí)行這個,B 的值是父類 A 的最終值 2
}
// 輸出 Sub.B 的結(jié)果是 2
3. 不是必需的
- 如果一個類中沒有靜態(tài)代碼塊,也沒有對靜態(tài)變量的顯式賦值操作(比如只有
static int i;),那么編譯器可以不為這個類生成<clinit>()方法。
4. 接口的初始化
- 接口也有
<clinit>()方法(因為接口可以有靜態(tài)變量,JDK8后還可以有靜態(tài)方法)。 - 關(guān)鍵區(qū)別:執(zhí)行一個接口的
<clinit>()方法并不需要先執(zhí)行其父接口的<clinit>()。父接口只有在真正被使用時(如其定義的變量被訪問)才會被初始化。 - 一個類在初始化時,不會自動先去執(zhí)行它實現(xiàn)的接口的
<clinit>()方法。
5. 線程安全與同步(極其重要?。?/h4>
- JVM 會保證一個類的
<clinit>() 方法在多線程環(huán)境中被正確地加鎖同步。
- 這意味著:多個線程如果同時去初始化同一個類,只有一個線程會去執(zhí)行
<clinit>() 方法,其他所有線程都會被阻塞等待。
- 直到那個活動線程執(zhí)行完
<clinit>() 方法后,其他線程才會被喚醒,并且不會再重新執(zhí)行初始化過程。
<clinit>() 方法在多線程環(huán)境中被正確地加鎖同步。<clinit>() 方法,其他所有線程都會被阻塞等待。<clinit>() 方法后,其他線程才會被喚醒,并且不會再重新執(zhí)行初始化過程。這個機制會導(dǎo)致一個嚴(yán)重的風(fēng)險:
如果你的 <clinit>() 方法中包含一個耗時極長的操作(比如一個死循環(huán)),或者由于某些原因卡住了,那么所有其他試圖初始化這個類的線程都會被無限期地阻塞在那里,從而導(dǎo)致系統(tǒng)癱瘓。
static class DeadLoopClass {
static {
if (true) { // 為了騙過編譯器的靜態(tài)檢查
System.out.println("線程" + Thread.currentThread() + "開始初始化...");
while (true) {} // 死循環(huán)!
}
}
}
// 如果兩個線程同時嘗試初始化 DeadLoopClass,一個會進去死循環(huán),另一個會永遠阻塞等待。
初始化觸發(fā)時機("主動引用")
只有當(dāng)類被"主動引用"時,才會觸發(fā)初始化:
- 遇到
new,getstatic,putstatic,invokestatic字節(jié)碼指令時。 - 使用
java.lang.reflect包的方法對類進行反射調(diào)用時。 - 初始化一個類時,如果其父類還未初始化,則先觸發(fā)父類的初始化。
- 虛擬機啟動時,需指定一個包含
main()方法的主類,虛擬機會先初始化這個主類。 - 使用 JDK 7 的動態(tài)語言支持時,如果一個
java.lang.invoke.MethodHandle實例最后的解析結(jié)果為 REF_getStatic, REF_putStatic, REF_invokeStatic 的方法句柄,并且這個句柄所對應(yīng)的類沒有進行過初始化。
總結(jié)與比喻
| 概念 | 比喻 |
|---|---|
| 準(zhǔn)備階段 | 給新房間配好家具并貼上"空"的標(biāo)簽(賦零值)。 |
| 初始化階段 | 按照主人的吩咐布置房間:把書放進書柜(i = 1),把畫掛上墻(靜態(tài)塊中的操作)。執(zhí)行 <clinit>() 方法。 |
<clinit>() 方法 |
房間的布置清單,由管家(編譯器)根據(jù)主人的吩咐(源碼)自動生成。 |
| 父類優(yōu)先 | 布置豪宅前,必須先把它所在的整個莊園都布置好。 |
| 非法前向引用 | 清單上要求"把花瓶放在第5號桌子上",但此時第5號桌子還沒運到房間裡(變量還未賦值),所以你無法描述它看起來怎么樣(無法訪問其值)。 |
| 線程安全 | 房間一次只允許一個管家進去布置,其他管家必須在門口排隊等候,等他布置完后,大家就都知道房間已經(jīng)準(zhǔn)備好了,無需再進去。 |
初始化階段是類加載過程中開發(fā)人員最能直接施加影響的階段,你寫的靜態(tài)賦值和靜態(tài)代碼塊就在這里執(zhí)行。理解它的順序規(guī)則和線程安全特性,對于編寫正確、高效的多線程程序至關(guān)重要。要特別小心在靜態(tài)初始化塊中編寫可能引起阻塞或死鎖的代碼。
七、總結(jié)
JVM 的類加載過程是一個嚴(yán)謹(jǐn)而精妙的系統(tǒng),它將靜態(tài)的字節(jié)碼文件轉(zhuǎn)變?yōu)檫\行時動態(tài)的 Java 對象。五個階段環(huán)環(huán)相扣:
- 加載是"找數(shù)據(jù)",通過靈活的類加載器獲取字節(jié)流。
- 驗證是"保安全",構(gòu)筑堅固的安全防線。
- 準(zhǔn)備是"建空間并清零",為類變量分配空間并賦零值。
- 解析是"查地址",將符號引用轉(zhuǎn)換為直接引用。
- 初始化是"賦真值",執(zhí)行靜態(tài)代碼和賦值,完成類的構(gòu)造。
理解這個過程,不僅能讓我們更深入地理解 Java 程序的運行原理,更能為我們在實踐中解決復(fù)雜問題、進行性能優(yōu)化和實現(xiàn)高級特性提供堅實的理論基礎(chǔ)。
?? 如果你喜歡這篇文章,請點贊支持! ?? 同時歡迎關(guān)注我的博客,獲取更多精彩內(nèi)容!
本文來自博客園,作者:佛祖讓我來巡山,轉(zhuǎn)載請注明原文鏈接:http://www.rzrgm.cn/sun-10387834/p/19099588

浙公網(wǎng)安備 33010602011771號