1. 程式人生 > 其它 >03 - Java虛擬機器是如何載入Java類的?

03 - Java虛擬機器是如何載入Java類的?

聽我的義大利同事說,他們那邊有個習俗,就是父親要幫兒子蓋棟房子。

這事要放在以前還挺簡單,親朋好友搭把手,蓋個小磚房就可以住人了。現在呢,整個過程要耗費好久的時間。首先你要請建築師出個方案,然後去市政部門報備、驗證,通過後才可以開始蓋房子。蓋好房子還要裝修,之後才能住人。

蓋房子這個事,和 Java 虛擬機器中的類載入還是挺像的。從 class 檔案到記憶體中的類,按先後順序需要經過載入、連結以及初始化三大步驟。其中,連結過程中同樣需要驗證;而記憶體中的類沒有經過初始化,同樣不能使用。那麼,是否所有的 Java 類都需要經過這幾步呢?

我們知道 Java 語言的型別可以分為兩大類:基本型別(primitive types)和引用型別(reference types)。在上一篇中,我已經詳細介紹過了 Java 的基本型別,它們是由 Java 虛擬機器預先定義好的。

至於另一大類引用型別,Java 將其細分為四種:類、介面、陣列類和泛型引數。由於泛型引數會在編譯過程中被擦除(我會在專欄的第二部分詳細介紹),因此 Java 虛擬機器實際上只有前三種。在類、介面和陣列類中,陣列類是由 Java 虛擬機器直接生成的,其他兩種則有對應的位元組流。

說到位元組流,最常見的形式要屬由 Java 編譯器生成的 class 檔案。除此之外,我們也可以在程式內部直接生成,或者從網路中獲取(例如網頁中內嵌的小程式 Java applet)位元組流。這些不同形式的位元組流,都會被載入到 Java 虛擬機器中,成為類或介面。為了敘述方便,下面我就用“類”來統稱它們。

無論是直接生成的陣列類,還是載入的類,Java 虛擬機器都需要對其進行連結和初始化。接下來,我會詳細給你介紹一下每個步驟具體都在幹些什麼。

載入

載入,是指查詢位元組流,並且據此建立類的過程。前面提到,對於陣列類來說,它並沒有對應的位元組流,而是由 Java 虛擬機器直接生成的。對於其他的類來說,Java 虛擬機器則需要藉助類載入器來完成查詢位元組流的過程。

以蓋房子為例,村裡的 Tony 要蓋個房子,那麼按照流程他得先找個建築師,跟他說想要設計一個房型,比如說“一房、一廳、四衛”。你或許已經聽出來了,這裡的房型相當於類,而建築師,就相當於類載入器。

村裡有許多建築師,他們等級森嚴,但有著共同的祖師爺,叫啟動類載入器(bootstrap class loader)。啟動類載入器是由 C++ 實現的,沒有對應的 Java 物件,因此在 Java 中只能用 null 來指代。換句話說,祖師爺不喜歡像 Tony 這樣的小角色來打擾他,所以誰也沒有祖師爺的聯絡方式。

除了啟動類載入器之外,其他的類載入器都是 java.lang.ClassLoader 的子類,因此有對應的 Java 物件。這些類載入器需要先由另一個類載入器,比如說啟動類載入器,載入至 Java 虛擬機器中,方能執行類載入。

村裡的建築師有一個潛規則,就是接到單子自己不能著手幹,得先給師傅過過目。師傅不接手的情況下,才能自己來。在 Java 虛擬機器中,這個潛規則有個特別的名字,叫雙親委派模型。每當一個類載入器接收到載入請求時,它會先將請求轉發給父類載入器。在父類載入器沒有找到所請求的類的情況下,該類載入器才會嘗試去載入。

在 Java 9 之前,啟動類載入器負責載入最為基礎、最為重要的類,比如存放在 JRE 的 lib 目錄下 jar 包中的類(以及由虛擬機器引數 -Xbootclasspath 指定的類)。除了啟動類載入器之外,另外兩個重要的類載入器是擴充套件類載入器(extension class loader)和應用類載入器(application class loader),均由 Java 核心類庫提供。

擴充套件類載入器的父類載入器是啟動類載入器。它負責載入相對次要、但又通用的類,比如存放在 JRE 的 lib/ext 目錄下 jar 包中的類(以及由系統變數 java.ext.dirs 指定的類)。

