23、請介紹類加載過程,什麼是雙親委派模型?

目錄

今天我要問你的問題是,請介紹類加載過程,什麼是雙親委派模型?

典型回答

考點分析

知識擴展

通常類加載機制有三個基本特徵:

類加載器,類文件容器等都發生了非常大的變化,我這裏總結一下:

 談到類加載器,繞不過的一個話題是自定義類加載器,常見的場景有:

我們可以總體上簡單理解自定義類加載過程:

簡單來說,AppCDS 基本原理和工作過程是:

一課一練


Java 通過引入字節碼和 JVM 機制,提供了強大的跨平臺能力,理解 Java 的類加載機制是深入 Java 開發的必要條件,也是個面試考察熱點。

今天我要問你的問題是,請介紹類加載過程,什麼是雙親委派模型?

典型回答

1、Java 的類加載過程

一般來說,我們把 Java 的類加載過程分爲三個主要步驟:加載、鏈接、初始化,具體行爲在Java 虛擬機規範裏有非常詳細的定義。

 

第一階段是加載階段(Loading),它是 Java 將字節碼數據從不同的數據源讀取到 JVM 中,並映射爲 JVM 認可的數據結構(Class 對象),這裏的數據源可能是各種各樣的形態,如 jar 文件、class 文件,甚至是網絡數據源等;如果輸入數據不是 ClassFile 的結構,則會拋出 ClassFormatError。

加載階段是用戶參與的階段,我們可以自定義類加載器,去實現自己的類加載過程。

 

第二階段是鏈接(Linking),這是核心的步驟,簡單說是把原始的類定義信息平滑地轉化入 JVM 運行的過程中。這裏可進一步細分爲三個步驟:

  •   驗證(Verification),這是虛擬機安全的重要保障,JVM需要覈驗字節信息是符合Java 虛擬機規範的,否則就被認爲是VerifyError,這樣就防止了惡意信息或者不合規的信息危害 JVM 的運行,驗證階段有可能觸發更多 class 的加載。
  •   準備(Preparation),創建類或接口中的靜態變量,並初始化靜態變量的初始值。但這裏的“初始化”和下面的顯式初始化階段是有區別的,側重點在於分配所需要的內存空間,不會去執行更進一步的 JVM 指令。
  •   解析(Resolution),在這一步會將常量池中的符號引用(symbolic reference)替換爲直接引用。在Java  虛擬機規範中,詳細介紹了類、接口、方法和字段等各個方面的解析。

 
第三階段是初始化階段(initialization),這一步真正去執行類初始化的代碼邏輯,包括靜態字段賦值的動作,以及執行類定義中的靜態初始化塊內的邏輯,編譯器在編譯階段就會把這部分邏輯整理好,父類型的初始化邏輯優先於當前類型的邏輯。

 

雙親委派模型

雙親委派模型,簡單說就是當類加載器(Class-Loader)試圖加載某個類型的時候,除非父加載器找不到相應類型,否則儘量將這個任務代理給當前加載器的父加載器去做。使用委派模型的目的是避免重複加載 Java 類型。

雙親委派模型工作過程是:如果一個類加載器收到類加載的請求,它首先不會自己去嘗試加載這個類,而是把這個請求委派給父類加載器去加載這個類,依次傳遞到頂層類加載器(Bootstrap)。每個類加載器都是如此,只有當父加載器在自己的搜索範圍內找不到指定的類時(即ClassNotFoundException),子加載器纔會嘗試自己去加載。

 

爲什麼需要雙親委派模型?

假設沒有雙親委派模型,試想一個場景:

黑客自定義一個java.lang.String類,該String類具有系統的String類一樣的功能,只是在某個函數稍作修改。比如equals函數,這個函數經常使用,如果在這這個函數中,黑客加入一些“病毒代碼”。並且通過自定義類加載器加入到JVM中。此時,如果沒有雙親委派模型,那麼JVM就可能誤以爲黑客自定義的java.lang.String類是系統的String類,導致“病毒代碼”被執行。

而有了雙親委派模型,黑客自定義的java.lang.String類永遠都不會被加載進內存。因爲首先是最頂端的類加載器加載系統的java.lang.String類,最終自定義的類加載器無法加載java.lang.String類。

