1 來源
- 來源:《Java高併發編程詳解 多線程與架構設計》,汪文君著
- 章節:第九、十、十一章
本文這三章的筆記整理。
2 類加載簡介
類加載的過程可以簡單分爲三個階段:
- 加載階段:主要負責查找並且加載類的二進制數據文件
- 連接階段:可以細分爲驗證、準備、解析三個階段,驗證就是確保類文件的正確性,準備就是爲類的靜態變量分配內存,並且爲其初始化默認值,解析就是把類中的符號引用轉換爲直接引用
- 初始化階段:爲類的靜態變量賦予正確的初始值
3 主動使用與被動使用
JVM
規範規定了每個類或接口在首次主動使用的時候都需要進行初始化,規定了以下六種主動使用類的場景:
- 通過
new
關鍵字會導致類的初始化 - 訪問類的靜態變量
- 訪問類的靜態方法
- 對某個類進行反射操作
- 初始化子類會導致父類初始化
- 啓動類(就是包含
main()
的類)也會初始化
除了以上六種情況外,其餘的都叫被動使用,不會導致類的加載和初始化,比如引用類的靜態常量不會導致類的初始化。
4 類加載詳解
前面也說了類加載可以簡單分爲三個階段:
- 加載階段
- 連接階段
- 初始化階段
下面先來看一下加載階段。
4.1 加載階段
加載階段就是將class
文件中的二進制數據讀取到內存之中,然後將該字節流代表的靜態存儲結構轉換爲方法區中運行時數據結構,並且在堆中生成一個該類的java.lang.Class
對象,作爲訪問方法區數據結構的入口。
類加載的最終產物就是堆內存中的class
對象,JVM
規範中指出類加載是通過一個全限定名去獲取二進制數據流,來源包括:
class
文件:這是最常見的格式,就是加載javac
編譯後的字節碼文件- 運行時動態生成:比如
ASM
可以動態生成,或者可以通過動態代理java.lang.Proxy
生成等 - 通過網絡獲取:比如
RMI
- 讀取壓縮文件:比如
JAR
、WAR
包 - 從數據庫讀取:比如讀取
MySQL
中的BLOB
字段類型的數據 - 運行時生成
class
文件並且動態加載:比如Thrift
、Avro
等序列化框架,將某個schema
生成若干個class
文件並進行加載
類加載階段結束後,JVM
會將這些二進制字節流按照JVM
定義的格式存放在方法區中,形成特定的數據結構後再在堆內存中實例化一個java.lang.Class
對象。
4.2 連接階段
該階段可以分爲三個小階段:
- 驗證
- 準備
- 解析
需要注意的是這三個小階段其實不是順序進行的,而是交叉着進行的,也就是解析的時候其實也會有驗證的過程。
4.2.1 驗證
驗證是爲了確保字節流所包含的內容符合JVM
規範,並且不會出現危害JVM
自身安全的代碼,當字節流信息不符合要求的時候,會拋出VerifyError
這樣的異常或其子異常,驗證的信息包括:
- 文件格式
- 元數據
- 字節碼
- 符號引用
4.2.1.1 驗證文件格式
包括:
- 魔數(
0xCAFEBABE
) - 主次版本號
- 是否存在殘缺或附加信息
- 常量池常量類型是否支持
- 常量池引用是否指向不存在常量或不支持類型常量
- 其他
4.2.1.2 驗證元數據
元數據驗證其實是進行語義分析的過程,語義分析是爲了確保字節流符合JVM
規範要求,包括:
- 檢查某個類是否存在父類,是否繼承某個接口,這些父類或接口是否合法,或是否存在
- 檢查是否繼承了
final
的類 - 檢查抽象類,檢查是否實現了父類的抽象方法或接口方法
- 檢查重載,比如相同的方法名稱、相同的參數但是返回類型不同,這是不允許的
4.2.1.3 驗證字節碼
字節碼驗證主要是驗證程序的控制流程,包括:
- 保證當前線程在程序計數器中的指令不會跳轉到不合法的字節碼指令中去
- 保證類型的轉換是合法的
- 保證任意時刻虛擬機棧中的操作棧類型與指令代碼都能正確被執行
- 其他驗證
4.2.1.4 驗證符號引用
驗證符號引用轉換爲直接引用的合法性,保證解析動作的順利執行,包括:
- 通過符號引用描述的字符串全限定名稱是否能夠順利找到相關的類
- 符號引用中的類、字段、方法是否對當前類可見
- 其他
4.2.2 準備
經過驗證後,就開始了準備階段,這階段比較簡單,就是對對象的靜態變量分配內存並且設置初始值,類變量的內存會被分配到方法區中。設置初始值就是爲相應的類變量給定一個相關類型在沒有被設置時的默認值,比如Int
的初始值爲0,引用的初始值爲null
。
4.2.3 解析
解析就是在常量池中尋找類、字段、接口和方法的符號引用,並且將這些符號引用替換成直接引用的過程。解析主要針對類接口、字段、類方法和接口方法進行的,包括:
- 類接口解析
- 字段解析
- 類方法解析
- 接口方法解析
4.3 初始化階段
初始化階段主要就是執行<clinit>
方法的過程,該方法是編譯階段生成的,也就是說包含在字節碼文件中,該方法包含了所有類變量的賦值動作和靜態語句塊的執行代碼。另一方面,<clinit>
與構造方法不同,不需要顯式調用父類構造器,虛擬機會保證父類的<clinit>
方法最先執行。
還需要注意的是<clinit>
只能被虛擬機執行,虛擬機還會保證多線程下的安全性,因此,如果在靜態代碼塊中如果包含了加載其他類的操作可能會引起死鎖,例子可以看這裏。
5 類加載器
5.1 JVM
中的三類核心類加載器
JVM
中有三類核心類加載器,分別是:
- 啓動類加載器:啓動類加載器是最頂層的類加載器,沒有父加載器,由
C++
編寫,負責JVM
核心類庫的加載,比如加載整個java.lang
包中的類 - 擴展類加載器:擴展類加載器的父加載器是啓動類加載器,主要加載
jre/lib/ext
子目錄下的類庫,純Java
實現,是URLClassLoader
的子類 - 應用類加載器:也叫系統類加載器,負責加載
classpath
下的類庫,應用類加載器的父加載器爲擴展類加載器,同時它也是自定義類加載器的默認父加載器
5.2 雙親委派機制
一個類加載器加載一個類的時候,並不會嘗試直接加載該類,而是先交給父加載器嘗試加載,一直到頂層的父加載器(啓動類加載器),如果父加載器加載失敗,則會自己嘗試加載,圖示如下:
6 線程上下文類加載器
JDK
中提供了很多SPI
(Service Provider Interface
),比如JDBC
等,JDBC
只規定了這些接口之間的邏輯關係,但不提供具體的實現,換句話說,JDBC
完全透明瞭應用程序和第三方廠商數據庫驅動的具體實現,應用程序只需要面向接口編程即可。但問題是:
java.lang.sql
中的所有接口都是由JDK
提供的,加載這些接口的類加載器是啓動類加載器- 第三方廠商的類庫驅動由系統類加載器加載
由於雙親委派機制,Connections
、Statement
等都是由啓動類加載器加載,而第三方JDBC
驅動包中的實現不會被加載。解決這個問題的關鍵,就是使用了線程上下文類加載器打破了雙親委派機制。
比如MySQL
驅動的加載過程,就是通過線程上下文類加載器加載的,
private static Connection getConnection(String url, Properties info, Class<?> caller) throws SQLException {
//...
if (callerCL == null || callerCL == ClassLoader.getPlatformClassLoader()) {
callerCL = Thread.currentThread().getContextClassLoader();
}
while(true) {
//...
if (isDriverAllowed(aDriver.driver, callerCL)) {
}
}
//...
}
private static boolean isDriverAllowed(Driver driver, ClassLoader classLoader) {
//...
try {
aClass = Class.forName(driver.getClass().getName(), true, classLoader);
} catch (Exception var5) {
result = false;
}
//...
return result;
}
通過線程上下文類加載器,就變成了啓動類加載器去委託子類加載器去加載實現的方式,也就是JDK
自己親自打破了雙親委派機制這種方式,這種加載方式幾乎涉及所有的SPI
加載,包括JAXB
、JCE
、JBI
等。