【進階之路】深入理解Java虛擬機器的類載入機制(長文)

南橘ryc發表於2021-06-29

我們在參加面試的時候,經常被問到一些關於類載入機制的問題,也都會在面試之前準備的時候背好答案,但是我們是否有去深入瞭解什麼是類載入機制呢?這段時間因為一些事情在家看了些書,這次就和大家分享一些關於Java類載入機制的知識。

虛擬機器的類載入機制:Java虛擬機器把資料載入到記憶體,同時對資料進行校驗、解析、初始化等一些列操作,最終把Class檔案變為虛擬機器可以直接使用的Java型別檔案。

一個類從被載入到虛擬機器記憶體開始,直到解除安裝出記憶體為止,他的生命週期會經歷載入驗證準備解析初始化使用解除安裝七個階段(其中驗證、準備、解析三個階段被稱為連線)

連線就是將已經讀入到記憶體的類的二進位制資料合併到虛擬機器的執行時環境中去,所以這三個階段可以看成是一整個階段。

image.png

一、載入階段

其中,載入、驗證、準備、初始化和解除安裝這五個階段的順序是確定的,類載入的順序必須按照這種順序按部就班的開始,而解析階段則不一樣,有時候為了支援動態繫結它可以在初始化階段之後再進行解析。
至於何時會進行載入階段,《Java虛擬機器規範中》並未進行強制約束,只需要在載入階段完成以下三件事:

  • 1、通過一個類的全限定名來獲取定義此類的二進位制位元組流
  • 2、將這個位元組流所代表的靜態儲存結構轉換為方法區的執行時資料結構
  • 3、在記憶體中生成一個代表這個類的java.lang.Class物件,作為方法區中這個類的各種資料的訪問入口。
    對於載入階段,Java虛擬機器的要求非常的開放,以至於通過一個類的全限定名來獲取定義此類的二進位制位元組流這個步驟就可以通過Class檔案獲取執行時計演算法(反射)ZIP包讀取(包括JAR\WAR等)網路運算(Web Applet)其他檔案生成(JSP)加密檔案獲取......等各式各樣的方法。