或許你會想,我在自定義的類加載器裏面強制加載自定義的java.lang.String類,不去通過調用父加載器不就好了嗎?確實,這樣是可行。但是,在JVM中,判斷一個對象是否是某個類型時,如果該對象的實際類型與待比較的類型的類加載器不同,那麼會返回false。

舉個簡單例子:

ClassLoader1ClassLoader2都加載java.lang.String類,對應Class1、Class2對象。那麼Class1對象不屬於ClassLoad2對象加載的java.lang.String類型。

 

總而言之,雙親委派模型有效解決了以下問題:

  • 每一個類都只會被加載一次,避免了重複加載
  • 每一個類都會被儘可能的加載(從引導類加載器往下,每個加載器都可能會根據優先次序嘗試加載它)
  • 有效避免了某些惡意類的加載(比如自定義了Java。lang.Object類,一般而言在雙親委派模型下會加載系統的Object類而不是自定義的Object類)

 

考點分析

今天的問題是關於 JVM 類加載方面的基礎問題,我前面給出的回答參考了 Java 虛擬機規範中的主要條款。如果你在面試中回答這個問題,在這個基礎上還可以舉例說明。

我們來看一個經典的延伸問題,準備階段談到靜態變量,那麼對於常量和不同靜態變量有什麼區別?

需要明確的是,沒有人能夠精確的理解和記憶所有信息,如果碰到這種問題,有直接答案當然最好;沒有的話,就說說自己的思路。

我們定義下面這樣的類型,分別提供了普通靜態變量、靜態常量,常量又考慮到原始類型和引用類型可能有區別。

public class CLPreparation {
    public static int a = 100;
    public static final int INT_CONSTANT = 1000;
    public static final Integer INTEGER_CONSTANT = Integer.valueOf(10000);
}

編譯並反編譯一下:

Javac CLPreparation.java
Javap –v CLPreparation.class

可以在字節碼中看到這樣的額外初始化邏輯:

         0: bipush        100
         2: putstatic     #2                  // Field a:I
         5: sipush        10000
         8: invokestatic  #3                  // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
        11: putstatic     #4                  // Field INTEGER_CONSTANT:Ljava/lang/Integer;

這能讓我們更清楚,普通原始類型靜態變量和引用類型(即使是常量),是需要額外調用 putstatic 等 JVM 
指令的,這些是在顯式初始化階段執行,而不是準備階段調用;而原始類型常量,則不需要這樣的步驟。

關於類加載過程的更多細節,有非常多的優秀資料進行介紹,你可以參考大名鼎鼎的《深入理解 Java 虛擬機》,一本非常好的入門書籍。我的建議是不要僅看教程,最好能夠想出代碼實例去驗證自己對某個方面的理解和判斷,這樣不僅能加深理解,還能夠在未來的應用開發中使用到。

 

其實,類加載機制的範圍實在太大,我從開發和部署的不同角度,各選取了一個典型擴展問題供你參考:

  •   如果要真正理解雙親委派模型,需要理解 Java 中類加載器的架構和職責,至少要懂具體有哪些內建的類加載器,這些是我上面的回答裏沒有提到的;以及如何自定義類加載器?
  •   從應用角度,解決某些類加載問題,例如我的 Java 程序啓動較慢,有沒有辦法儘量減小 Java 類加載的開銷?

另外,需要注意的是,在 Java 9 中,Jigsaw 項目爲 Java 提供了原生的模塊化支持,內建的類加載器結構和機制發生了明顯變化。我會對此進行講解,希望能夠避免一些未來升級中可能發生的問題。

 

知識擴展

首先,從架構角度,一起來看看 Java 8 以前各種類加載器的結構,下面是三種 JDK 內建的類加載器。

  •   啓動類加載器(Bootstrap Class-Loader),負責java核心類的加載,比如System,String等,加載 jre/lib 下面的 jar 文件,如 rt.jar。它是個超級公民,即使是在開啓了 Security Manager 的時候,JDK 仍賦予了它加載的程序 AllPermission。

對於做底層開發的工程師,有的時候可能不得不去試圖修改 JDK 的基礎代碼,也就是通常意義上的核心類庫,我們可以使用下面的命令行參數。

# 指定新的 bootclasspath,替換 java.* 包的內部實現
java -Xbootclasspath:<your_boot_classpath> your_App

