JVM學習-虛擬機器類載入機制

sayWhat_sayHello發表於2018-12-10

類載入的時機

類載入的生命週期:

載入 ->
連線(包括:驗證 - > 準備 -> 解析)->
初始化 ->
使用 ->
解除安裝

載入、驗證、準備、初始化和解除安裝這5個階段的順序是確定的,而解析可以在初始化階段後進行,這是為了支援JAVA語言的執行時繫結。

應當立即對類或介面進行初始化的情況(java8):又被稱為主動引用:

  1. 遇到new,getstatic,putstatic,invokestatic這4條位元組碼指令時,如果類沒有進行過初始化,則需要先觸發其初始化。
  2. 在初次呼叫java.lang.invoke.MethodHandle例項時,該例項時由Java虛擬機器所解析的種類是2(REF_getStatic),4(REF_putStatic),6(REF_invokeStatic),8(REF_newInvokeSpecial)的方法控制程式碼
  3. 在呼叫類庫中某些反射方法時
  4. 在對類的某個子類進行初始化的時候。
  5. 在它被選定為JVM虛擬機器啟動時的初始類時(例如main())

而被動引用有三種:

  1. 通過子類引用父類的靜態欄位不會導致子類初始化。
  2. 通過陣列定義來引用類不會觸發此類的初始化。
  3. 常量在編譯階段會存入呼叫類的常量池中,本質上沒有直接引用到定義常量的類,因此不會觸發常量的類的初始化。

類載入的過程

loading

載入階段,虛擬機器需要完成以下三件事情:

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

驗證

驗證階段對於虛擬機器的類載入機制來說是非常重要的階段。如果能確保所執行的程式碼都已經被反覆使用和驗證過,在實施階段可以考慮使用-Xverify:none引數來關閉大部分的類驗證措施,以縮短虛擬機器類載入的時間。

檔案格式驗證

該階段的主要目的是保證輸入的位元組流能正確的解析並儲存於方法區之內。經過這個階段的驗證,位元組流進入方法區。
以下是一小小部分驗證內容:

  • 是否以魔術0xCAFEBABE開頭
  • jvm是否支援對應的主次版本號
  • 常量池的常量中是否有不被支援的常量型別

後設資料驗證

對語義進行校驗,確保其描述的資訊符合java語言規範。例如:

  1. 這個類是否有父類(除了Object之外,其他所有的類都有父類)
  2. 這個類的父類是否繼承了final修飾的類

位元組碼驗證

主要工作是進行資料流和控制流分析。例如:

  1. 保證跳轉指令不會跳轉到方法體外的位元組碼指令上。
  2. 保證方法體內的型別轉換是有效的。

需要注意的是,即使有個方法體通過了位元組碼驗證也不一定是安全的。因為無法用程式去檢驗程式的邏輯是否絕對準確。

符號引用驗證

最後一個階段的驗證發生在虛擬機器將符號引用轉化成直接引用的時候,這個轉化會在 解析 階段發生。符號引用可以看做是對類自身以外的資訊進行匹配性的校驗,例如:

  1. 符號引用通過字串描述的全限定名是否能找到對應的類。
  2. 在指定類中是否存在符合方法的欄位描述符和簡單名稱所描述的方法和欄位。

符號引用驗證的目的是確保解析動作能正常執行,如果無法通過符合引用驗證將會丟擲一個java.lang.IncompatiableClassChangeError異常的子類,例如java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError等。

準備

準備階段是正式為類變數分配記憶體並設定類變數初始值的階段,這些記憶體都是在方法區中進行分配的。需要注意的是 是對類變數,即使用static修飾的變數,而不包括例項變數。其次是初始值“通常情況”下分配的是預設值,例如數字型別分配0,引用型別分配null等。只有定義為final型別的變數才會直接分配常量值。

解析

解析階段是虛擬機器將常量池內的符合引用替換成直接引用的過程。符號引用在Class檔案中以CONSTANT_Class_info, CONSTANT_Fieldref_info, CONSTANT_Methodref_info等型別的常量出現。

  • 符號引用:符號引用以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能無歧義的定位到目標即可。引用的目標不一定已經載入到記憶體中。
  • 直接引用:直接引用可以是直接指向目標的指標、相對偏移量或是一個間接定位到目標的控制程式碼。直接引用的目標一定存在記憶體中。

虛擬機器規範規定解析應當發生在執行anewarray,checkcast,getfield,getstatic,instanceof,invokeinterface,invokespecial,invokestatic,invokevirtual,multianewarray,new,putfield,putstatic這13個用於操作符號引用的位元組碼指令之前,先對他們所使用的符號引用進行解析。

解析主要針對類或介面、欄位、類方法、介面方法四類符號引用進行,分別對應於常量池的CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info以及CONSTANT_InterfaceMethodref_info四種常量型別。

注意,結合這個理解比較容易。

CONSTANT_Class_info {
    u1 tag;
    u2 name_index;
}
CONSTANT_Methodref(/Fieldref/InterfaceMethodref)_info {
    u1 tag;
    u2 class_index;
    u2 name_and_type_index;
}

類或介面的解析

