很長一段時間里,我對 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 將類的信息動態添加到內存並使用的一種機制)不那么抗拒了——真是蠻奇妙的一件事啊。
也許學習就應該是這樣,只要你敢於挑戰自己,就能收獲知識——就像山就在那里,只要你肯攀登,就能到達山頂。