Java高併發學習筆記(三):類載入

語言: CN / TW / HK

1 來源

  • 來源:《Java高併發程式設計詳解 多執行緒與架構設計》,汪文君著
  • 章節:第九、十、十一章

本文這三章的筆記整理。

2 類載入簡介

類載入的過程可以簡單分為三個階段:

  • 載入階段:主要負責查詢並且載入類的二進位制資料檔案
  • 連線階段:可以細分為驗證、準備、解析三個階段,驗證就是確保類檔案的正確性,準備就是為類的靜態變數分配記憶體,並且為其初始化預設值,解析就是把類中的符號引用轉換為直接引用
  • 初始化階段:為類的靜態變數賦予正確的初始值

3 主動使用與被動使用

JVM規範規定了每個類或介面在首次主動使用的時候都需要進行初始化,規定了以下六種主動使用類的場景:

  • 通過new關鍵字會導致類的初始化
  • 訪問類的靜態變數
  • 訪問類的靜態方法
  • 對某個類進行反射操作
  • 初始化子類會導致父類初始化
  • 啟動類(就是包含main()的類)也會初始化

除了以上六種情況外,其餘的都叫被動使用,不會導致類的載入和初始化,比如引用類的靜態常量不會導致類的初始化。

4 類載入詳解

前面也說了類載入可以簡單分為三個階段:

  • 載入階段
  • 連線階段
  • 初始化階段

下面先來看一下載入階段。

4.1 載入階段

載入階段就是將class檔案中的二進位制資料讀取到記憶體之中,然後將該位元組流代表的靜態儲存結構轉換為方法區中執行時資料結構,並且在堆中生成一個該類的java.lang.Class物件,作為訪問方法區資料結構的入口。

類載入的最終產物就是堆記憶體中的class物件,JVM規範中指出類載入是通過一個全限定名去獲取二進位制資料流,來源包括:

  • class檔案:這是最常見的格式,就是載入javac編譯後的位元組碼檔案
  • 執行時動態生成:比如ASM可以動態生成,或者可以通過動態代理java.lang.Proxy生成等
  • 通過網路獲取:比如RMI
  • 讀取壓縮檔案:比如JARWAR
  • 從資料庫讀取:比如讀取MySQL中的BLOB欄位型別的資料
  • 執行時生成class檔案並且動態載入:比如ThriftAvro等序列化框架,將某個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中提供了很多SPIService Provider Interface),比如JDBC等,JDBC只規定了這些介面之間的邏輯關係,但不提供具體的實現,換句話說,JDBC完全透明瞭應用程式和第三方廠商資料庫驅動的具體實現,應用程式只需要面向介面程式設計即可。但問題是:

  • java.lang.sql中的所有介面都是由JDK提供的,載入這些介面的類載入器是啟動類載入器
  • 第三方廠商的類庫驅動由系統類載入器載入

由於雙親委派機制,ConnectionsStatement等都是由啟動類載入器載入,而第三方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載入,包括JAXBJCEJBI等。