假設當前程式碼所處的類為D,如果要把一個從未解析過的符號引用N解析為一個類或介面C的直接引用,那麼虛擬機器要完成以下3個步驟:

  1. 如果C不是一個陣列型別,那虛擬機器將會把代表N的全限定名傳遞給D的類載入器去載入這個類C。如果出現異常,解析過程宣告失敗。
  2. 如果C是一個陣列型別,並且陣列的元素型別為物件,那麼將會按照第一點的規則載入陣列元素型別。接著虛擬機器生成一個代表此陣列維度和元素的陣列物件。
  3. 如果上面的步驟沒有異常,那麼C在虛擬機器中實際上已經成為一個有效的類或介面了,但在解析完成前還要進行符號引用驗證,確認C是否具備對D的訪問許可權,如果不具備,丟擲java.lang.IllegalAccessError異常。

欄位解析

要解析一個未被解析過的欄位符號引用,首先將會對欄位表內的class_index項中索引的CONSTANT_Class_info符號引用進行解析,如果解析失敗拋異常,否則將這個欄位所屬的類或介面用C表示,虛擬機器要求按照如下步驟對C進行後續欄位的搜尋:

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

如果查詢成功返回了引用,再進行許可權驗證,如果不滿足丟擲java.lang.IllegalAccessError異常。

類方法解析

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

如果查詢成功返回了引用,再進行許可權驗證,如果不滿足丟擲java.lang.IllegalAccessError異常。

介面方法解析

  1. 和類解析相反,如果class_index索引的不是介面而是類,拋java.lang.IncompatiableClassChangeError異常。
  2. 否則,找本類。
  3. 否則,找父介面,直到java.lang.Object(包含)為止,看看是有簡單名稱和描述符都和目標相匹配的方法。
  4. 否則,拋java.lang.NoSuchMethodError異常

由於介面中所有的方法都預設是public的,所以不存在許可權問題。

初始化

初始化階段是執行類構造器<clinit>()方法的過程。以下是一些該方法執行過程中可能影響程式執行行為的一些特點和細節:

  • <clinit>()是由編譯期自動收集類中所有類變數的賦值動作和靜態語句塊合併生成的,編譯器收集的順序由原始碼中順序決定。
  • <clinit>()方法和類的建構函式(或者說例項構造器<init>()方法) 不同,它不需要顯式的呼叫父類構造器,虛擬機器保證在子類的<clinit>()方法執行之前,父類的<clinit>()方法已經執行完成。
  • 由於父類的<clinit>()方法先執行,意味著父類中定義的靜態語句塊先於子類的變數賦值操作。
  • <clinit>()不是必需的,如果一個類中沒有靜態語句塊或類變數的賦值操作,那麼編譯器可以不為這個類生成<clinit>()方法。
  • 介面中不能是靜態語句塊,但仍然有變數初始化的賦值操作。但介面和類不同的是,執行介面的<clinit>()方法不需要先執行父介面的<clinit>()方法。只有當父介面中定義的變數被使用時,父介面才會被初始化。另外,介面的實現類在初始化時也一樣不會執行介面的<clinit>()方法。
  • 虛擬機器會保證一個類的<clinit>()方法在多執行緒環境中正確的加鎖和同步,如果多個執行緒同時初始化一個類,那麼只有一個執行緒會執行這個類的<clinit>()方法,其他執行緒都會阻塞等待。

類載入器

類載入階段中的“通過一個類的全限定名獲取此類的二進位制位元組流”這個動作放到java虛擬機器外部去實現,以便讓應用程式自己決定如何去獲取所需要的類。實現這個動作的程式碼模組被稱為 類載入器。

類和類載入器

對於任意一個類,都需要由載入它的類和這個類本身一同確立其在java虛擬機器中的唯一性。

雙親委派模型

系統提供三種類載入器:

  1. 啟動類載入器(Bootstrap ClassLoader):這個類載入器負責將存放在<JAVA_HOME>\lib目錄中的,或者被-Xbootclasspath引數所指定的路徑中,並且是虛擬機器識別的(僅按照檔名識別)類庫載入到虛擬機器記憶體中。啟動類無法被java程式直接引用。
  2. 擴充套件類載入器(Extension ClassLoader):這個載入器由sum.misc.Launcher$ExtClassLoader實現,它負責載入<JAVA_HOME>\lib\ext目錄下的,或者被java.ext.dirs系統變數指定的路徑下的所有類庫。
  3. 應用程式類載入器(Application ClassLoader):這個類載入器由sum.misc.Launcher$AppClassLoader來實現。也被稱為系統類載入器。負責載入使用者路徑(ClassPath)上所指定的類庫。

如果一個類載入器收到了類載入的請求,它首先不會自己嘗試去載入這個類,而是把這個請求委派給父類載入器完成(層層向上),當父載入器無法完成這個載入時,子載入器才會嘗試自己去載入。

破壞雙親委派模型

  1. 執行緒上下文載入器。Thread類下的setContextClassLoader()方法進行設定。可以實現父類請求子類載入器完成類載入器的動作。
  2. 程式碼熱替換。每一個程式模組都有一個自己的類載入器,當需要更換一個程式模組時,就把程式模組和類載入器一起換掉實現程式碼的熱替換。

參考

《深入理解java虛擬機器》

相關文章