23、請介紹類載入過程,什麼是雙親委派模型?
目錄
今天我要問你的問題是,請介紹類載入過程,什麼是雙親委派模型?
類載入器,類檔案容器等都發生了非常大的變化,我這裡總結一下:
談到類載入器,繞不過的一個話題是自定義類載入器,常見的場景有:
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。
舉個簡單例子:
ClassLoader1
、ClassLoader2
都載入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 問題?你有遇到過類似情況嗎,如何解決呢?