# a 意味着 append,將指定目錄添加到 bootclasspath 後面
java -Xbootclasspath/a:<your_dir> your_App

# p 意味着 prepend,將指定目錄添加到 bootclasspath 前面
java -Xbootclasspath/p:<your_dir> your_App

用法其實很易懂,例如,使用最常見的 “/p”,既然是前置,就有機會替換個別基礎類的實現。

我們一般可以使用下面方法獲取父加載器,但是在通常的 JDK/JRE 實現中,擴展類加載器 getParent() 都只能返回 null。

public final ClassLoader getParent()
  • 擴展類加載器(Extension or Ext Class-Loader),負責加載我們放到 jre/lib/ext/ 目錄下面的 jar 包,這就是所謂的 extension 機制。該目錄也可以通過設置  “java.ext.dirs”來覆蓋。
java -Djava.ext.dirs=your_ext_dir HelloWorld
  • 應用類加載器(Application or App Class-Loader),就是加載我們最熟悉的 classpath 的內容。這裏有一個容易混淆的概念,系統(System)類加載器,通常來說,其默認就是 JDK 內建的應用類加載器,但是它同樣是可能修改的,比如:
java -Djava.system.class.loader=com.yourcorp.YourClassLoader HelloWorld

如果我們指定了這個參數,JDK 內建的應用類加載器就會成爲定製加載器的父親,這種方式通常用在類似需要改變雙親委派模式的場景。

具體請參考下圖:

至於前面被問到的雙親委派模型,參考這個結構圖更容易理解。試想,如果不同類加載器都自己加載需要的某個類型,那麼就會出現多次重複加載,完全是種浪費。

 

通常類加載機制有三個基本特徵:

  •   雙親委派模型。但不是所有類加載都遵守這個模型,有的時候,啓動類加載器所加載的類型,是可能要加載用戶代碼的,比如 JDK 內部的  ServiceProvider/ServiceLoader機制,用戶可以在標準 API 框架上,提供自己的實現,JDK 也需要提供些默認的參考實現。 例如,Java 中 JNDI、JDBC、文件系統、Cipher 等很多方面,都是利用的這種機制,這種情況就不會用雙親委派模型去加載,而是利用所謂的上下文加載器。
  •   可見性,子類加載器可以訪問父加載器加載的類型,但是反過來是不允許的,不然,因爲缺少必要的隔離,我們就沒有辦法利用類加載器去實現容器的邏輯。
  •   單一性,由於父加載器的類型對於子加載器是可見的,所以父加載器中加載過的類型,就不會在子加載器中重複加載。但是注意,類加載器“鄰居”間,同一類型仍然可以被加載多次,因爲互相併不可見。


在 JDK 9 中,由於 Jigsaw 項目引入了 Java 平臺模塊化系統(JPMS),Java SE 的源代碼被劃分爲一系列模塊。

 

類加載器,類文件容器等都發生了非常大的變化,我這裏總結一下:

  •   前面提到的 -Xbootclasspath 參數不可用了。API 已經被劃分到具體的模塊,所以上文中,利用“-Xbootclasspath/p”替換某個 Java 核心類型代碼,實際上變成了對相應的模塊進行的修補,可以採用下面的解決方案:

首先,確認要修改的類文件已經編譯好,並按照對應模塊(假設是 java.base)結構存放, 然後,給模塊打補丁:

java --patch-module java.base=your_patch yourApp
  •   擴展類加載器被重命名爲平臺類加載器(Platform Class-Loader),而且 extension 機制則被移除。也就意味着,如果我們指定  java.ext.dirs 環境變量,或者 lib/ext 目錄存在,JVM 將直接返回錯誤!建議解決辦法就是將其放入 classpath 裏。
  •   部分不需要 AllPermission 的 Java 基礎模塊,被降級到平臺類加載器中,相應的權限也被更精細粒度地限制起來。
  •   rt.jar 和 tools.jar 同樣是被移除了!JDK 的核心類庫以及相關資源,被存儲在 jimage 文件中,並通過新的 JRT  文件系統訪問,而不是原有的 JAR 文件系統。雖然看起來很驚人,但幸好對於大部分軟件的兼容性影響,其實是有限的,更直接地影響是 IDE  等軟件,通常只要升級到新版本就可以了。
  •   增加了 Layer 的抽象, JVM 啓動默認創建 BootLayer,開發者也可以自己去定義和實例化 Layer,可以更加方便的實現類似容器一般的邏輯抽象。


