Java虛擬機器類載入的過程

最好的安排發表於2017-09-02

1. 類載入的時機:

  • 類從被載入到虛擬機器記憶體開始到解除安裝出記憶體,整個生命週期包括以下七個階段,其中載入,驗證,準備,初始化,解除安裝這5個階段的順序是確定的。
    類生命週期

  • 類在什麼情況下進行載入: 虛擬機器對類的載入時機並沒有明確的規定,是由具體的虛擬機器實現的,當時明確規定了,在以下5種情況下(有且只有),類必須進行初始化(載入、驗證、準備必須在初始化之前)。這5種方式也被稱為主動引用,除此之外的其他引用都不會觸發初始化,稱為被動引用。

    • 遇到new(使用new關鍵字建立例項物件的時候)、getstatic(讀取一個靜態變數,被final修飾,已在編譯把結果放入常量池的靜態變數除外)、putstatic(設定一個靜態變數,被final修飾,已在編譯把結果放入常量池的靜態變數除外)或invokestatic(呼叫一個類的靜態方法)這四條位元組碼指令的時候,如果類沒有進行初始化,則需要先觸發其初始化。
    • 使用java.lang.reflect方法對類進行反射呼叫的時候,如果類沒有進行初始化,則需要先觸發器初始化
    • 初始化一個類時,發現其父類沒有進行初始化,則需要先觸發父類的初始化。
    • 虛擬機器啟動的時候,使用者需要指定一個執行的主類(包含main方法的類),虛擬機器會先對初始化這個主類
    • 當使用JDK1.7的動態語言支援時,如果一個java.lang.invoke.MethodHandle例項最後的解析結果REF_getStatic、REF_putStatic、REF_invokeStatic的方法的控制程式碼,並且這個方法控制程式碼所對應的類沒有進行過初始化,則需要先觸發其初始化。(使用場景未知)

2. 類載入的過程:

2.1 載入:

  • 載入過程中虛擬機器需要完成的幾件事:
    • 通過一個類的全限定名類獲取定義此類的二進位制位元組流–可從多種渠道獲取(jar,war,網路等)
    • 將這個位元組流所代表的靜態儲存結構轉換為方法區的執行時資料結構
    • 在記憶體中生成一個代表這個類的java.lang.Class物件,作為這個方法區這個類的各種資料的訪問入口(HotSpot虛擬機器將這個物件儲存在方法區中,該物件將作為程式訪問方法區中這些資料型別的外部介面)

2.2 驗證:確保Class檔案中的位元組流中包含的資訊符合當前虛擬機器的要求,並且不會危害虛擬機器自身的安全。

  • 驗證階段大致會完成以下4個階段的檢驗動作:
    • 檔案格式驗證:位元組流是否符合Class檔案格式的規範,並且能被當前版本的虛擬機器處理。
      • 包括魔數校驗:是否以0xCAFEBABE開頭
      • 主次版本號是否在當前虛擬機器的處理範圍內
      • 。。。。
    • 後設資料驗證:對位元組碼描述的資訊進行語義分析,保證其描述的資訊符合java語言規範的要求。
      • 這個類是否有父類
      • 這個類的弗雷是否繼承了不允許被繼承的類(被final修飾的類)
      • 如果這個類不是抽象類,是否實現了其父類或介面之中要求實現的所有方法
      • 類中的欄位,方法是否和父類產生矛盾(如覆蓋率弗雷的final欄位,或者出現不符合規則的方法的過載,例如方法引數都一致,但返回值型別卻不相同等)
    • 位元組碼驗證:通過資料流和控制流分析,確定程式的語義是否是合法的,符合邏輯的
      • 保證跳轉指令不會跳轉到方法體之外的位元組碼指令上
      • 保證方法體中的型別轉換是有效的,例如可以把子類物件賦值給父類資料型別,這是安全的,但是不能吧父類物件賦值給子類資料型別,甚至是毫不相關的型別
      • 保證任意時刻運算元棧的資料型別和指令碼序列都能配合工作。
    • 符號引用驗證:對類自身以外(常量池中的各種符號引用)的資訊進行匹配性校驗, 這一階段的校驗發生在虛擬機器將符號引用轉換為直接引用的時候,這個動作發生在連線的第三階段(解析階段)中發生。
      • 符號引用中通過字串描述的全限定名是否能找到相應的類
      • 在指定的類中是否存在符合方法的欄位描述符以及簡單名稱所描述的方法和欄位
      • 符號引用中的類、欄位、方法的訪問性(private、protected、public、default)是否可被當前類訪問

