從 1 開始學 JVM 系列 | JVM 類加載器(一)


從 1 開始學 JVM 系列

類加載器,對於很多人來說並不陌生。我自己第一次聽到這個概念時覺得有點“高大上”,覺得只有深入 JDK 源碼才會觸碰到 ClassLoader,平時都是傳聞中的東西。

今天,就讓我們一起來探索一下這”傳聞“中的類加載器,看看它是何方神聖。

類生命周期

在正式聊類加載器之前,我們先正本清源,看看類的生命周期是什么樣的。

為了方便后續解讀,下面我貼了一張圖展示了類的生命周期的 7 個步驟。

image

對於前 5 步,簡單來說就是加載、鏈接、初始化,這是一個類最關鍵的加載步驟。

對照着上圖,我們逐一來解釋一下。

  1. 加載(Loading):找 Class 文件
  2. 驗證(Verification):驗證格式、依賴
  3. 准備(Preparation):靜態字段、方法表
  4. 解析(Resolution):符號解析為引用
  5. 初始化(Initialization):構造器、靜態變量賦值、靜態代碼塊
  6. 使用(Using)
  7. 卸載(Unloading)

1.加載

所謂的加載,就是查找字節流,並根據字節流創建類的過程

  • 對於數組類,它沒有對應的字節流,是由 Java 虛擬機直接生成的。
  • 對於其他的類,Java 虛擬機需要借助類加載器來完成查找字節流的過程。

以蓋房子為例,Jack 想要要蓋個房子,按照流程他要先找個建築師,跟他說想要設計一個房型,比如說“一房一廳兩衛”。這里的房型就相當於類,而建築師就相當於類加載器。

啟動類加載器

建築界有許多的建築師,他們等級分明,但都有着共同的祖師爺,叫「啟動類加載器(boot class loader)」。由於啟動類加載器是由 C++ 實現的,沒有對應的 Java 對象,因此在 Java 中只能用 null 來指代。

// jdk中 BootstrapClass 是 native 實現
private native Class<?> findBootstrapClass(String name);

但是,祖師爺不喜歡像 Jack 這樣的小角色來打擾他,所以誰也沒有祖師爺的聯系方式,也就相當於 null 指代。

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

雙親委派模型

建築師界有個潛規則:接到單子后自己不能着手干,得先給師傅過過目。師傅不接手的情況下,才能自己來。即等級高的師傅有優先選擇權。

在 Java 虛擬機中,這個潛規則就是「雙親委派模型」。每當一個類加載器接收到加載請求時,它會先將請求轉發給父類加載器。在父類加載器沒有找到請求的類時,這個類加載器才會嘗試去加載。

加載器類型

加載器類型(Java 9 之前) 作用 加載路徑
啟動類加載器 負責加載最為基礎、最為重要的類 比如存放在 JRE 的 lib 目錄下 jar 包中的類(以及由虛擬機參數 -Xbootclasspath 指定的類)
擴展類加載器 (extension class loader) 父類加載器是啟動類加載器。它負責加載相對次要、但又通用的類 比如存放在 JRE 的 lib/ext 目錄下 jar 包中的類(以及由系統變量 java.ext.dirs 指定的類)
應用類加載器 (application class loader) 父類加載器則是擴展類加載器。它負責加載應用程序路徑下的類。 默認情況下,應用程序中包含的類便是由應用類加載器加載的 這里的應用程序路徑,便是指虛擬機參數 -cp/-classpath、系統變量 java.class.path 或環境變量 CLASSPATH 所指定的路徑。

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

除了由 Java 核心類庫提供的類加載器外,我們還可以加入「自定義類加載器」,實現特殊的加載方式。

舉個例子,我們可以對 class 文件進行加密,加載時再利用自定義類加載器對其解密。

類加載器的命名空間

除了加載功能之外,類加載器還提供了「命名空間」的作用。

打個比方,假設建築界不講版權,如果某個人剽竊了另一個建築師的設計作品,只要你標上自己的名字,這兩個房型就是不同的。

在 Java 虛擬機中,類的唯一性是由類加載器實例以及類的全名一同確定的。即便是同一串字節流,經由不同的類加載器加載,也會得到兩個不同的類。

image

在大型應用中,我們往往借助這一特性,來運行同一個類的不同版本。

2.鏈接