結合了 Layer,目前的 JVM 內部結構就變成了下面的層次,內建類加載器都在 BootLayer 中,其他 Layer 內部有自定義的類加載器,不同版本模塊可以同時工作在不同的 Layer。

 

 談到類加載器,繞不過的一個話題是自定義類加載器,常見的場景有:

  •   實現類似進程內隔離,類加載器實際上用作不同的命名空間,以提供類似容器、模塊化的效果。例如,兩個模塊依賴於某個類庫的不同版本,如果分別被不同的容器加載,就可以互不干擾。這個方面的集大成者是Java EE和OSGI、JPMS等框架。
  •   應用需要從不同的數據源獲取類定義信息,例如網絡數據源,而不是本地文件系統。
  •   或者是需要自己操縱字節碼,動態修改或者生成類型。


我們可以總體上簡單理解自定義類加載過程:

  •   通過指定名稱,找到其二進制實現,這裏往往就是自定義類加載器會“定製”的部分,例如,在特定數據源根據名字獲取字節碼,或者修改或生成字節碼。
  •   然後,創建 Class 對象,並完成類加載過程。二進制信息到 Class 對象的轉換,通常就依賴defineClass,我們無需自己實現,它是 final 方法。有了 Class 對象,後續完成加載過程就順理成章了。

具體實現我建議參考這個用例。

 

我在專欄第 1 講中,就提到了由於字節碼是平臺無關抽象,而不是機器碼,所以 Java 需要類加載和解釋、編譯,這些都導致 Java 啓動變慢。談了這麼多類加載,有沒有什麼通用辦法,不需要代碼和其他工作量,就可以降低類加載的開銷呢?

這個,可以有。

  •   在第 1 講中提到的 AOT,相當於直接編譯成機器碼,降低的其實主要是解釋和編譯開銷。但是其目前還是個試驗特性,支持的平臺也有限,比如,JDK 9 僅支持   Linux x64,所以侷限性太大,先暫且不談。
  •   還有就是較少人知道的 AppCDS(Application Class-Data Sharing),CDS 在 Java 5 中被引進,但僅限於   Bootstrap Class-loader,在 8u40 中實現了 AppCDS,支持其他的類加載器,在目前 2018 年初發布的 JDK 10 中已經開源。


簡單來說,AppCDS 基本原理和工作過程是:

首先,JVM 將類信息加載, 解析成爲元數據,並根據是否需要修改,將其分類爲 Read-Only 部分和 Read-Write 
部分。然後,將這些元數據直接存儲在文件系統中,作爲所謂的 Shared Archive。命令很簡單:

Java -Xshare:dump -XX:+UseAppCDS -XX:SharedArchiveFile=<jsa>  \
         -XX:SharedClassListFile=<classlist> -XX:SharedArchiveConfigFile=<config_file>

第二,在應用程序啓動時,指定歸檔文件,並開啓 AppCDS。

Java -Xshare:on -XX:+UseAppCDS -XX:SharedArchiveFile=<jsa> yourApp

通過上面的命令,JVM 會通過內存映射技術,直接映射到相應的地址空間,免除了類加載、解析等各種開銷。

AppCDS 改善啓動速度非常明顯,傳統的 Java EE 應用,一般可以提高 20%~30% 以上;實驗中使用 Spark KMeans 負載,20 個 slave,可以提高 11% 的啓動速度。

與此同時,降低內存 footprint,因爲同一環境的 Java 進程間可以共享部分數據結構。前面談到的兩個實驗,平均可以減少 10% 以上的內存消耗。

當然,也不是沒有侷限性,如果恰好大量使用了運行時動態類加載,它的幫助就有限了。

今天我梳理了一下類加載的過程,並針對 Java 新版中類加載機制發生的變化,進行了相對全面的總結,最後介紹了一個改善類加載速度的特性,希望對你有所幫助。

 

一課一練

關於今天我們討論的題目你做到心中有數了嗎?今天的思考題是,談談什麼是 Jar Hell 問題?你有遇到過類似情況嗎,如何解決呢?

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章