很長一段時間裡,我對 Java 的類載入機制都非常的抗拒,因為我覺得太難理解了。但為了成為一名優秀的 Java 工程師,我決定硬著頭皮研究一下。
01、位元組碼
在聊 Java 類載入機制之前,需要先了解一下 Java 位元組碼,因為它和類載入機制息息相關。
計算機只認識 0 和 1,所以任何語言編寫的程式都需要編譯成機器碼才能被計算機理解,然後執行,Java 也不例外。
Java 在誕生的時候喊出了一個非常牛逼的口號:“Write Once, Run Anywhere”,為了達成這個目的,Sun 公司釋出了許多可以在不同平臺(Windows、Linux)上執行的 Java 虛擬機器(JVM)——負責載入和執行 Java 編譯後的位元組碼。
到底 Java 位元組碼是什麼樣子,我們藉助一段簡單的程式碼來看一看。
原始碼如下:
package com.cmower.java_demo;
public class Test {
public static void main(String[] args) {
System.out.println("沉默王二");
}
}
程式碼編譯通過後,通過 xxd Test.class
命令檢視一下這個位元組碼檔案。
xxd Test.class
00000000: cafe babe 0000 0034 0022 0700 0201 0019 .......4."......
00000010: 636f 6d2f 636d 6f77 6572 2f6a 6176 615f com/cmower/java_
00000020: 6465 6d6f 2f54 6573 7407 0004 0100 106a demo/Test......j
00000030: 6176 612f 6c61 6e67 2f4f 626a 6563 7401 ava/lang/Object.
00000040: 0006 3c69 6e69 743e 0100 0328 2956 0100 ..<init>...()V..
00000050: 0443 6f64 650a 0003 0009 0c00 0500 0601 .Code...........
00000060: 000f 4c69 6e65 4e75 6d62 6572 5461 626c ..LineNumberTabl
感覺有點懵逼,對不對?
懵就對了。
這段位元組碼中的 cafe babe
被稱為“魔數”,是 JVM 識別 .class 檔案的標誌。檔案格式的定製者可以自由選擇魔數值(只要沒用過),比如說 .png 檔案的魔數是 8950 4e47
。
至於其他內容嘛,可以選擇忘記了。
02、類載入過程
瞭解了 Java 位元組碼後,我們來聊聊 Java 的類載入過程。
Java 的類載入過程可以分為 5 個階段:載入、驗證、準備、解析和初始化。這 5 個階段一般是順序發生的,但在動態繫結的情況下,解析階段發生在初始化階段之後。
1)Loading(載入)
JVM 在該階段的主要目的是將位元組碼從不同的資料來源(可能是 class 檔案、也可能是 jar 包,甚至網路)轉化為二進位制位元組流載入到記憶體中,並生成一個代表該類的 java.lang.Class
物件。
2)Verification(驗證)
JVM 會在該階段對二進位制位元組流進行校驗,只有符合 JVM 位元組碼規範的才能被 JVM 正確執行。該階段是保證 JVM 安全的重要屏障,下面是一些主要的檢查。
- 確保二進位制位元組流格式符合預期(比如說是否以
cafe bene
開頭)。 - 是否所有方法都遵守訪問控制關鍵字的限定。
- 方法呼叫的引數個數和型別是否正確。
- 確保變數在使用之前被正確初始化了。
- 檢查變數是否被賦予恰當型別的值。
3)Preparation(準備)
JVM 會在該階段對類變數(也稱為靜態變數,static
關鍵字修飾的)分配記憶體並初始化(對應資料型別的預設初始值,如 0、0L、null、false 等)。
也就是說,假如有這樣一段程式碼:
public String chenmo = "沉默";
public static String wanger = "王二";
public static final String cmower = "沉默王二";
chenmo 不會被分配記憶體,而 wanger 會;但 wanger 的初始值不是“王二”而是 null
。
需要注意的是,static final
修飾的變數被稱作為常量,和類變數不同。常量一旦賦值就不會改變了,所以 cmower 在準備階段的值為“沉默王二”而不是 null
。
4)Resolution(解析)
該階段將常量池中的符號引用轉化為直接引用。
what?符號引用,直接引用?
符號引用以一組符號(任何形式的字面量,只要在使用時能夠無歧義的定位到目標即可)來描述所引用的目標。
在編譯時,Java 類並不知道所引用的類的實際地址,因此只能使用符號引用來代替。比如 com.Wanger
類引用了 com.Chenmo
類,編譯時 Wanger 類並不知道 Chenmo 類的實際記憶體地址,因此只能使用符號 com.Chenmo
。
直接引用通過對符號引用進行解析,找到引用的實際記憶體地址。
5)Initialization(初始化)
該階段是類載入過程的最後一步。在準備階段,類變數已經被賦過預設初始值,而在初始化階段,類變數將被賦值為程式碼期望賦的值。換句話說,初始化階段是執行類構造器方法的過程。
oh,no,上面這段話說得很抽象,不好理解,對不對,我來舉個例子。
String cmower = new String("沉默王二");
上面這段程式碼使用了 new
關鍵字來例項化一個字串物件,那麼這時候,就會呼叫 String 類的構造方法對 cmower 進行例項化。
03、類載入器
聊完類載入過程,就不得不聊聊類載入器。
一般來說,Java 程式設計師並不需要直接同類載入器進行互動。JVM 預設的行為就已經足夠滿足大多數情況的需求了。不過,如果遇到了需要和類載入器進行互動的情況,而對類載入器的機制又不是很瞭解的話,就不得不花大量的時間去除錯
ClassNotFoundException
和 NoClassDefFoundError
等異常。
對於任意一個類,都需要由它的類載入器和這個類本身一同確定其在 JVM 中的唯一性。也就是說,如果兩個類的載入器不同,即使兩個類來源於同一個位元組碼檔案,那這兩個類就必定不相等(比如兩個類的 Class 物件不 equals
)。
站在程式設計師的角度來看,Java 類載入器可以分為三種。
1)啟動類載入器(Bootstrap Class-Loader),載入 jre/lib
包下面的 jar 檔案,比如說常見的 rt.jar。
2)擴充套件類載入器(Extension or Ext Class-Loader),載入 jre/lib/ext
包下面的 jar 檔案。
3)應用類載入器(Application or App Clas-Loader),根據程式的類路徑(classpath)來載入 Java 類。
來來來,通過一段簡單的程式碼瞭解下。
public class Test {
public static void main(String[] args) {
ClassLoader loader = Test.class.getClassLoader();
while (loader != null) {
System.out.println(loader.toString());
loader = loader.getParent();
}
}
}
每個 Java 類都維護著一個指向定義它的類載入器的引用,通過 類名.class.getClassLoader()
可以獲取到此引用;然後通過 loader.getParent()
可以獲取類載入器的上層類載入器。
這段程式碼的輸出結果如下:
sun.misc.Launcher$AppClassLoader@73d16e93
sun.misc.Launcher$ExtClassLoader@15db9742
第一行輸出為 Test 的類載入器,即應用類載入器,它是 sun.misc.Launcher$AppClassLoader
類的例項;第二行輸出為擴充套件類載入器,是 sun.misc.Launcher$ExtClassLoader
類的例項。那啟動類載入器呢?
按理說,擴充套件類載入器的上層類載入器是啟動類載入器,但在我這個版本的 JDK 中, 擴充套件類載入器的 getParent()
返回 null
。所以沒有輸出。
04、雙親委派模型
如果以上三種類載入器不能滿足要求的話,程式設計師還可以自定義類載入器(繼承 java.lang.ClassLoader
類),它們之間的層級關係如下圖所示。
這種層次關係被稱作為雙親委派模型:如果一個類載入器收到了載入類的請求,它會先把請求委託給上層載入器去完成,上層載入器又會委託上上層載入器,一直到最頂層的類載入器;如果上層載入器無法完成類的載入工作時,當前類載入器才會嘗試自己去載入這個類。
PS:雙親委派模型突然讓我聯想到朱元璋同志,這個同志當上了皇帝之後連宰相都不要了,所有的事情都親力親為,只有自己沒精力沒時間做的事才交給大臣們去幹。
使用雙親委派模型有一個很明顯的好處,那就是 Java 類隨著它的類載入器一起具備了一種帶有優先順序的層次關係,這對於保證 Java 程式的穩定運作很重要。
上文中曾提到,如果兩個類的載入器不同,即使兩個類來源於同一個位元組碼檔案,那這兩個類就必定不相等——雙親委派模型能夠保證同一個類最終會被特定的類載入器載入。
05、最後
硬著頭皮翻看了大量的資料,並且動手去研究以後,我發現自己竟然對 Java 類載入機制(JVM 將類的資訊動態新增到記憶體並使用的一種機制)不那麼抗拒了——真是蠻奇妙的一件事啊。
也許學習就應該是這樣,只要你敢於挑戰自己,就能收穫知識——就像山就在那裡,只要你肯攀登,就能到達山頂。