2.3 準備:正式為類變數分配記憶體並設定類變數初始值的階段,這些變數所使用的記憶體都將在方法區中進行分配

  • 這個時候進行記憶體分配的只包括類變數(被static修飾的變數),並不包括例項變數,例項變數是在物件例項化時隨物件一起分配在java堆中。
  • 分配的初始值為零值,假設一個變數定義為:public static int value = 123;則設定變數的初始值應該為0, 而不是123. 把value賦值為123的putstatic指令是在程式被編譯後,存放於類構造器<clinit>()方法之中,所以吧value賦值為123的動作將在初始化階段才會執行。
  • 如果欄位屬性表中存在ConstantValue屬性,那麼在準備階段會將value賦值為ConstantValue屬性所指定的值
    • 例如:public static final int value = 123; 那麼在準備階段,則會將value賦值為123;

2.4 解析 :將符號引用轉換成直接引用的過程

  • 符號引用和直接引用的定義:

    • 符號應用:符號引用是一組以符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用是能無歧義的定位到目標即可。符號引用與虛擬機器實現的記憶體佈局無關,引用的目標並不一定已經載入到記憶體中。各種虛擬機器實現的記憶體佈局可以各不相同,但是它們能接受的符號引用必須都是一致的,因為符號引用的字面量形式明確定義在java虛擬機器規範的Class檔案格式中。符號引用在Class檔案中以CONSTANT_Class_info(類或介面的符號引用)、CONSTANT_Fieldref_info(欄位的符號引用)、CONSTANT_Methodref_info(方法的符號引用)等型別的常量出現。

      符號引用主要包括以下三類常量:

      • 類和介面的全限定名:java.lang.String的全限定名為:java/lang/String
      • 欄位的名稱和描述符:java.lang.String[][]二維陣列的描述符為:[[Ljava.lang.String
      • 方法的名稱和描述符:java.lang.String.toString()方法的描述符為:()Ljava.lang.String
    • 直接引用:直接引用可以是直接指向目標的指標、相對偏移量或是一個能間接定位到目標的控制程式碼。直接引用是和虛擬機器實現的記憶體佈局相關的,同一個符號引用在不同虛擬機器例項上翻譯出來的直接引用一般不會相同。如果有了直接應用,那引用的目標必定已經在記憶體中存在
  • 解析階段的發生時間:

    • 虛擬機器規範中並沒有規定解析階段發生的具體時間,值要求類在執行:anewarray、checkcast、getfield、getstatic、instanceof、invokedynamic、invokeinterface、invokespecial、invokestatic、invokevirtual、ldc、ldc_w、multianewarray、new、putfield、putstatic這16個用於操作符號引用的位元組碼指令之前,先對它們所使用的符號引用進行解析。
  • 解析針對的符號引用型別:
    • 解析動作主要針對:類或介面(CONSTANT_Class_info)、欄位(CONSTANT_Fieldref_info)、類方法(CONSTANT_Methodref_info)、介面方法(CONSTANT_InterfaceMethodref_info)、方法型別(CONSTANT_MethodType_info)、方法控制程式碼(CONSTANT_MethodHandle_info)和呼叫點限定符(CONSTANT_InvokeDynamic_info)7類符號引用進行。(CONSTANT_String_info也需要解析過程)
  • 解析符號引用過程分析:

    • 類或介面的解析(CONSTANT_Class_info)
      • 假設當前程式碼所處的類為D,如果要吧一個未解析過的符號引用N解析為一個類或介面C的符號引用,虛擬機器需要完成以下3個步驟:
        1. 如果C不是一個數字型別,那虛擬機器將會吧代表N的全限定名傳遞個D的類載入器去載入這個類C。
        2. 如果C是一個陣列型別?
        3. 如果載入沒有異常,那麼C在虛擬機器中實際已經成為一個有效的類或介面了,但在解析完成之前還需要進行符號引用的校驗,確認D是否具備對C的訪問許可權。如果不具備會丟擲java.lang.IllegalAccessError異常。
    • 欄位解析:解析一個未被解析過的欄位符號引用,首先會對欄位所屬的類或介面的符號引用進行解析。如果解析成功,則將這個欄位所屬的類或介面用C表示,虛擬機器規範要求安裝如下步驟對C進行後續欄位的搜尋:

      • 如果C本身就寶航了簡單名稱和欄位描述符都與目標相匹配的欄位,則返回這個欄位的直接引用,查詢結束。
      • 否則,如果在C中實現了介面,將會按照繼承關係從下往上遞迴搜尋各個介面和他的父介面,如果介面中包含了簡單名稱和欄位描述符都與目標相匹配的欄位,則返回這個欄位的直接引用,查詢結束。
      • 否則:如果C不是java.lang.Object的話,將會按照繼承關係從下往上遞迴搜尋其弗雷,如果在父類中包含了簡單名稱和欄位描述符都與目標相匹配的欄位,則返回這個欄位的位元組引用,查詢結束。
      • 否則查詢失敗,丟擲java.lang.NoSushFieldError異常。

      如果查詢過程中返回了引用,則需要對這個欄位進行許可權校驗,如果發現不具備對欄位的訪問許可權,將丟擲java.lang.IllegalAccessError異常。

    • 類方法解析:首先會對方法所屬的類或介面的符號引用進行解析。如果解析成功,則將這個方法所屬的類或介面用C表示,虛擬機器規範要求安裝如下步驟對C進行後續的類方法搜尋:

      • 類方法和介面方法符號引用的常量型別定義是分開的,如果在類方法表中發現class_index中索引的C是一個介面,那就直接丟擲:java.lang.IncompatibleClassChangeError異常
      • 如果通過了第一步,在類C中查詢是否有簡單名稱和描述符都與目標相匹配的方法,如果有這返回這個方法的直接引用,查詢結束。
      • 否則,在類C的父類中遞迴查詢是否有簡單名稱和描述符都與目標相匹配的方法,如果有,則返回這個方法的直接引用,查詢結束。
      • 否則,在類C實現的介面列表及它們的弗雷介面之中遞迴查詢是否有簡單名稱和描述符都與目標相匹配的方法,如果存在匹配法方法,說明類C是一個抽象類,這時查詢結束,丟擲java.lang.AbstractMethodError異常。
      • 否則,宣告方法查詢失敗,丟擲java.lang.NoSushMethodError

      最後,如果查詢過程成功放回了直接引用,將會對這個方法進行許可權驗證,如果發現不具備對此方法的訪問許可權,將丟擲java.lang.IllegalAccessError異常

    • 介面方法的解析:介面方法也需要先解析出介面方法表的class_index項中索引的方法所屬的類或介面的符號引用。如果解析成功,則將這個方法所屬的類或介面用C表示,虛擬機器規範要求安裝如下步驟對C進行後續的類方法搜尋:

      • 與類方法的解析不同,如果在介面方法中發現class_index中的索引C是個類而不是介面,那就直接丟擲java.lang.IncompatibleClassChangeError異常。
      • 否則,在介面C中查詢是否有簡單名稱和描述符都與目標相陪陪的方法,如果有則返回這個方法的直接引用,查詢結束。
      • 否則,在介面C的父介面中遞迴查詢,直到java.lang.Object類(查詢範圍會包含Object類)為止,看是否有簡單名稱和描述符都與目標相匹配的方法,如果有則返回這個方法的直接引用,查詢結束。
      • 否則,宣告查詢失敗,丟擲java.lang.NoSushMethodError異常。

      介面中的所有方法預設都是public的,所以不存在訪問許可權問題,因此介面方法的符號解析應當不會出現java.lang.IllegalAccessError異常

2.5 初始化:初始化階段才真正開始執行類中定義的java程式程式碼(位元組碼)

  • 在準備階段,變數已經賦過一次系統要求的初始值,而在初始化階段,則根據程式猿通過程式指定的主觀計劃去初始化類變數和其他資源,或者可以從另外一個角度來表達:初始化階段是執行類構造器<clinit>()方法的過程。

    <clinit>()方法解析:

    • <clinit>方法是有編譯器自動收集類中的所有類變數的賦值動作和靜態語句塊(static{}塊)中的語句合併產生的,編譯器手機的順序是由語句在原始檔中出現的順序決定的,靜態語句塊中只能訪問到定義在靜態語句塊之前的變數,定義在他之後的變數,在前面的靜態語句塊可以賦值,但不能訪問:

      public class Test
      {
          static
          {
              i = 0;//給變數賦值可以正常通過
              System.out.println(i);//這句話會編譯提示”非法向前訪問“
          }
      }
      
    • <clinit>方法與類的構造器(或者說例項構造器<init>()方法)不同,它不需要顯示地呼叫父類構造器,虛擬機器會保證在子類的<clinit>()方法執行前,父類的<clinit>()方法已經執行完畢,因此在虛擬機器中的一個被執行的<clinit>()方法的類肯定是java.lang.Object。

    • 由於父類的<clinit>()方法先執行,也就意味著父類中定義的靜態語句塊要優先於子類的變數的操作。
    • <clinit>()方法對於類或介面來說並不是必需的,如果一個類中沒有靜態語句塊,也沒有對變數的賦值操作,那麼編譯器可以不為這個類生成<clinit>()方法。
    • 介面中不能使用靜態語句塊,但仍然有變數初始化的賦值操作,因此介面與類一樣都會生成<clinit>()方法,但介面與類不同的是,執行介面的<clinit>()方法不需要先執行父介面的<clinit>()方法。只有當父介面中定義的變數使用時,父介面才會初始化。另外,介面的實現類在初始化時也一樣不會執行介面的<clinit>()方法。
    • 虛擬機器會保證一個類的<clinit>()方法在多執行緒環境中被正確的加鎖、同步,如果多個執行緒同時去初始化一個類,那麼只會有一個執行緒去執行這個類的<clinit>()方法,其他執行緒都需要阻塞等待,知道活動執行緒執行<clinit>()方法完畢。如果在一個類的<clinit>()方法中有耗時很長的操作,就可能會造成多個程式阻塞,在實際應用中這種阻塞往往是很隱蔽的。

相關文章