虛擬機器類載入機制(深入Jvm讀書筆記二)

weixin_33670713發表於2016-08-13

虛擬機器把Class檔案載入到記憶體,並對資料進行校驗、轉換解析和初始化,最終形成可以被虛擬機器直接使用的Java型別,這就是虛擬機器的類載入機制。
在Java語言裡,型別的載入和連線過程都是在程式執行期間完成的,這樣會在類載入時稍微增加一些效能開銷,但是卻能提供更好的靈活性。

類載入的時機

類從載入到虛擬機器記憶體開始到解除安裝出記憶體為止,其生命週期主要包括:Loading、Verification、Preparation、Resolution、Initialization、Using、Unloading等階段。其中Verification、Preparation、Resolution等三個階段合為Linking階段。
什麼情況下需要開始Loading階段虛擬機器規範中並沒有進行強制約束,但是對於初始化階段,迅即規範則嚴格規定了有且只有四種情況必須立即對類進行初始化(Initialization):

  • 遇到new、getstatic、putstatic或invokestatic這4調位元組碼指令的時候,如果類沒有初始化,則需要先出發其初始化。其對應的java程式碼一般是使用new建立物件,讀取或對靜態變數賦值(常量池靜態變數除外)以及呼叫一個類的靜態方法的時候。
  • 使用java.lang.reflect包的方法對類進行反射呼叫的時候
  • 當初始化一個類的時候需先初始化其父類
  • 虛擬機器啟動的時候會初始化執行主類(含main方法,且是程式入口)

對於這四種場景中外的情況,都屬於被動引用,不會觸發初始化。
對於靜態欄位,其被訪問的時候,只有直接定義這個欄位的類才會被初始化,因此通過其子類來引用父類中定義的靜態欄位,只會觸發父類的初始化,而不會觸發子類的初始化。
通過陣列定義來引用類,不會觸發此類的初始化egSuperClass[] sca = new SuperClass[10];並不會初始化SuperClass
對字串常量的引用會轉化成對自己類檔案中常量池的中常量的引用,因此也不會初始化被引用的類。
介面中不能使用static{}語句塊,但編譯器仍然會為介面生成"<clinit>()'類構造器,用於初始化介面中定義的成員變數。介面初始化時並不要求其父介面全部初始化,只有在真正使用到父介面的時候才進行初始化。

類載入的過程

載入(Loading)

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

  • 通過一個類的許可權定名來獲取定義此類的二進位制位元組流
  • 將這個位元組流所代表的靜態儲存結構轉化為方法去的執行時的資料結構
  • 在Java堆中生成一個代表這個類的Class物件,作為方法去這些資料的訪問入口

載入類的二進位制流,其來源是 不限制的,可以來自檔案、網路、資料庫、執行時生成等。載入完成時,虛擬機器外部的二進位制位元組流就按照虛擬機器所需的格式儲存在方法區之中,方法區中的資料儲存格式由虛擬機器實現自行定義,虛擬機器規範未規定此區域的具體資料結構。然後在Java堆中例項化一個Class類的物件,這個物件將作為俄日程式訪問方法區中的這些型別資料的外部介面。載入階段與連線階段的部分內容(如一部分位元組碼檔案格式驗證動作)是交叉進行的。

校驗(Verification)

校驗是連線階段的第一步,其目的是為了確保Class檔案的位元組流中包含的資訊符合當前虛擬機器的要求,並且不會危害虛擬機器自身的安全。虛擬機器對輸入的位元組流不符合Class檔案的儲存格式就丟擲一個java.lang.VerifyError異常或者其子類。

檔案格式的驗證

這一階段主要驗證位元組流是否符合Class檔案格式規範,並且能被當前版本的虛擬機器處理。可能包括:

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

經過這個階段的驗證後,位元組流才會進入記憶體的方法區中進行儲存,所以後面三個驗證階段全部是基於方法區的儲存結構進行的。

後設資料驗證

第二階段是對位元組碼描述的資訊進行語義分析,以保證資訊符合Java語言規範的要求

  • 這個類是否有父類(除Object之外,所有的類都應當有父類)
  • 這個類是否繼承了不允許被繼承的類
  • 如果這個類不是抽象類,是否實現了其父類或介面中要求實現的全部方法
  • 類中的欄位、方法是否與父類產生了矛盾
  • ...

位元組碼驗證

