Java 通過引入字節碼和 JVM 機制,提供了強大的跨平臺能力,理解 Java 的類加載機制是深入 Java 開發的必要條件,也是個面試考察熱點。
今天我要問你的問題是,請介紹類加載過程,什么是雙親委派模型?
## 典型回答
一般來說,我們把 Java 的類加載過程分為三個主要步驟:加載、鏈接、初始化,具體行為在[Java 虛擬機規范](https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-5.html)里有非常詳細的定義。
首先是加載階段(Loading),它是 Java 將字節碼數據從不同的數據源讀取到 JVM 中,并映射為 JVM 認可的數據結構(Class 對象),這里的數據源可能是各種各樣的形態,如 jar 文件、class 文件,甚至是網絡數據源等;如果輸入數據不是 ClassFile 的結構,則會拋出 ClassFormatError。
加載階段是用戶參與的階段,我們可以自定義類加載器,去實現自己的類加載過程。
第二階段是鏈接(Linking),這是核心的步驟,簡單說是把原始的類定義信息平滑地轉化入 JVM 運行的過程中。這里可進一步細分為三個步驟:
* 驗證(Verification),這是虛擬機安全的重要保障,JVM 需要核驗字節信息是符合 Java 虛擬機規范的,否則就被認為是 VerifyError,這樣就防止了惡意信息或者不合規的信息危害 JVM 的運行,驗證階段有可能觸發更多 class 的加載。
* 準備(Preparation),創建類或接口中的靜態變量,并初始化靜態變量的初始值。但這里的“初始化”和下面的顯式初始化階段是有區別的,側重點在于分配所需要的內存空間,不會去執行更進一步的 JVM 指令。
* 解析(Resolution),在這一步會將常量池中的符號引用(symbolic reference)替換為直接引用。在[Java 虛擬機規范](https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-5.html#jvms-5.4.3)中,詳細介紹了類、接口、方法和字段等各個方面的解析。
最后是初始化階段(initialization),這一步真正去執行類初始化的代碼邏輯,包括靜態字段賦值的動作,以及執行類定義中的靜態初始化塊內的邏輯,編譯器在編譯階段就會把這部分邏輯整理好,父類型的初始化邏輯優先于當前類型的邏輯。
再來談談雙親委派模型,簡單說就是當類加載器(Class-Loader)試圖加載某個類型的時候,除非父加載器找不到相應類型,否則盡量將這個任務代理給當前加載器的父加載器去做。使用委派模型的目的是避免重復加載 Java 類型。
## 考點分析
今天的問題是關于 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 以前各種類加載器的結構,下面是三種 Oracle JDK 內建的類加載器。
* 啟動類加載器(Bootstrap Class-Loader),加載 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](https://docs.oracle.com/javase/9/docs/api/java/util/ServiceLoader.html)機制,用戶可以在標準 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](http://www.oracle.com/technetwork/java/javaee/overview/index.html)和[OSGI](https://en.wikipedia.org/wiki/OSGi)、[JPMS](https://en.wikipedia.org/wiki/Java_Platform_Module_System)等框架。
* 應用需要從不同的數據源獲取類定義信息,例如網絡數據源,而不是本地文件系統。
* 或者是需要自己操縱字節碼,動態修改或者生成類型。
我們可以總體上簡單理解自定義類加載過程:
* 通過指定名稱,找到其二進制實現,這里往往就是自定義類加載器會“定制”的部分,例如,在特定數據源根據名字獲取字節碼,或者修改或生成字節碼。
* 然后,創建 Class 對象,并完成類加載過程。二進制信息到 Class 對象的轉換,通常就依賴[defineClass](https://docs.oracle.com/javase/9/docs/api/java/lang/ClassLoader.html#defineClass-java.lang.String-byte:A-int-int-),我們無需自己實現,它是 final 方法。有了 Class 對象,后續完成加載過程就順理成章了。
具體實現我建議參考這個[用例](http://www.baeldung.com/java-classloaders)。
我在[專欄第 1 講](http://time.geekbang.org/column/article/6845)中,就提到了由于字節碼是平臺無關抽象,而不是機器碼,所以 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 問題?你有遇到過類似情況嗎,如何解決呢?
- 前言
- 開篇詞
- 開篇詞 -以面試題為切入點,有效提升你的Java內功
- 模塊一 Java基礎
- 第1講 談談你對Java平臺的理解?
- 第2講 Exception和Error有什么區別?
- 第3講 談談final、finally、 finalize有什么不同?
- 第4講 強引用、軟引用、弱引用、幻象引用有什么區別?
- 第5講 String、StringBuffer、StringBuilder有什么區別?
- 第6講 動態代理是基于什么原理?
- 第7講 int和Integer有什么區別?
- 第8講 對比Vector、ArrayList、LinkedList有何區別?
- 第9講 對比Hashtable、HashMap、TreeMap有什么不同?
- 第10講 如何保證集合是線程安全的? ConcurrentHashMap如何實現高效地線程安全?
- 第11講 Java提供了哪些IO方式? NIO如何實現多路復用?
- 第12講 Java有幾種文件拷貝方式?哪一種最高效?
- 第13講 談談接口和抽象類有什么區別?
- 第14講 談談你知道的設計模式?
- 模塊二 Java進階
- 第15講 synchronized和ReentrantLock有什么區別呢?
- 第16講 synchronized底層如何實現?什么是鎖的升級、降級?
- 第17講 一個線程兩次調用start()方法會出現什么情況?
- 第18講 什么情況下Java程序會產生死鎖?如何定位、修復?
- 第19講 Java并發包提供了哪些并發工具類?
- 第20講 并發包中的ConcurrentLinkedQueue和LinkedBlockingQueue有什么區別?
- 第21講 Java并發類庫提供的線程池有哪幾種? 分別有什么特點?
- 第22講 AtomicInteger底層實現原理是什么?如何在自己的產品代碼中應用CAS操作?
- 第23講 請介紹類加載過程,什么是雙親委派模型?
- 第24講 有哪些方法可以在運行時動態生成一個Java類?
- 第25講 談談JVM內存區域的劃分,哪些區域可能發生OutOfMemoryError?
- 第26講 如何監控和診斷JVM堆內和堆外內存使用?
- 第27講 Java常見的垃圾收集器有哪些?
- 第28講 談談你的GC調優思路?
- 第29講 Java內存模型中的happen-before是什么?
- 第30講 Java程序運行在Docker等容器環境有哪些新問題?
- 模塊三 Java安全基礎
- 第31講 你了解Java應用開發中的注入攻擊嗎?
- 第32講 如何寫出安全的Java代碼?
- 模塊四 Java性能基礎
- 第33講 后臺服務出現明顯“變慢”,談談你的診斷思路?
- 第34講 有人說“Lambda能讓Java程序慢30倍”,你怎么看?
- 第35講 JVM優化Java代碼時都做了什么?
- 模塊五 Java應用開發擴展
- 第36講 談談MySQL支持的事務隔離級別,以及悲觀鎖和樂觀鎖的原理和應用場景?
- 第37講 談談Spring Bean的生命周期和作用域?
- 第38講 對比Java標準NIO類庫,你知道Netty是如何實現更高性能的嗎?
- 第39講 談談常用的分布式ID的設計方案?Snowflake是否受冬令時切換影響?
- 周末福利
- 周末福利 談談我對Java學習和面試的看法
- 周末福利 一份Java工程師必讀書單
- 結束語
- 結束語 技術沒有終點
- 結課測試 Java核心技術的這些知識,你真的掌握了嗎?