深入理解Java虛擬機器(類載入機制)

張磊BARON發表於2019-06-16

文章首發於微信公眾號:BaronTalk

上一篇文章我們介紹了「類檔案結構」,這一篇我們來看看虛擬機器是如何載入類的。

我們的原始碼經過編譯器編譯成位元組碼之後,最終都需要載入到虛擬機器之後才能執行。虛擬機器把描述類的資料從 Class 檔案載入到記憶體,並對資料進行校驗、轉換解析和初始化,最終形成可以被虛擬機器直接使用的 Java 型別,這就是虛擬機器的類載入機制

與編譯時需要進行連線工作的語言不同,Java 語言中類的載入、連線和初始化都是在程式執行期間完成的,這種策略雖然會讓類載入時增加一些效能開銷,但是會為 Java 應用程式提供高度的靈活性,Java 裡天生可動態擴充套件的語言特性就是依賴執行期間動態載入和動態連線的特點實現的。

例如,一個面向介面的應用程式,可以等到執行時再指定實際的實現類;使用者可以通過 Java 預定義的和自定義的類載入器,讓一個本地的應用程式執行從網路上或其它地方載入一個二進位制流作為程式程式碼的一部分。

一. 類載入時機

類從被虛擬機器從載入到解除安裝,整個生命週期包含:載入(Loading)、驗證(Verification)、準備(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)、解除安裝(Unloading)7 個階段。其中驗證、準備、解析 3 個部分統稱為連線(Linking)。這 7 個階段的發生順序如下圖:

深入理解Java虛擬機器(類載入機制)

上圖中載入、驗證、準備、初始化和解除安裝 5 個階段的順序是確定的,類的載入過程必須按照這種順序按部就班的開始「注意,這裡說的是按部就班的開始,並不要求前一階段執行完才能進入下一階段」,而解析階段則不一定:它在某些情況下可以在初始化階段之後再開始,這是為了支援 Java 的動態繫結。

虛擬機器規範中對於什麼時候開始類載入過程的第一節點「載入」並沒有強制約束。但是對於「初始化」階段,虛擬機器則是嚴格規定了有且只有以下 5 種情況,如果類沒有進行初始化,則必須立即對類進行「初始化」(載入、驗證、準備自然需要在此之前開始):

  1. 遇到 new、getstatic、putstatic 或 invokestatic 這 4 條位元組碼指令;
  2. 使用 java.lang.reflect 包的方法對類進行反射呼叫的時候;
  3. 當初始化一個類的時候,發現其父類還沒有進行初始化的時候,需要先觸發其父類的初始化;
  4. 當虛擬機器啟動時,使用者需要指定一個要執行的主類,虛擬機器會先初始化這個類;
  5. 當使用 JDK 1.7 的動態語言支援時,如果一個 java.lang.invoke.MethodHandle 例項最後的解析結果 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法控制程式碼,並且這個方法控制程式碼所對應的類沒有初始化。

「有且只有」以上 5 種場景會觸發類的初始化,這 5 種場景中的行為稱為對一個類的主動引用。除此之外,所有引用類的方式都不會觸發初始化,稱為被動引用。比如如下幾種場景就是被動引用:

  1. 通過子類引用父類的靜態欄位,不會導致子類的初始化;
  2. 通過陣列定義來引用類,不會觸發此類的初始化;
  3. 常量在編譯階段會存入呼叫類的常量池中,本質上並沒有直接引用到定義常量的類,因此不會觸發定義常量的類的初始化;

二. 類載入過程

載入

這裡的「載入」是指「類載入」過程的一個階段。在載入階段,虛擬機器需要完成以下 3 件事:

  1. 通過一個類的全限定名來獲取定義此類的二進位制位元組流;
  2. 將這個位元組流所代表的靜態儲存結構轉化為方法區的執行時資料結構;
  3. 在記憶體中生成一個代表這個類的 java.lang.Class 物件,作為方法區這個類的各種資料的訪問入口。

驗證