但是對於陣列類而言、情況則有所不同。陣列類本身不通過類載入器來創造,而是由Java虛擬機器在直接在記憶體中動態構建出來的。但是構成陣列類本身的元數型別(Element Type)還是需要類載入器來載入完成,所以最終還是會遵循類載入器的以下規則:

  • 1、如果陣列的元件型別是引用型別,那就遞迴採用定義的載入過程去載入這個元件,陣列類將被標識在載入該元件型別的類載入器的類名稱空間上。(一個類必須與類載入器一起確定唯一性
  • 2、陣列的元件型別不是引用型別(比如int[]陣列就是int型別),Java虛擬機器會把陣列在載入該元件型別的類載入器的類名稱空間上標識。
  • 3、陣列類的可訪問性級別與它的元件的可訪問性級別一直,如果陣列型別不是引用型別,則可訪問性級別將預設為public。

載入階段結束後,Java需要立即外部的二進位制位元組流就會按照虛擬機器所設定的格式儲存在方法區之中了,方法區中的資料儲存格式將完全由虛擬機器自行實現。

與之前所說的一致,資料被儲存在方法區之後,會在Java堆記憶體中例項化一個代表這個類的java.lang.Class物件,物件將作為方法區中這個類的各種資料的外部介面。

載入階段與連線階段的部分動作是交替進行的(比如位元組碼檔案格式的驗證動作),載入階段尚未結束也許連線階段就已經開始,但是兩個階段的開始時間還是保持著先後順序。

二、驗證階段

驗證是連線的第一步,這一階段的目的就是確保Class檔案位元組流中包含的資訊符合《Java虛擬機器規範》中的全部約束,並且確保這些資訊不會危害虛擬機器本身的安全。驗證階段會完成四個階段的驗證:檔案格式驗證後設資料驗證位元組碼驗證符號引用驗證,接下來就依次介紹這四種驗證。

1、檔案格式驗證

第一階段自然是檢查位元組流是否符合Class檔案格式的規範,並且能被當前版本的虛擬機器理解(這一部分需要聯絡到Class的檔案結構)

Class檔案格式採用一種類似於C語言結構體的偽結構來儲存資料,這種偽結構中只有兩種資料型別:無符號數

  • 無符號數屬於基本的資料型別,以u1、u2、u4、u8來分別代表1個位元組、2個位元組、4個位元組和8個位元組的無符號數,無符號數可以用來描述數字、索引引用、數量值或者按照UTF-8編碼構成字串值。
  • 表是由多個無符號數或者其他表作為資料項構成的複合資料型別,為了便於區分,所有表的命名都習慣性地以_info結尾。表用於描述有層次關係的複合結構的資料,整個Class檔案本質上也可以視作是一張表,這張表由下圖所示的資料項按嚴格順序排列構成。

下圖為詳細介紹:

型別 名稱 中文名 數量 預設值(沒有則不寫)
u4 magic 魔數 1
u2 minor_version 次版本號 1
u2 major_version 主版本號 1 JDK版本(k>=2),對應的範圍為45.0~44+k.0
u2 constant_pool_count 常量池容量 1 值為常量池成員數+1,唯一一個從1開始計數的單位
cp_info constant_pool 常量池 constant_pool_count-1 下標為0:表示“不引用任何一個常量池”
u2 access_flags 訪問標誌 1
u2 this_class; 類索引 1 常量池表中的一個有效索引,該索引處的成員為CONSTANT_Class_info型別常量(類/介面)
u2 super_flags 父類索引 1 0或者常量池有效索引,0表示該類為Object
u2 interfaces_count 介面計數器 1 可以為0
u2 interfaces 介面表 interfaces_count 常量池中CONSTANT_Class_info的有效索引
u2 fields_count 欄位計數器 1
field_info fields 欄位表 fields_count 成員為field_info結構,不包括父類或父介面的欄位
u2 methods_count 方法計數器 1
method_info methods 方法表 methods_count 成員為method_info結構,包括,不包括父類或父介面的方法
u2 attributes_count 屬性計數器 1
attribute_info attributes 屬性表 attributes_count 成員為attribute_info結構,Signature、InnerClasses等

只有通過了這個階段的驗證,位元組流才被允許進入Java虛擬機器的記憶體的方法區中進行儲存。後面的三個驗證階段全部給予方法區的儲存結構式進行的,不會再直接讀取操作位元組流了

2、後設資料驗證

第二階段是對位元組碼描述的資訊進行語義分析,以確保其描述資訊符合規範:

  • 1、該類是否有父類(除了java.lang.Object之外,所有的類都應該有父類)
  • 2、父類是否結成了不允許被繼承的類(被final修飾的類)
  • 3、是否是抽象類,是否實現了父類或者介面中要求實現的方法
  • ...

3、位元組碼驗證

第三階段是整個驗證過程中最為複雜的一個階段,主要目的是通過資料流分析控制流分析,確定語義是合法以及符合邏輯的。在第二階段對後設資料資訊中的資料型別校驗完畢之後,這個階段主要對類的方法體進行校驗分析,保證被校驗類的方法在執行時不會做出危害虛擬機器的行為。

4、符號引用驗證

最後一個校驗階段發生在虛擬機器將符號應用轉化為直接引用的時候,這個轉化會在解析階段發生。

符號引用驗證的目的是要確保解析行為能正常秩序,如果無法通過符號引用驗證,Java虛擬機器會丟擲一個java.lang.IncompatibleClassChangeError的子類異常。

三、準備階段

準備階段是正式為類中定義的變數(即靜態變數)分配記憶體並設定類變數初始值的階段,從概念上來說,這些變數所使用的記憶體都應當在方法區中進行分配。

在準備階段進行記憶體分配的僅包括類變數(靜態變數),而不包括例項變數。例項變數將會在物件例項化的階段隨著物件一起分配在java堆中。

public static int value = 1;

類似於這種情況,在準備階段後依然是0而不是1,因為這時候尚未執行任何Java方法,將value賦值必須等到類的初始化階段才會被執行。

public static final int value = 1;

但是如果類欄位存在ConstantValue屬性,則在準備階段就會根據ConstantValue的設定將value賦值為1。

ConstantValue屬於屬性表集合中的一個屬性
static final修飾的欄位在javac編譯時生成comstantValue屬性,在類載入的準備階段直接把constantValue的值賦給該欄位。可以理解為在編譯期即把結果放入了常量池中,同時ConstantValue的屬性值只限於基本型別和String型別。

四、解析階段

解析階段是Java虛擬機器將常量池內的符號引用替換為直接引用的過程。

  • 符號引用:符號引用以一組符號來描述所引用的目標。這裡的符號可以是任何形式的字面量。符號引用與虛擬機器實現的記憶體佈局無關,引用的目標並不一定是已經載入到了虛擬機器記憶體中的內容。符號引用的字面量形式定義在《Java虛擬機器規範》的Class檔案格式中。

在電腦科學中,字面量(literal)是用於表達原始碼中一個固定值的表示法(notation)。幾乎所有計算機程式語言都具有對基本值的字面量表示,諸如:整數、浮點數以及字串;而有很多也對布林型別和字元型別的值也支援字面量表示;還有一些甚至對列舉型別的元素以及像陣列、記錄和物件等複合型別的值也支援字面量表示法。

  • 直接引用:直接引用是可以直接指向目標的指標、相對偏移量或者是一個能間接定位到目標的控制程式碼。直接引用是和虛擬機器實現的記憶體佈局直接相關的,同一個符號引用在不同虛擬機器例項上翻譯出來的直接引用一般不會相同。當然,如果有了直接引用,那麼被引用的目標必定已經在虛擬機器的記憶體中存在了。

解析階段包括四種型別的解析。

1、對類或者介面的解析步驟

  • 1、判斷將要解析的符號引用是不是一個陣列型別,如果不是,那麼虛擬機器將會把該符號代表的全限定名稱傳遞給類載入器去載入這個類。這個過程由於涉及驗證過程所以可能會觸發其他相關類的載入過程。

  • 2、如果該符號引用是一個陣列型別,並且該陣列的元素型別是物件。將會按照規則載入陣列元素型別,例如需需要載入的元素型別是java.lang.Integer,則會由虛擬機器將會生成一個代表此陣列物件的直接引用。

  • 3、如果上面的步驟正常執行,那麼該符號引用已經在虛擬機器中產生了一個直接引用,但是在解析完成之前需要對符號引用進行驗證,主要是確認當前呼叫這個符號引用的類是否具有訪問許可權,如果沒有訪問許可權將丟擲java.lang.IllegalAccess異常。

2、對欄位的解析步驟

欄位解析將會按照以下步驟進行解析。

  • 1、如果該欄位符號引用就包含了簡單名稱和欄位描述符都與目標相匹配的欄位,則會直接返回這個欄位的直接引用,並且結束解析階段。

  • 2、如果在該符號的類實現了介面,將會按照繼承關係從下往上遞迴搜尋各個介面和它的父介面,如果在介面中包含了簡單名稱和欄位描述符都與目標相匹配的欄位,則會直接返回這個欄位的直接引用,並且結束解析階段。

  • 3、如果該符號所在的類不是Object類的話,將會按照繼承關係從下往上遞迴搜尋其父類,如果在父類中包含了簡單名稱和欄位描述符都相匹配的欄位,則會直接返回這個欄位的直接引用,並且結束解析階段。

  • 4、如果三種情況都沒有成功解析,則為解析失敗,並丟擲java.lang.NoSuchFieldError異常。

以上規則能確保Java虛擬機器獲得欄位的唯一解析結果,但在實際情況中,編譯器往往會採取比上述規範更加嚴格的約束,比如同名欄位同時出現在某個類的介面和父類中,或者在自己和父類中同時出現,Javac編譯器就會直接拒編譯。

3、對方法的解析步驟

  • 1、類方法和介面方法的符號引用是分開的,所以如果在類方法表中發現class_index(類中方法的符號引用)的索引是一個介面,那麼會丟擲java.lang.IncompatibleClassChangeError的異常。

  • 2、如果class_index的索引確實是一個類,那麼在該類中查詢是否有簡單名稱和描述符都與目標欄位相匹配的方法,則會直接返回這個欄位的直接引用,並且結束解析階段。

  • 3、在該類的父類中遞迴查詢是否具有簡單名稱和描述符都與目標欄位相匹配的欄位,如果有,則會直接返回這個欄位的直接引用,並且結束解析階段。

  • 4、在這個類實現的介面以及它的父介面中遞迴查詢是否有簡單名稱和描述都與目標相匹配的方法,如果找到的話就說明這個方法是一個抽象類,解析結束,返回java.lang.AbstractMethodError異常。

  • 5、否則,查詢失敗,丟擲java.lang.NoSuchMethodError異常。

最後如果成功返回了直接引用,還會對方法進行訪問許可權驗證,如果失敗依然要丟擲java.lang.illegalAccessError異常。

4、對介面方法的解析步驟

  • 1、首先會判斷是否是一個介面,如果不是,那麼會丟擲java.lang.IncompatibleClassChangeError的異常。

  • 2、在該介面方法的所屬的介面中查詢是否具有簡單名稱和描述符都與目標欄位相匹配的方法,如果有的話就直接返回這個方法的直接引用。

  • 3、在該介面以及其父介面中查詢,直到Object類,如果找到則直接返回這個方法的直接引用

  • 4、否則,查詢失敗,丟擲java.lang.NoSuchMethodError異常。

在JDK9引入模組化之後,public型別也不在意味著程式任何位置都有它的訪問許可權,還需要檢查模組之間的訪問許可權,介面方法訪問完全有可能因為訪問許可權控制而出現java.lang.illegalAccessError異常。

五、初始化階段

初始化階段是類載入過程的最後一個步驟,在之前的幾個步驟中,除了在載入階段使用者可以通過自定義類載入器的方式區域性控制以外,其他時間都是完全由Java虛擬機器來主導。在初始化階段,Java虛擬機器才真正開始執行類中編寫的Java程式程式碼。

我們之前提過,在準備階段,變數已經經過一次系統初始賦值(大部分情況為初始值),而在初始化階段,則會根據我們設計的程式而去初始化變數。

《Java虛擬機器規範》中定義了六種情況必須對類進行初始化:

  • 1、使用 New 關鍵字例項化物件的時候。

  • 2、讀取或設定一個類的靜態欄位的時候。

  • 3、呼叫一個類的靜態方法的時候。

  • 4、通過java.lang.reflect包中的方法對類進行反射呼叫的時候。

  • 5、當初始化一個類時,發現其父類還沒有進行初始化,則需要先觸發其父類初始化。

  • 6、當虛擬機器啟動時,使用者需要指定一個要執行的包含 main 方法的主類,虛擬機器會初始化這個主類。

除此之外,其他方式都無法觸發類的初始化,我們可以通過子類引用父類的靜態欄位來測試。


public class Father {
    static {
        System.out.println("I am Father ");
    }
    public static int value =1;
}

public class Son extends Father{
    static {
        System.out.println("I am Son ");
    }
}

public static void main(String[] args) {
    System.out.println(Son.value);
}

這是一個很有名的例子,告訴我們子類引用父類的靜態欄位,並不會導致子類的初始化,只有直接定義這個欄位的類才會被初始化

image.png

我們再來看看如果在編譯階段把資料放入常量池,是否會進行初始化。

public class ConstantValueTest {
    static {
        System.out.println("I am ConstantValueTest ");
    }
    public static final int value = 1;
}

public static void main(String[] args) {
    System.out.println(ConstantValueTest.value);
}

答案也是顯而易見,因為我們之前也有提過,在之前的階段已經將常量儲存在常量池中,所以並不會初始化類本身。

六、終於寫完了

類載入的主要流程大體上是這樣的,雖然還是沒有做到非常詳細,如果需要更加深入瞭解的同學們可以通過去讀一些JVM方面的書籍獲取更多的資訊。

類載入器是Java語言的非常重要的基石,它的提前編譯的策略會增加計算機的開銷,但卻為Java應用提高了擴充套件性和靈活性,Java天生可以動態擴充套件的語言特性就是一類執行期動態載入和動態連結這個特性實現的。

有需要的同學可以加我的公眾號,以後的最新的文章第一時間都在裡面,也可以找我要思維導圖

相關文章