應用類載入器的父類載入器則是擴充套件類載入器。它負責載入應用程式路徑下的類。(這裡的應用程式路徑,便是指虛擬機器引數 -cp/-classpath、系統變數 java.class.path 或環境變數 CLASSPATH 所指定的路徑。)預設情況下,應用程式中包含的類便是由應用類載入器載入的。

Java 9 引入了模組系統,並且略微更改了上述的類載入器[1]。擴充套件類載入器被改名為平臺類載入器(platform class loader)。Java SE 中除了少數幾個關鍵模組,比如說 java.base 是由啟動類載入器載入之外,其他的模組均由平臺類載入器所載入。

除了由 Java 核心類庫提供的類載入器外,我們還可以加入自定義的類載入器,來實現特殊的載入方式。舉例來說,我們可以對 class 檔案進行加密,載入時再利用自定義的類載入器對其解密。

除了載入功能之外,類載入器還提供了名稱空間的作用。這個很好理解,打個比方,咱們這個村不講究版權,如果你剽竊了另一個建築師的設計作品,那麼只要你標上自己的名字,這兩個房型就是不同的。

在 Java 虛擬機器中,類的唯一性是由類載入器例項以及類的全名一同確定的。即便是同一串位元組流,經由不同的類載入器載入,也會得到兩個不同的類。在大型應用中,我們往往藉助這一特性,來運行同一個類的不同版本。

連結

連結,是指將建立成的類合併至 Java 虛擬機器中,使之能夠執行的過程。它可分為驗證、準備以及解析三個階段。

驗證階段的目的,在於確保被載入類能夠滿足 Java 虛擬機器的約束條件。這就好比 Tony 需要將設計好的房型提交給市政部門稽核。只有當稽核通過,才能繼續下面的建造工作。

通常而言,Java 編譯器生成的類檔案必然滿足 Java 虛擬機器的約束條件。因此,這部分我留到講解位元組碼注入時再詳細介紹。

準備階段的目的,則是為被載入類的靜態欄位分配記憶體。Java 程式碼中對靜態欄位的具體初始化,則會在稍後的初始化階段中進行。過了這個階段,咱們算是蓋好了毛坯房。雖然結構已經完整,但是在沒有裝修之前是不能住人的。

除了分配記憶體外,部分 Java 虛擬機器還會在此階段構造其他跟類層次相關的資料結構,比如說用來實現虛方法的動態繫結的方法表。

在 class 檔案被載入至 Java 虛擬機器之前,這個類無法知道其他類及其方法、欄位所對應的具體地址,甚至不知道自己方法、欄位的地址。因此,每當需要引用這些成員時,Java 編譯器會生成一個符號引用。在執行階段,這個符號引用一般都能夠無歧義地定位到具體目標上。

舉例來說,對於一個方法呼叫,編譯器會生成一個包含目標方法所在類的名字、目標方法的名字、接收引數型別以及返回值型別的符號引用,來指代所要呼叫的方法。

解析階段的目的,正是將這些符號引用解析成為實際引用。如果符號引用指向一個未被載入的類,或者未被載入類的欄位或方法,那麼解析將觸發這個類的載入(但未必觸發這個類的連結以及初始化)。

如果將這段話放在蓋房子的語境下,那麼符號引用就好比“Tony 的房子”這種說法,不管它存在不存在,我們都可以用這種說法來指代 Tony 的房子。實際引用則好比實際的通訊地址,如果我們想要與 Tony 通訊,則需要啟動蓋房子的過程。

Java 虛擬機器規範並沒有要求在連結過程中完成解析。它僅規定了:如果某些位元組碼使用了符號引用,那麼在執行這些位元組碼之前,需要完成對這些符號引用的解析。

初始化

在 Java 程式碼中,如果要初始化一個靜態欄位,我們可以在宣告時直接賦值,也可以在靜態程式碼塊中對其賦值。

如果直接賦值的靜態欄位被 final 所修飾,並且它的型別是基本型別或字串時,那麼該欄位便會被 Java 編譯器標記成常量值(ConstantValue),其初始化直接由 Java 虛擬機器完成。除此之外的直接賦值操作,以及所有靜態程式碼塊中的程式碼,則會被 Java 編譯器置於同一方法中,並把它命名為 < clinit >。

類載入的最後一步是初始化,便是為標記為常量值的欄位賦值,以及執行 < clinit > 方法的過程。Java 虛擬機器會通過加鎖來確保類的 < clinit > 方法僅被執行一次。