驗證是連線階段的第一步,這一階段的目的是為了確保 Class 檔案的位元組流中包含的資訊符合當前虛擬機器的要求,並且不會危害虛擬機器自身的安全。驗證階段大致上會完成下面 4 個階段的檢驗動作:

  1. 檔案格式驗證:第一階段要驗證位元組流是否符合 Class 檔案格式的規範,並且能夠被當前版本的虛擬機器處理。驗證點主要包括:是否以魔數 0xCAFEBABE 開頭;主、次版本號是否在當前虛擬機器處理範圍之內;常量池的常量中是否有不被支援的常量型別;Class 檔案中各個部分及檔案本身是否有被刪除的或者附加的其它資訊等等。

  2. 後設資料驗證:第二階段是對位元組碼描述的資訊進行語義分析,以保證其描述的資訊符合 Java 語言規範的要求,這個階段的驗證點包括:這個類是否有父類;這個類的父類是否繼承了不允許被繼承的類;如果這個類不是抽象類,是否實現了其父類或者介面之中要求實現的所有方法;類中的欄位、方法是否與父類產生矛盾等等。

  3. 位元組碼驗證:第三階段是整個驗證過程中最複雜的一個階段,主要目的是通過資料流和控制流分析,確定程式語義是合法的、符合邏輯的。

  4. 符號引用驗證:最後一個階段的校驗發生在虛擬機器將符號引用轉化為直接引用的時候,這個轉化動作將在連線的第三階段--解析階段中發生。符號引用驗證可以看做是對類自身以外(常量池中的各種符號引用)的形象進行匹配性校驗。

準備

準備階段是正式為類變數分配記憶體並設定類變數初始值的階段,這些變數所使用的記憶體都將在方法區進行分配。這個階段中有兩個容易產生混淆的概念需要強調下:

  • 首先,這時候進行記憶體分配的僅包括類變數(被 static 修飾的變數),而不包括例項變數,例項變數將會在物件例項化時隨著物件一起分配在 Java 堆中;

  • 其次這裡所說的初始值「通常情況」下是資料型別的零值。假設一個類變數的定義為public static int value = 123; 那麼變數 value 在準備階段過後的初始值為 0 而不是 123,因為這個時候尚未執行任何 Java 方法,而把 value 賦值為 123 的 putstatic 指令是程式被編譯之後,存放於類構造器 () 方法之中,所以把 value 賦值為 123 的動作將在初始化階段才會執行。

這裡提到,在「通常情況」下初始值是零值,那相對的會有一些「特殊情況」:如果類欄位的欄位屬性表中存在 ConstantsValue 屬性,那在準備階段變數 value 就會被初始化為 ConstantValue 屬性所指的值。假設上面的類變數 value 的定義變為 public static final int value = 123;,編譯時 JavaC 將會為 value 生成 ConstantValue 屬性,在準備階段虛擬機器就會根據 ConstantValue 的設定將 value 賦值為 123。

解析

解析階段是虛擬機器將常量池內的符號引用替換為直接引用的過程。前面提到過很多次符號引用和直接引用,那麼到底什麼是符號引用和直接引用呢?

  • 符號引用(Symbolic Reference):符號引用以一組符號來描述所引用的目標,符號可以上任何形式的字面量,只要使用時能無歧義地定位到目標即可。

  • 直接引用(Direct Reference):直接引用可以是直接指向目標的指標、相對偏移量或是一個能間接定位到目標的控制程式碼。

初始化

類初始化階段是類載入過程中的最後一步,前面的類載入過程中,除了在載入階段使用者應用程式可以通過自定義類載入器參與之外,其餘動作完全是由虛擬機器主導和控制的。到了初始化階段,才真正開始執行類中定義的 Java 程式程式碼。初始階段是執行類構造器 () 方法的過程。

三. 類載入器

虛擬機器設計團隊把類載入階段中的「通過一個類的全限定名來獲取描述此類的二進位制位元組流」這個動作放到 Java 虛擬機器外部去實現,以便讓應用程式自己決定如何去獲取所需要的類。實現這個動作的程式碼模組稱為「類載入器」。

類與類載入器

對於任意一個類,都需要由載入它的類載入器和這個類本身一同確立其在 Java 虛擬機器的唯一性,每個類載入器都擁有一個獨立的類名稱空間。也就是說:比較兩個類是否「相等」,只要在這兩個類是由同一個類載入器載入的前提下才有意義,否則,即使這兩個類來源於同一個 Class 檔案,被同一個虛擬機器載入,只要載入它們的類載入器不同,那這兩個類就必定不相等。

雙親委派模型

從 Java 虛擬機器的角度來講,只存在兩種不同的類載入器:一種是啟動類載入器(Bootstrap ClassLoader),這個類載入器使用 C++ 來實現,是虛擬機器自身的一部分;另一種就是所有其他的類載入器,這些類載入器都由 Java 來實現,獨立於虛擬機器外部,並且全都繼承自抽象類 java.lang.ClassLoader