鏈接,是指將創建好的類合並至 Java 虛擬機中,使之能夠執行的過程。它可分為驗證、准備以及解析三個階段。

    1. 「驗證」階段的目的,在於確保被加載類能夠滿足 Java 虛擬機的約束條件

    這就好比 Jack 需要將設計好的房型提交給市政部門審核。只有當審核通過,才能繼續下面的建造工作。

    通常而言,Java 編譯器生成的類文件必然滿足 Java 虛擬機的約束條件。

  • 2.「准備」階段的目的,則是為被加載類的靜態字段分配內存。Java 代碼中對靜態字段的具體初始化,則會在稍后的初始化階段中進行。

    過了這個階段,算是蓋好了毛坯房。雖然結構已經完整,但沒有裝修之前不能住人。

    除了分配內存外,部分 Java 虛擬機會在此階段構造其他跟類層次相關的數據結構,比如說用來實現虛方法的動態綁定的方法表。

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

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

  • 3.「解析」階段的目的,正是將這些符號引用解析成為實際引用

    如果符號引用指向一個未被加載的類,或者未被加載類的字段或方法,那么解析將觸發這個類的加載(但未必觸發這個類的鏈接以及初始化。)

    • 符號引用就好比“Jack 的房子”這種說法,不管它存在不存在,我們可以用這種說法指代 Jack 的房子。

    • 實際引用則好比實際的通訊地址,如果我們想要與 Jack 通信,則需要啟動蓋房子的過程。

    Java 虛擬機規范並沒有要求在鏈接過程中完成解析。它僅規定了:如果某些字節碼使用了符號引用,那么在執行這些字節碼之前,需要完成對這些符號引用的解析。

3.初始化

靜態字段的賦值

Java 中如果要初始化一個靜態字段,我們可以在聲明時直接賦值,也可以在靜態代碼塊中對其賦值。

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

image

初始化

類加載的最后一步是初始化,便是為標記為常量值的字段賦值和執行 < clinit > 方法的過程。Java 虛擬機會通過加鎖來確保類的 < clinit > 方法僅被執行一次

只有當初始化完成之后,類才正式成為可執行的狀態。

在蓋房子的例子中,相當於房子裝修好了,Jack 可以真正拎包入住了。

那么,類的初始化何時會被觸發呢?

JVM 規范枚舉了下述多種觸發情況:

  1. 當虛擬機啟動時,初始化用戶指定的主類,就是啟動執行的 main 方法所在的類;

  2. 當遇到用以新建目標類實例的 new 指令時,初始化 new 指令的目標類;

  3. 當遇到調用靜態方法的指令時,初始化該靜態方法所在的類;

  4. 當遇到訪問靜態字段的指令時,初始化該靜態字段所在的類;

  5. 子類的初始化會觸發父類的初始化;

  6. 如果一個接口定義了 default 方法,那么直接實現或者間接實現該接口的類的初始化,會觸發該接口的初始化;

    和繼承類似(5、6 條都是面向對象)

  7. 使用反射 API 對某個類進行反射調用時,初始化這個類;

  8. 初次調用 MethodHandle 實例時,初始化該 MethodHandle 指向的方法所在的類。

    和反射類似(7、8 條都是反射相關)

// 單例延遲初始化例子
public class Singleton {
  private Singleton() {}
  private static class LazyHolder {
    static final Singleton INSTANCE = new Singleton();
  }
  public static Singleton getInstance() {
    return LazyHolder.INSTANCE;
  }
}

只有當調用 Singleton.getInstance 時,程序才會訪問 LazyHolder.INSTANCE,才會觸發對 LazyHolder 的初始化(對應第 4 種情況),繼而新建一個 Singleton 的實例。

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

那么,什么時候不會初始化,但可能會加載?

  1. 通過子類引用父類的靜態字段,只會觸發父類的初始化,而不會觸發子類的初始化。

  2. 定義對象數組,不會觸發該類的初始化。

    直到 new 才觸發

  3. 常量在編譯期間會存入調用類的常量池中,本質上並沒有直接引用定義常量的類,不會觸發定義常量所在的類。

    常量不是變量

  4. 通過類名獲取 Class 對象,不會觸發類的初始化,Hello.class 不會讓 Hello 類初始化。

  5. 通過 Class.forName 加載指定類時,如果指定參數 initialize 為 false 時,也不會觸發類初始化,其實這個參數是告訴虛擬機,是否要對類進行初始化。Class.forName (“jvm.Hello”)默認會加載 Hello 類。

  6. 通過 ClassLoader 默認的 loadClass 方法,也不會觸發初始化動作(加載了,但不初始化)。

流程概覽

為了方便查看,我畫了一張流程圖演示上面的步驟。

image

END

如果你覺得有用,歡迎關注 「小尹探世界」 微信公眾號,希望我們一起打造一個有知識、有溫度、有趣點、有價值的頻道,探索技術之外的廣袤世界。

類的唯一性


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM