JVM 類載入機制

panhaos發表於2017-11-29

前言

在java檔案被編譯成class檔案儲存為二進位制位元組碼後,並不能直接使用,經過類載入,一個類才可以被裝載進執行時記憶體並被使用。因此理解類載入機制才能讓我們更深刻地理解我們編寫的java程式碼是如何一步一步的編譯成class檔案,到如何在記憶體中正確的使用的過程。複製程式碼

類載入的時機

這裡寫圖片描述
這裡寫圖片描述

類從被載入到虛擬機器記憶體開始,到解除安裝出記憶體為止,它的生命週期如上圖。其中,驗證、準備和解析3個部分統稱為連線。

載入、驗證、準備、初始化和解除安裝這5個階段的順序是確定的,而解析則不一定,解析主要目的是將符號引用轉換為直接引用,它某些情況可以在初始化以後才開始,這是為了支援java的執行時繫結。

關於載入什麼時候開始,jvm規範中並沒有明確約束,由不同虛擬機器自己把握,但對於初始化階段,虛擬機器規範嚴格規定有且只有5種情況必須對類進行初始化:

  1. 遇到new、getstaic、putstatic或invokestatic這4條位元組碼指令時,如果類沒有進行過初始化,則會觸發初始化。
  2. 使用java.lang.reflect包的方法對類進行反射呼叫的時候,如果類沒有進行過初始化,則會先觸發其初始化。
  3. 當初始化一個類的時候,如果發現其父類還沒進行過初始化,則需要觸發其父類初始化。
  4. 當虛擬機器啟動時,使用者需要指定一個要執行的主類,虛擬機器會先初始化這個主類。
  5. 當使用jdk1.7動態語言支援時,如果一個java.lang.invoke.MethodHandle例項最後的解析結果 REF_getStatic、REF_putStatic、REF_invokeStatic的方法控制程式碼,並且這個方法控制程式碼所對應的類沒有進行過初始化,則會先觸發其初始化。

類載入的過程

載入

載入階段,虛擬機器需要完成3件事:

  1. 通過一個類的全限定名獲取定義此類的二進位制位元組流。
  2. 將這個位元組流所代表的靜態儲存結構轉換為方法區的執行時資料結構。
  3. 在記憶體中生成一個代表這個類的java.lang.Class物件,作為方法區這個類的各種資料結構的訪問入口。

上面說獲取二進位制位元組流,而沒有明確的說明是class檔案中的位元組流,因為還有其它獲取位元組流的方式,例如從jar包中獲取、從網路中獲取、動態代理執行時生成等。

載入階段與連線階段的部分內容是交叉進行的,如:一部分位元組碼檔案格式驗證動作。載入階段尚未完成,連線階段可能已經開始了。

驗證

驗證是連線階段的第一步,這一階段的目的是為了確保Class檔案的位元組流中包含的資訊符合虛擬機器的要求,並且不會危害虛擬機器自身的安全。驗證階段大致會完成以下4個階段的校驗動作:檔案格式驗證、後設資料驗證、位元組碼驗證和符號引用驗證。

1.檔案格式驗證

這一階段目的是驗證二進位制位元組流是否符合Class檔案格式的規範,並且能被當前版本的虛擬機器處理,檢測內容包括以下幾點:

  • 是否以魔數(0xCAFEBABY)開頭。
  • 主次版本號是否在當前虛擬機器處理範圍之內。
  • 常量池中的常量是否有不支援的常量型別。
  • 指向常量的各種索引值中是否有指向不存在的常量或不符合型別的常量。
  • CONSTANT_Utf8_info 型常量是否有不符合UTF8資料編碼的資料。
  • Class檔案中各個部分及檔案本身是否有被刪除的或附加的資訊。

這個階段是基於二進位制位元組流進行的,只有通過了這個階段的驗證,位元組流才會流入方法區中進行儲存,後面3個階段全是基於方法區的儲存結構進行的,不會再直接操作位元組流。

2.後設資料驗證

這一階段主要對位元組碼的描述資訊進行語義分析,以保證其描述資訊符合java語言規範,這階段的驗證點可能包括以下幾點:

  • 這個類是否有父類
  • 這個類的父類是否繼承了不被允許繼承的類(被final 修飾)
  • 如果這個類不是抽象類,是否實現了父類或介面中的方法
  • 類中的欄位、方法是否與父類產生矛盾(覆蓋父類的final欄位值等)

3.位元組碼驗證

這一階段目的主要目的是確定程式語義是合法的、符合邏輯的。這個階段主要對類的位元組碼進行校驗分析,保證該類的方法不會在執行時做出危害虛擬機器安全的事:

  • 保證任意時刻運算元棧的資料型別與指令程式碼序列都能配合工作,例如不會出險運算元棧上 int 型別的資料使用時按long型別載入進本地變數表中。
  • 保證跳轉指令不會跳轉到方法體以外的位元組碼指令上。
  • 保證方法體內的型別轉化是有效的,可以把一個子類物件賦值給父類資料結構,這是安全的,而不能把父類賦值給子類甚至與它無關係的資料型別,這是危險和不合法的。

4.符號引用驗證

這一階段用來將符號引用轉換為直接引用的時候,這個轉化將在解析階段中發生,符號引用驗證可以看做是類對自身以外(常量池中各種符號引用)的資訊進行匹配性校驗,通常需要校驗以下內容:

  • 符號引用中能否根據字串的許可權定名找到對應的類。
  • 在指定類中是否存在符合方法的欄位描述符以及簡單名稱描述的方法和欄位。
  • 符號引用中的類、欄位、方法的訪問性是否可以被當前類訪問。

準備

準備階段是正式為類變數分配記憶體並設定初始值的階段,這些變數所使用的記憶體都將在方法區分配。例項變數會在物件例項化的時候跟物件一起在java堆中分配。這裡的初始值指的是通常情況下的零值。假設一個類變數定義為:

public static int a=123;

那麼變數a初始化的值是0而不是123。如果變數同時是final型別,那麼準備階段就會被賦值為123,不必等到初始化階段再賦值。

解析

解析階段是將虛擬機器常量池內的符號引用替換為直接引用的過程。解析動作主要針對類或介面、欄位、類方法、介面方法、方法型別、方法控制程式碼和呼叫點限定符7類符號進行。可能大家有疑問Class檔案中哪有這麼多內容,其實上面也說了,是針對常量池。不管是CLass檔案中的方法表還是欄位表,不能直接表示的內容,基本都會直接或間接存在常量池中,因此解析過程就是針對常量池中的資料型別進行解析的。

1.類或介面的解析

要把一個從未解析過的符號引用N解析為一個類或介面的直接引用,虛擬機器需要完成以下3個步驟:

  1. 如果C不是一個陣列型別,那麼虛擬機器會把代表N的許可權定名傳遞給D的類載入器去載入這個類C。在載入的過程當中,由於後設資料、位元組碼驗證的操作,又可能觸發其它類的載入動作,一旦出險任何異常,則解析宣告失敗。
  2. 如果C是一個陣列型別,並且陣列元素為物件,描述符類似“[Ljava/lang/Integer”的形式按照第一點的規則載入陣列元素型別。如果N的描述符如前面所假設的形式,需要載入的型別就是java.lang.Integer,接著由虛擬機器生成一個代表次陣列維度和元素的陣列物件。
  3. 如果上面的步驟沒有任何異常,那麼C在虛擬機器中實際上已經稱為一個有效的類或介面了。解析之前還要進行符號驗證,確認D是否具有對C的訪問許可權,如果不具備則會丟擲異常。

2.欄位解析

對欄位表內class_index項中索引的CONSTANT_Class_info符號引用進行解析,也就是欄位所屬的類或介面的符號引用,如果解析這個類或符號引用的過程中出現任何異常,都會導致欄位符號引用解析的失敗。如果解析成功,這個欄位對應的類或介面用C表示,接下來沿著A和它的父類/父介面尋找是否有這個欄位,如果有會進行許可權驗證,如果不具備許可權則丟擲異常。如果這個過程不出錯,則會在找到符合欄位的時候返回這個欄位的直接飲用,查詢結束。

3.類(靜態)方法解析

類方法解析首先也要首先解析出類方法表class_index項中索引的方法所屬的類或介面的符號引用,解析成功用C表示。

  1. 類方法和介面方法符號引用的常量型別定義是分開的,如果在類方法表中索引類是個介面,直接丟擲異常。
  2. 如果通過了第一步,在類C中查詢是否有簡單名稱和描述符都與目標匹配的方法,有則返回這個方法的直接引用,查詢結束。
  3. 否則在類的父類遞迴查詢是否有這個方法,有則返回直接引用,查詢結束。
  4. 否則在類的介面列表和父介面遞迴查詢,如果存在匹配的方法,說明類C是一個抽象類,查詢結束,丟擲異常。
  5. 否則宣告查詢失敗,丟擲異常。

最後如果查詢成功返回了直接引用,還要對這個方法進行許可權驗證,如果不具備許可權,則會丟擲異常。

介面方法解析

介面方法需要先解析出介面方法表的class_index 項中索引的方法所屬的類或介面的符號引用。

  1. 如果發現class_index 中的索引C是個類而不是介面,直接丟擲異常。
  2. 否則在介面C中查詢是否有描述符和名稱都匹配的方法,有則返回方法的直接引用,查詢結束。
  3. 否則在其父介面中遞迴查詢,匹配就返回方法的直接引用,查詢結束。
  4. 否則宣告方法查詢失敗。

初始化

類初始化是類載入過程的最後一步。前面的類載入過程中,除了載入階段可以自定義類載入器干預之外,其餘動作完全由虛擬機器主導。到了初始化階段,才真正開始執行java程式碼。

我們知道,在前面的準備階段,已經對類變數分配過記憶體並設定初始值。在初始化階段,則是為類變數或其它資源設定程式中宣告的值。注意這裡仍然是類變數,不包括例項變數。或者明確的說,這一階段,是執行static關鍵字修飾的變數或程式碼塊。本質上,初始化是執行類構造器
<client>方法的過程。

<client>方法是由編譯器自動收集類中所有類變數的賦值動作和靜態程式碼塊中的語句合併產生的。編譯器收集的順序是有語句在資原始檔中出險的順序所決定的。

因此平時可能會遇到這種問題:如下程式碼

public class Client {
    private static Client client = new Client();

    public static int a;
    public static int b = 0;

    private Client() {
        a++;
        b++;
    }

    public static Client getInstance() {
        return client;
    }

    public static void main(String[] args) {
        Client instance = Client.getInstance();
        System.out.println("a= " + Client.a);
        System.out.println("b= " + Client.b);
    }

}複製程式碼

輸出結果是

a= 1
b= 0複製程式碼

可能有人問為什麼,其實把類載入的過程邏輯理清楚,也不是問題。我們知道在類載入的準備階段會給類變數分配記憶體和賦初始值。在外部呼叫Client.getInstance()時,因為之前類沒有被載入過,會引發類載入,到了準備階段就會給類變數賦初始值。賦值順序同一個類中是按宣告的順序,也就是

client=null;
a=0;
b=0複製程式碼

然後解析完開始初始化,按程式宣告的值給類變數賦值。首先執行clinet=new Client(),其實關鍵就是這裡new的過程會呼叫建構函式,呼叫完後

a=1;
b=1;複製程式碼

接著繼續初始化,a只是宣告沒有賦值,所以沒有任何操作,b宣告且賦值為0,所以初始化完成後

a=1;
b=0;複製程式碼

相關文章