從 Java 開發者的角度來看,類載入器可以劃分為:

  • 啟動類載入器(Bootstrap ClassLoader):這個類載入器負責將存放在 <JAVA_HOME>\lib 目錄中的類庫載入到虛擬機器記憶體中。啟動類載入器無法被 Java 程式直接引用,使用者在編寫自定義類載入器時,如果需要把載入請求委派給啟動類載入器,納智捷使用 null 代替即可;

  • 擴充套件類載入器(Extension ClassLoader):這個類載入器由 sun.misc.Launcher$ExtClassLoader 實現,它負責載入 <JAVA_HOME>\lib\ext 目錄中,或者被 java.ext.dirs 系統變數所指定的路徑中的所有類庫,開發者可以直接使用擴充套件類載入器;

  • 應用程式類載入器(Application ClassLoader):這個類載入器由 sun.misc.Launcher$App-ClassLoader 實現。getSystemClassLoader() 方法返回的就是這個類載入器,因此也被稱為系統類載入器。它負責載入使用者類路徑(ClassPath)上所指定的類庫。開發者可以直接使用這個類載入器,如果應用程式中沒有自定義過自己的類載入器,一般情況下這個就是程式中預設的類載入器。

我們的應用程式都是由這 3 種類載入器互相配合進行載入的,在必要時還可以自己定義類載入器。它們的關係如下圖所示:

深入理解Java虛擬機器(類載入機制)

上圖中所呈現出的這種層次關係,稱為類載入器的雙親委派模型(Parents Delegation Model)。雙親委派模型要求除了頂層的啟動類載入器以外,其餘的類載入器都應當有自己的父類載入器。

雙親委派模型的工作過程是這樣的:如果一個類載入器收到了類載入的請求,它首先不會自己去嘗試載入這個類,而是把這個請求委派給父類載入器去完成,每一個層次的類載入器都是如此,因此所有的載入請求最終都應該傳送到頂層的啟動類載入器中,只有當父類載入器反饋自己無法完成這個類載入請求(它的搜尋範圍中沒有找到所需的類)時,子載入器才會嘗試自己去載入。

這樣做的好處就是 Java 類隨著它的類載入器一起具備了一種帶有優先順序的層次關係。例如 java.lang.Object,它放在 rt.jar 中,無論哪一個類載入器要載入這個類,最終都是委派給處於模型頂端的啟動類載入器來載入,因此 Object 類在程式的各種類載入器環境中都是同一個類。相反,如果沒有使用雙親委派模型,由各個類載入器自行去載入的話,如果使用者自己編寫了一個稱為 java.lang.Object 的類,並放在程式的 ClassPath 中,那系統中將會出現多個不同的 Object 類,Java 型別體系中最基本的行為也就無法保證了。

雙親委派模型對於保證 Java 程式執行的穩定性很重要,但它的實現很簡單,實現雙親委派模型的程式碼都集中在 java.lang.ClassLoader 的 loadClass() 方法中,邏輯很清晰:先檢查是否已經被載入過,若沒有則呼叫父類載入器的 loadClass() 方法,若父載入器為空則預設使用啟動類載入器作為父載入器。如果父類載入失敗,丟擲 ClassNotFoundException 異常後,再呼叫自己的 findClass() 方法進行載入。

protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException {
    // 首先,檢查請求的類是不是已經被載入過
    Class<?> c = findLoadedClass(name);
    if (c == null) {
        try {
            if (parent != null) {
                c = parent.loadClass(name, false);
            } else {
                c = findBootstrapClassOrNull(name);
            }
        } catch (ClassNotFoundException e) {
            // 如果父類丟擲 ClassNotFoundException 說明父類載入器無法完成載入
        }

        if (c == null) {
            // 如果父類載入器無法載入,則呼叫自己的 findClass 方法來進行類載入
            c = findClass(name);
        }
    }
    if (resolve) {
        resolveClass(c);
    }
    return c;
}
複製程式碼

關於類檔案結構和類載入就通過連續的兩篇文章介紹到這裡了,下一篇我們來聊聊「虛擬機器的位元組碼執行引擎」。

參考資料:

  • 《深入理解 Java 虛擬機器:JVM 高階特性與最佳實踐(第 2 版)》

如果你喜歡我的文章,就關注下我的公眾號 BaronTalk知乎專欄 或者在 GitHub 上添個 Star 吧!

深入理解Java虛擬機器(類載入機制)

相關文章