Java 虛擬機器類載入機制

NO IMAGE

原文地址

虛擬機器把描述類的資料從Class檔案載入到記憶體,並對資料進行校驗,轉換解析和初始化,最終形成可以被虛擬機器直接使用的Java型別,This
is the class loading mechanism of the virtual machine

本文基於HotSpot虛擬機器

類載入

類從被載入到虛擬機器記憶體開始,到解除安裝出記憶體為止,整個過程包括載入(Loading)、驗證(Verification)、準備(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和解除安裝(Unloading)7個階段。其中驗證準備解析3部分統稱為連線

其中類載入的過程包括了載入、驗證、準備、解析、初始化五個階段。在這五個階段中,載入、驗證、準備和初始化這四個階段發生的順序是確定的,而解析階段則不一定,它在某些情況下可以在初始化階段之後開始,這是為了支援Java語言的執行時繫結(也成為動態繫結或晚期繫結)。另外注意這裡的幾個階段是按順序開始,而不是按順序進行或完成,因為這些階段通常都是互相交叉地混合進行的,通常在一個階段執行的過程中呼叫或啟用另一個階段。

關於靜態繫結和動態繫結:

靜態繫結(前期繫結)是指:在程式執行前就已經知道方法是屬於那個類的,在編譯的時候就可以連線到類的中,定位到這個方法。

在Java中,final、private、static修飾的方法以及建構函式都是靜態繫結的,不需程式執行,不需具體的例項物件就可以知道這個方法的具體內容。

動態繫結(後期繫結)是指:在程式執行過程中,根據具體的例項物件才能具體確定是哪個方法。

動態繫結是多型性得以實現的重要因素,它通過方法表來實現:每個類被載入到虛擬機器時,在方法區儲存後設資料,其中,包括一個叫做 方法表(method table)的東西,表中記錄了這個類定義的方法的指標,每個表項指向一個具體的方法程式碼。如果這個類重寫了父類中的某個方法,則對應表項指向新的程式碼實現處。從父類繼承來的方法位於子類定義的方法的前面。

類載入的過程

載入

載入是“類載入”過程的一個階段,這個階段需要完成以下3件事情:

  • 通過一個類的全限定名來獲取定義此類的二進位制位元組流
  • 將這個位元組流所代表的靜態儲儲存結構轉化為方法區的執行時資料結構 (將類資訊、靜態變數、位元組碼、常量這些.class檔案中的內容放入方法區中)
  • 在記憶體中生成一個代表這個類的java.lang.Class物件,作為方法區這個類的各種資料的訪問入口

關於獲取類的二進位制位元組流的方法,虛擬機器並沒有指明要從哪裡獲取,如何獲取。

在Java的發展歷程中,主要出現了以下幾種方法

  1. 從ZIP包中讀取
  2. 從網路獲取(例如:Applet)
  3. 執行時計算生成(例如:動態代理)
  4. 有其他檔案生成(例如:JSP應用)
  5. 從資料庫中讀取

相對於類載入的其他階段而言,載入階段(準確地說,是載入階段獲取類的二進位制位元組流的動作)是可控性最強的階段,因為開發人員既可以使用系統提供的類載入器來完成載入,也可以自定義自己的類載入器來完成載入。

載入階段完成後,虛擬機器外部的二進位制位元組流就按照虛擬機器所需的格式儲存在方法區之中,而且在Java堆中也建立一個java.lang.Class類的物件,這樣便可以通過該物件訪問方法區中的這些資料。

驗證

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

危險因素的來源:

Java語言本身是安全的,但是由於Class檔案並不一定由Java原始碼編譯而來。所以很可能會載入有害的位元組流而導致系統崩潰。

不同的虛擬機器對類驗證的實現可能會有所不同,但大致都會完成以下四個階段的驗證:檔案格式的驗證後設資料的驗證位元組碼驗證符號引用驗證

檔案格式驗證

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

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

魔數的概念:很多型別的檔案,其起始的幾個位元組的內容是固定的(或是有意填充,或是本就如此)。根據這幾個位元組的內容就可以確定檔案型別,因此這幾個位元組的內容被稱為魔數 (magic number)。

class檔案魔數CAFEBABE的由來

這個階段的驗證是基於二進位制位元組流進行的,之後的3個驗證階段全部基於方法區的儲存結構進行的,不會在直接操作位元組流。

後設資料驗證

對位元組碼描述的資訊進行語義分析(其實就是對類中的各資料型別進行語法校驗),以保證其描述的資訊符合Java語言的規範要求。

  • 這個類是否有父類
  • 這個類的父類是否繼承了不允許被繼承的類
  • 如果這個類不是抽象類,是否實現了其父類或介面之中要求實現的所有方法。
  • 類中的欄位,方法是否與父類長生矛盾
  • 。。。

位元組碼驗證

該階段驗證的主要工作是進行資料流和控制流分析,對類的方法體進行校驗分析,以保證被校驗的類的方法在執行時不會做出危害虛擬機器安全的行為。

符號引用驗證

這是最後一個階段的驗證,它發生在虛擬機器將符號引用轉化為直接引用的時候(解析階段中發生該轉化,後面會有講解),主要是對類自身以外的資訊(常量池中的各種符號引用)進行匹配性的校驗。通常要校驗以下內容:

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

準備

準備階段是正式為類變數分配記憶體並設定類變數初始值的階段,這些變數所使用的記憶體都將在方法區中進行分配。這時候進行記憶體分配的僅包括類變數(被static修飾的變數),而不包括例項變數,例項變數將會在物件例項化時隨著物件一起分配在堆中。其次,這裡所說的初始值“通常情況”下是資料型別的零值

假設一個類變數的定義為:

public static int value = 10;

那麼變數value在準備階段過後的初始值為0,而不是10,因為這時候尚未開始執行任何Java方法,而把value賦值為3的putstatic指令是在程式編譯後,存放於類構造器<clinit>()方法之中的,所以把value賦值為10的動作將在初始化階段才會執行。

基本資料型別的零值如下:

這一階段還需要注意如下幾點:

  • 對基本資料型別來說,對於類變數(static)和全域性變數,如果不顯式地對其賦值而直接使用,則系統會為其賦予預設的零值,而對於區域性變數來說,在使用前必須顯式地為其賦值,否則編譯時不通過。
  • 對於同時被static和final修飾的常量,必須在宣告的時候就為其顯式地賦值,否則編譯時不通過;而只被final修飾的常量則既可以在宣告時顯式地為其賦值,也可以在類初始化時顯式地為其賦值,總之,在使用前必須為其顯式地賦值,系統不會為其賦予預設零值。
  • 對於引用資料型別reference來說,如陣列引用、物件引用等,如果沒有對其進行顯式地賦值而直接使用,系統都會為其賦予預設的零值,即null。
  • 如果在陣列初始化時沒有對陣列中的各元素賦值,那麼其中的元素將根據對應的資料型別而被賦予預設的零值。

如果類欄位的欄位屬性表中存在ConstantValue屬性,即同時被final和static修飾,那麼在準備階段變數value就會被初始化為ConstValue屬性所指定的值。

假設上面的類變數value被定義為:

public static final int value = 10;

編譯時Javac將會為value生成ConstantValue屬性,在準備階段虛擬機器就會根據ConstantValue的設定將value賦值為10。

static final常量在編譯期就將其結果放入了呼叫它的類的常量池中。

例如:

public class Test {
public static int value = 10;
public Test() {
System.out.println("This is Test Class");
}
}
public class Main {
public static void main(String[] args) {
System.out.println(Test.value);
}
}

以上程式碼只會列印10,不會列印This is Test Class

解析

解析階段是虛擬機器將常量池中的符號引用轉化為直接引用的過程。

  • 符號引用,以一組符號來描述所引用的目標,符號可以是任意形式的字面量,只要使用時可以無歧義的定位到目標即可。與虛擬機器的記憶體佈局無關,引用的目標不一定已經載入到記憶體中
  • 直接引用,可以是直接指向目標的指標,相對偏移量或是一個能間接定位到目標的控制代碼。與虛擬機器的記憶體佈局有關,同一個符號引用在不同虛擬機器例項上翻譯出來的直接引用一般不相同。如果有直接引用,那麼引用的目標必定在記憶體中存在。

對同一個符號引用進行多次解析請求時很常見的事情,虛擬機器實現可能會對第一次解析的結果進行快取(在執行時常量池中記錄直接引用,並把常量標示為已解析狀態),從而避免解析動作重複進行。

解析動作主要針對類或介面、欄位、類方法、介面方法、方法型別、方法控制代碼和呼叫點限定符7類符號引用進行。

類和介面的解析

判斷所要轉化成的直接引用是對陣列型別,還是普通的物件型別的引用,從而進行不同的解析。

欄位解析

對欄位進行解析時,會先在本類中查詢是否包含有簡單名稱和欄位描述符都與目標相匹配的欄位,如果有,則查詢結束;如果沒有,則會按照繼承關係從上往下遞迴搜尋該類所實現的各個介面和它們的父介面,還沒有,則按照繼承關係從上往下遞迴搜尋其父類,直至查詢結束。

class Super{
public static int m = 11;
static{
System.out.println("執行了super類靜態語句塊");
}
}
class Father extends Super{
public static int m = 33;
static{
System.out.println("執行了父類靜態語句塊");
}
}
class Child extends Father{
static{
System.out.println("執行了子類靜態語句塊");
}
}
public class StaticTest{
public static void main(String[] args){
System.out.println(Child.m);
}
}
執行結果如下:
執行了super類靜態語句塊
執行了父類靜態語句塊
33
如果註釋掉Father類中對m定義的那一行,則輸出結果如下:
執行了super類靜態語句塊
11

static變數發生在靜態解析階段,也即是初始化之前,此時已經將欄位的符號引用轉化為了記憶體引用,也便將它與對應的類關聯在了一起,由於在子類中沒有查詢到與m相匹配的欄位,那麼m便不會與子類關聯在一起,因此並不會觸發子類的初始化。

理論上是按照上述順序進行搜尋解析,但在實際應用中,虛擬機器的編譯器實現可能要比上述規範要求的更嚴格一些。如果有一個同名欄位同時出現在該類的介面和父類中,或同時在自己或父類的介面中出現,編譯器可能會拒絕編譯。

類方法解析:

對類方法的解析與對欄位解析的搜尋步驟差不多,只是多了判斷該方法所處的是類還是介面的步驟,而且對類方法的匹配搜尋,是先搜尋父類,再搜尋介面。

介面方法解析:

與類方法解析步驟類似,知識介面不會有父類,因此,只遞迴向上搜尋父介面就行了。

初始化

初始化階段是類載入過程的最後一步,初始化階段是真正執行類中定義的Java程式程式碼(或者說是位元組碼)的過程。初始化過程是一個執行類構造器<clinit>()方法的過程,根據程式設計師通過程式制定的主觀計劃去初始化類變數和其它資源。把這句話說白一點,其實初始化階段做的事就是給static變數賦予使用者指定的值以及執行靜態程式碼塊。

Java虛擬機器規範嚴格規定了有且只有5種場景必須立即對類進行初始化:

  1. 使用new關鍵字例項化物件、讀取或者設定一個類的靜態欄位(被final修飾的靜態欄位除外)、呼叫一個類的靜態方法的時候
  2. 使用java.lang.reflect包中的方法對類進行反射呼叫的時候
  3. 初始化一個類,發現其父類還沒有初始化過的時候
  4. 虛擬機器啟動的時候,虛擬機器會先初始化使用者指定的包含main()方法的那個類
  5. 當使用jdk1.7動態語言支援時,如果一個java.lang.invoke.MethodHandle例項最後的解析結果REF_getstatic,REF_putstatic,REF_invokeStatic的方法控制代碼,並且這個方法控制代碼所對應的類沒有進行初始化,則需要先出觸發其初始化。(沒理解是什麼意思)
class Father{
public static int a = 1;
static{
a = 2;
}
}
class Child extends Father{
public static int b = a;
}
public class ClinitTest{
public static void main(String[] args){
System.out.println(Child.b);
}
}

執行上面的程式碼,會列印出2,也就是說b的值被賦為了2。

我們來看得到該結果的步驟。首先在準備階段為類變數分配記憶體並設定類變數初始值,這樣A和B均被賦值為預設值0,而後再在呼叫<clinit>()方法時給他們賦予程式中指定的值。當我們呼叫Child.b時,觸發Child的<clinit>()方法,根據規則2,在此之前,要先執行完其父類Father的<clinit>()方法,又根據規則1,在執行<clinit>()方法時,需要按static語句或static變數賦值操作等在程式碼中出現的順序來執行相關的static語句,因此當觸發執行Father的<clinit>()方法時,會先將a賦值為1,再執行static語句塊中語句,將a賦值為2,而後再執行Child類的<clinit>()方法,這樣便會將b的賦值為2.

如果我們顛倒一下Father類中“public static int a = 1;”語句和“static語句塊”的順序,程式執行後,則會列印出1。很明顯是根據規則1,執行Father的<clinit>()方法時,根據順序先執行了static語句塊中的內容,後執行了“public static int a = 1;”語句。

另外,在顛倒二者的順序之後,如果在static語句塊中對a進行訪問(比如將a賦給某個變數),在編譯時將會報錯,因為根據規則1,它只能對a進行賦值,而不能訪問。

類載入器

類載入器雖然只用於實現類的載入動作,但它在Java程式中起到的作用卻遠遠不限於類的載入階段。對於任意一個類,都需要由它的類載入器和這個類本身一同確定其在就Java虛擬機器中的唯一性,也就是說,即使兩個類來源於同一個Class檔案,只要載入它們的類載入器不同,那這兩個類就必定不相等。這裡的“相等”包括了代表類的Class物件的equals()、isAssignableFrom()、isInstance()等方法的返回結果,也包括了使用instanceof關鍵字對物件所屬關係的判定結果。

從Java虛擬機器的角度來講,只存在兩種不同的類載入器:

  • 啟動類載入器:它使用C 實現(這裡僅限於Hotspot,也就是JDK1.5之後預設的虛擬機器,有很多其他的虛擬機器是用Java語言實現的),是虛擬機器自身的一部分。
  • 所有其他的類載入器:這些類載入器都由Java語言實現,獨立於虛擬機器之外,並且全部繼承自抽象類java.lang.ClassLoader,這些類載入器需要由啟動類載入器載入到記憶體中之後才能去載入其他的類。

從Java開發人員的角度來看,類載入器可以大致劃分為以下三類:

  • 啟動類載入器:Bootstrap ClassLoader,跟上面相同。它負責載入存放在JDKjrelib(JDK代表JDK的安裝目錄,下同)下,或被-Xbootclasspath引數指定的路徑中的,並且能被虛擬機器識別的類庫(如rt.jar,所有的java.*開頭的類均被Bootstrap ClassLoader載入)。啟動類載入器是無法被Java程式直接引用的。
  • 擴充套件類載入器:Extension ClassLoader,該載入器由sun.misc.Launcher$ExtClassLoader實現,它負責載入JDKjrelibext目錄中,或者由java.ext.dirs系統變數指定的路徑中的所有類庫(如javax.*開頭的類),開發者可以直接使用擴充套件類載入器。
  • 應用程式類載入器:Application ClassLoader,該類載入器由sun.misc.Launcher$AppClassLoader來實現,它負責載入使用者類路徑(ClassPath)所指定的類,開發者可以直接使用該類載入器,如果應用程式中沒有自定義過自己的類載入器,一般情況下這個就是程式中預設的類載入器。

應用程式都是由這三種類載入器互相配合進行載入的,如果有必要,我們還可以加入自定義的類載入器。因為JVM自帶的ClassLoader只是懂得從本地檔案系統載入標準的java class檔案,因此如果編寫了自己的ClassLoader,便可以做到如下幾點:

  1. 在執行非置信程式碼之前,自動驗證數字簽名。
  2. 動態地建立符合使用者特定需要的定製化構建類。
  3. 從特定的場所取得java class,例如資料庫中和網路中。

事實上當使用Applet的時候,就用到了特定的ClassLoader,因為這時需要從網路上載入java class,並且要檢查相關的安全資訊,應用伺服器也大都使用了自定義的ClassLoader技術。

如上圖展示的類載入之間的這種層次關係,稱為類載入器的雙親委派模型 我們把每一層上面的類載入器叫做當前層類載入器的父載入器,當然,它們之間的父子關係並不是通過繼承關係來實現的,而是使用組合關係來複用父載入器中的程式碼。該模型在JDK1.2期間被引入並廣泛應用於之後幾乎所有的Java程式中,但它並不是一個強制性的約束模型,而是Java設計者們推薦給開發者的一種類的載入器實現方式。

雙親委派模型的工作流程是:如果一個類載入器收到了類載入的請求,它首先不會自己去嘗試載入這個類,而是把請求委託給父載入器去完成,依次向上,因此,所有的類載入請求最終都應該被傳遞到頂層的啟動類載入器中,只有當父載入器在它的搜尋範圍中沒有找到所需的類時,即無法完成該載入,子載入器才會嘗試自己去載入該類。

使用雙親委派模型來組織類載入器之間的關係,有一個很明顯的好處,就是Java類隨著它的類載入器(說白了,就是它所在的目錄)一起具備了一種帶有優先順序的層次關係,這對於保證Java程式的穩定運作很重要。例如,類java.lang.Object類存放在JDKjrelib下的rt.jar之中,因此無論是哪個類載入器要載入此類,最終都會委派給啟動類載入器進行載入,這邊保證了Object類在程式中的各種類載入器中都是同一個類。