我竟然不再抗拒 Java 的類載入機制了

沉默王二發表於2019-07-10

很長一段時間裡,我對 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 預設的行為就已經足夠滿足大多數情況的需求了。不過,如果遇到了需要和類載入器進行互動的情況,而對類載入器的機制又不是很瞭解的話,就不得不花大量的時間去除錯
ClassNotFoundExceptionNoClassDefFoundError 等異常。

對於任意一個類,都需要由它的類載入器和這個類本身一同確定其在 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 將類的資訊動態新增到記憶體並使用的一種機制)不那麼抗拒了——真是蠻奇妙的一件事啊。

也許學習就應該是這樣,只要你敢於挑戰自己,就能收穫知識——就像山就在那裡,只要你肯攀登,就能到達山頂。

 

相關文章