只有當初始化完成之後,類才正式成為可執行的狀態。這放在我們蓋房子的例子中就是,只有當房子裝修過後,Tony 才能真正地住進去。

那麼,類的初始化何時會被觸發呢?JVM 規範枚舉了下述多種觸發情況:

  1. 當虛擬機器啟動時,初始化使用者指定的主類;
  2. 當遇到用以新建目標類例項的 new 指令時,初始化 new 指令的目標類;
  3. 當遇到呼叫靜態方法的指令時,初始化該靜態方法所在的類;
  4. 當遇到訪問靜態欄位的指令時,初始化該靜態欄位所在的類;
  5. 子類的初始化會觸發父類的初始化;
  6. 如果一個介面定義了 default 方法,那麼直接實現或者間接實現該介面的類的初始化,會觸發該介面的初始化;
  7. 使用反射 API 對某個類進行反射呼叫時,初始化這個類;
  8. 當初次呼叫 MethodHandle 例項時,初始化該 MethodHandle 指向的方法所在的類。
public class Singleton {
  private Singleton() {}
  private static class LazyHolder {
    static final Singleton INSTANCE = new Singleton();
  }
  public static Singleton getInstance() {
    return LazyHolder.INSTANCE;
  }
}

我在文章中貼了一段程式碼,這段程式碼是在著名的單例延遲初始化例子中[2],只有當呼叫 Singleton.getInstance 時,程式才會訪問 LazyHolder.INSTANCE,才會觸發對 LazyHolder 的初始化(對應第 4 種情況),繼而新建一個 Singleton 的例項。

由於類初始化是執行緒安全的,並且僅被執行一次,因此程式可以確保多執行緒環境下有且僅有一個 Singleton 例項。

總結與實踐

今天我介紹了 Java 虛擬機器將位元組流轉化為 Java 類的過程。這個過程可分為載入、連結以及初始化三大步驟。

載入,是指查詢位元組流,並且據此建立類的過程。載入需要藉助類載入器,在 Java 虛擬機器中,類載入器使用了雙親委派模型,即接收到載入請求時,會先將請求轉發給父類載入器。

連結,是指將建立成的類合併至 Java 虛擬機器中,使之能夠執行的過程。連結還分驗證、準備和解析三個階段。其中,解析階段為非必須的。

初始化,則是為標記為常量值的欄位賦值,以及執行 < clinit > 方法的過程。類的初始化僅會被執行一次,這個特性被用來實現單例的延遲初始化。

今天的實踐環節,你可以來驗證一下本篇中的理論知識。

通過 JVM 引數 -verbose:class 來列印類載入的先後順序,並且在 LazyHolder 的初始化方法中列印特定字樣。在命令列中執行下述指令(不包含提示符 $):

$ echo '
public class Singleton {
  private Singleton() {}
  private static class LazyHolder {
    static final Singleton INSTANCE = new Singleton();
    static {
      System.out.println("LazyHolder.<clinit>");
    }
  }
  public static Object getInstance(boolean flag) {
    if (flag) return new LazyHolder[2];
    return LazyHolder.INSTANCE;
  }
  public static void main(String[] args) {
    getInstance(true);
    System.out.println("----");
    getInstance(false);
  }
}' > Singleton.java
$ javac Singleton.java
$ java -verbose:class Singleton

問題 1:新建陣列(第 11 行)會導致 LazyHolder 的載入嗎?會導致它的初始化嗎?

在命令列中執行下述指令(不包含提示符 $):

$ java -cp /path/to/asmtools.jar org.openjdk.asmtools.jdis.Main Singleton\$LazyHolder.class > Singleton\$LazyHolder.jasm.1
$ awk 'NR==1,/stack 1/{sub(/stack 1/, "stack 0")} 1' Singleton\$LazyHolder.jasm.1 > Singleton\$LazyHolder.jasm
$ java -cp /path/to/asmtools.jar org.openjdk.asmtools.jasm.Main Singleton\$LazyHolder.jasm
$ java -verbose:class Singleton

問題 2:新建陣列會導致 LazyHolder 的連結嗎?


  1. https://docs.oracle.com/javase/9/migrate/toc.htm#JSMIG-GUID-A868D0B9-026F-4D46-B979-901834343F9E ↩︎

  2. https://en.wikipedia.org/wiki/Initialization-on-demand_holder_idiom ↩︎

作者:PP傑

出處:http://www.cnblogs.com/newber/

博學之,審問之,慎思之,明辨之,篤行之。