第三階段是最複雜的一個階段,主要工作是進行資料流和控制流分析。這個類主要對類中方法體進行校驗分析。這個階段的任務是保證被校驗的方法執行時不會做出危害虛擬機器安全的行為:

  • 保證任意時刻運算元棧的資料型別與指令程式碼序列都能配合工作,例如不會出現操作棧上放置一個int型別的資料,使用時卻按long型別來載入入本地變數表中。
  • 保證跳轉指令不會跳轉到方法體外的位元組碼指令上
  • 保證方法體中的型別轉換是有效的。例如把子類賦給父類是合法的反之則非法
  • ...

由於資料流驗證的高複雜性,虛擬機器設計團隊為了避免將過多的時間消耗在位元組碼驗證階段。在JDK1.6之後Javac編譯器中進行了一項優化,給方法體的Code屬性的屬性表中新增了一個“StackMapTable”的屬性,這項屬性描述了方法體重所有的基本塊開始時本地變數表和操作棧應有的狀態,這可以將位元組碼驗證的型別推導轉變為型別檢查從而節省一些時間。

符號引用驗證

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

  • 符號引用中通過字串描述的全限定名是否能找到對應的類
  • 在指定類中是否存在符合方法的欄位描述符及簡單名稱所描述的方法和欄位。
  • 符號引用中的類、欄位和方法的訪問控制許可權是否允許被當前類訪問。

符號引用驗證的目的是保證解析動作能正常執行。
校驗階段對於虛擬機器類載入機制來說是一個非常重要的但非必要的階段,可以通過-Xverify:none引數來關閉大部分類驗證措施,以縮短虛擬機器類載入的時間。

準備(Preparation)階段

準備階段是正式為類變數分配記憶體並設定類初始變數初始值的階段,這些記憶體都將在方法區中進行分配。這個階段記憶體分配只包含類變數而不包括例項變數,例項變數將會在物件例項化時隨著物件一起分配在Java堆中。其次是變數初始值為預設值。例如public static int value = 123準備階段後初始值為0而不是123因為這個時候尚未開始執行任何Java程式碼,而value賦值的指令時編譯後放到<clinit>()方法中的。如果欄位的欄位屬性表中存在ConstantValue屬性,那再準備階段變數value就會被初始化為ConstantValue屬性指定的值,如public static final int value = 123在準備階段就會被直接賦值

解析(Resolution)

解析階段是虛擬機器將常量池中的符號引用替換為直接引用的過程。
符號引用:符號引用以一組符號來描述所引用的目標,符號可以使任何形式的字面量,只要使用時能無歧義地定位到目標即可。符號引用與虛擬機器實現的記憶體佈局無關,引用的目標不一定已經載入到記憶體中。
直接引用:直接引用可以是直接指向目標的指標,相對偏移量或是一個能簡介定位到目標的控制程式碼。直接引用是與虛擬機器實現的記憶體佈局相關的,同一個符號引用在不同虛擬機器實力上翻譯出來的直接引用一般不會相同。如果有了直接引用,那引用的目標必定已經在記憶體中存在了。
關於符號引用和直接引用的區別可參考https://www.zhihu.com/question/30300585
虛擬機器規範中並未規定解析階段發生的具體時間,只要求在執行anewarray、checkcast、個體field
getstatic、instanceof、invokeinterface、invokespecial、invokestatic、invokevirtual、multianewarray、new、putfield和putstatic13個用於操作符號引用的位元組碼指令之前,先對他們所適應的符號引用進行解析。所以虛擬機器實現會根據需要來判斷,到底是在類被載入時就對常量池中的符號引用進行解析還是等到一個符號引用將要使用前才去解析它。解析動作做主要針對類或介面、欄位、類方法、介面方法 四類符號引用進行,分別對應於常量池的CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info及CONSTANT_InterfaceMethodref_info四種常量型別。

類或者介面的解析

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

  • 如果C不是一個陣列型別,那虛擬機器將會把代表N的許可權定名傳遞給D的類載入器去載入C這個類。在載入過程中,由於無資料驗證、位元組碼驗證的需要,又將可能觸發其他相關類的載入動作,例如載入這個類的父類或實現的介面。一旦這個載入過程出現了任何異常,解析過程就將宣告失敗。
  • 如果C是一個陣列型別,並且陣列的元素型別為物件,那將會按照第一條的規則載入陣列元素型別。接著由虛擬機器生成一個代表此陣列維度和元素的陣列物件。
  • 如果上面的步驟沒有出現任何異常,那麼C在虛擬機器中實際上已經成為一個有效的類或者介面了,但在解析完成前還需要進行符號引用驗證,確認D是否具備對C的訪問許可權,如果發現不具備訪問許可權將丟擲 java.lang.IllegalAccessError.

欄位解析

首先根據class_index解析其型別,如果解析完成。先查詢類本身,而後自上而下查詢介面最後自上而下查詢父類。如果查詢失敗丟擲NoSuchFieldError.如果查詢成功,將會對這個欄位進行許可權驗證。

類方法解析

先根據class_index索引解析所屬的類或者介面的符號引用,如果解析成功。按照以下順序對方法進行搜尋:如果在類方法表中發現class_index中索引是個介面,直接丟擲java.lang.IncompatibleClassChangeError.而後在類中查詢,繼續在父類中遞迴查詢,如果沒找到則到此類實現的介面或者父介面中遞迴查詢,如果找到說明此類為抽象類。如果沒查詢到則丟擲NoSuchMethodError。

介面方法解析

與類解析相反,如果在介面方法中發現class_index中的索引是個類而不是介面直接丟擲IncompatibleClassChangeError。而後在介面本身內部查詢,進而在介面的父介面中遞迴查詢,如果都查不到則丟擲NoSuchMethodError異常。

初始化(Initialization)

初始化階段才真正開始執行類中定義的Java程式程式碼
初始化階段是執行類構造器<clinit>()方法的過程。

  • <clinit>()方法是由編譯器自動收集類中的所有類變數的賦值動作和靜態語句塊中的語句合併產生的,編譯器手機的順序是由語句在原始檔中出現的順序所決定的,靜態語句塊中只能訪問到定義在靜態語句塊之前的變數,定義在它之後的變數,在前面的靜態語句塊中可以賦值,但是不能訪問。
  • <clinit>()方法與類的建構函式不同,它不需要顯式地呼叫父類的構造器,虛擬機器會保證在子類的<clinit>()方法執行之前,父類的<clinit>()方法已經執行完畢。
  • 如果一個類中沒有靜態語句塊,也沒有對變數的賦值操作,那麼編譯器可以不為這個類生成<clinit>()方法
  • 介面中不能使用靜態程式碼塊,但仍然有變數初始化的賦值操作,介面的<clinit>()方法呼叫的時候不一定需要呼叫父介面對應的方法。而介面的實現類在初始化時也一樣不會呼叫介面的<clinit>()方法。
  • 虛擬機器會保證一個類的<clinit>()方法在多執行緒環境中被正確地加鎖和同步,如果多個執行緒同時去初始化一個類,那麼只會有一個執行緒回去執行這個類的<clint>()方法,其他執行緒都需要阻塞等待。

類載入器

類載入器最初為了Java Applet來設計,目前在類層次劃分,OSGi,熱部署,程式碼加密等領域應用廣泛。

類與類載入器

對於任一類,都需要由載入它的類載入器和這個類本身一同確立其在java虛擬機器中的唯一性。如果不注意Class的唯一性標識,可能會導致Class物件的equals, isAssignableFrom方法,isInstance等方法返回結果與預期不同。

雙親委派模型

站在Java虛擬機器的角度講,只存在兩種不同的類載入器:一種是啟動類的載入器(Bootstrap ClassLoader)這個類載入器使用C++實現,是虛擬機器自身的一部分。另一種是所有其他類的載入器,這些來載入器都由Java語言實現,獨立於虛擬機器外部,並且全部繼承自java.lang.ClassLoader
絕大部分Java程式會使用到以下三種類載入器

  • Bootstrap ClassLoader:這個類載入器負責將存放在<JAVA_HOME>\lib目錄中或者被-Xbootclasspath引數指定的路徑中的,並且是虛擬機器識別的(僅按照檔名識別)類庫載入到虛擬機器中,此載入器無法被Java程式直接引用。
  • Extension ClassLoader:這個類載入器由sum.misc.Launcher$ExtClassLoader實現,它負責載入<JAVA_HOME>\lib\ext目錄中的,或者被java.ext.dirs系統變數所指定的路徑中的所有類庫。
  • Application ClassLoader:這個載入器由sun.misc.Launcher$AppClassLoader來實現。由於這個類載入器是ClassLoader中的getSystemClassLoader()方法的返回值,所以一般也成為系統類載入器。它負責載入使用者類路徑上指定的類庫,開發者可以使用這個類載入器。一般情況下這個基於是程式中預設的類載入器。

類載入器之間的這種層次關係,成為類載入器的雙親委派模型。雙親委派模型要求除了Bootstrap ClassLoader其餘的類載入器都應當有自己的父類載入器。這些類載入器之間一般不使用繼承而是使用組合來複用類載入器程式碼。一般的做法是隻有父類載入器無法完成載入動作的時候,子類載入器才會真正的去載入某個類。這樣保證了Java繼承體系的正常運作。

相關文章