1. 程式人生 > >類載入機制詳解

類載入機制詳解

之前在介紹JVM記憶體模型的時候(參看:JVM記憶體模型),提到了在執行時資料區之前,有個Class Loader,這個就是類載入器。用以把Class檔案中的描述資訊載入到記憶體中執行和使用。以下是《深入理解Java虛擬機器第二版》對類載入器機制的定義原文:

虛擬機器把描述類的資料從Class檔案載入到記憶體,並對資料進行校驗、轉換解析和初始化,最終形成可以被虛擬機器直接使用的Java型別,這就是虛擬機器的類載入機制。

一般我們把類從載入到記憶體到卸載出記憶體的整個過程分為七個階段:載入,驗證,準備,解析,初始化,使用和解除安裝。其中,驗證、準備和解析統稱為連線。

在這幾個階段中,載入、驗證、準備、初始化和解除安裝這五個階段的順序是固定的,而解析階段則不一定,它有時候可能會在初始化之後開始,這是為了支援Java的執行時繫結。需要特別注意的是,這裡邊的順序指的是按順序開始,而不是按順序進行或完成,因為這些階段通常會互相交叉的混合進行。

瞭解類的載入機制非常有必要,下面將逐個解釋說明類載入的全過程(即載入,驗證,準備,解析,初始化五個階段)。相信看完之後,你會對Java類某些問題有更深刻的理解(例如,為什麼子類可以覆蓋父類的欄位和方法?餓漢式單例為什麼天生是執行緒安全的?)

載入

載入過程分為三步:

1)通過一個類的全限定名來獲取定義此類的二進位制位元組流。

2)將這個位元組流所代表的的靜態儲存結構轉化為方法區的執行時資料結構。

3)在記憶體中生成一個代表這個類的Class物件,作為方法區這個類的各種資料的訪問入口。

上面的第一步獲取二進位制位元組流,並沒有限定只能從編譯好的.class檔案中獲取,也可以是zip包,jar,war,網路流(Applet),執行時計算生成(如動態代理,通過反射在執行時動態生成代理類),其他檔案(如jsp,因jsp最終會編譯成class),資料庫(用的場景較少)。

對於陣列類的載入,和普通類的載入有所不同。陣列類本身不通過類載入器載入,而是由虛擬機器直接完成。但是陣列類的元素型別(指陣列類去除維度之後的型別,如String[] 陣列的元素型別就是 String)是靠類載入器載入的。

載入階段完成之後,虛擬機器就會把外部的二進位制位元組流(不論從何處獲取的)按照一定的資料格式儲存在執行時資料區中的方法區。然後在記憶體中例項化一個java.lang.Class物件(Class這個物件比較特殊,它存放在方法區中而不是堆中),這個物件將作為程式訪問方法區中的這些資料的外部介面。

驗證

驗證是連線階段的第一步,這一階段的主要目的就是確保Class檔案流中的資訊符合虛擬機器的規範,並且不會危害虛擬機器的安全。驗證階段一般分為四個階段:檔案格式驗證,元資料驗證,位元組碼驗證和符號引用驗證。

1)檔案格式驗證

第一階段要驗證二進位制位元組流是否符合Class檔案格式的規範,確保能被虛擬機器處理。主要包括以下驗證點:

  • 是否以魔數 0xCAFEBABE 開頭。(每個Class檔案的頭4個位元組稱為魔數,是一個16進位制的固定值,它的作用就是確保這個Class檔案能被虛擬機器接受)
  • 主、次版本號是否在當前虛擬機器的處理範圍中(緊接著魔數後面的第5,6位元組代表次版本號,第7,8位元組代表主版本號)。
  • 常量池中的常量是否有不被支援的常量型別(依據常量的tag值)。

等等,還有其他很多驗證,不再一一說明。這一階段的驗證主要是針對二進位制位元組流進行的,驗證完成之後,位元組流會進入記憶體中的方法區進行儲存。所以後面的三個驗證階段不再直接操作二進位制位元組流。

2)元資料驗證

第二階段是對位元組碼描述的資訊進行語義分析,保證其描述的資訊符合Java語言規範。主要包括以下驗證點:

  • 這個類是否有父類(除了Object類,所有類都應該有父類)。
  • 這個類是否繼承了不允許被繼承的類(被final修飾的類不可被繼承)。
  • 是否實現了其父類或介面要求實現的所有方法。
  • 類中的欄位、方法是否與父類產生矛盾(如覆蓋了父類的final欄位,或者重寫、過載不符合規範)。

3)位元組碼驗證

第三階段主要是對類的方法體進行驗證,確保程式語義是合法的、符合邏輯的。

  • 保證資料的定義和使用相匹配,如定義int型別資料,使用時不能以long型操作。
  • 保證跳轉指令不會跳轉到方法體以外的位元組碼指令上。
  • 保證方法體中的型別轉換是有效的。如可以把子類物件賦值給父類引用,但是父類不可以直接賦值給子類(必須強轉)或其他不相干的型別。

4)符號引用驗證

最後一個階段的驗證發生在符號引用轉換為直接引用的時候。實際的轉換動作,發生在後面的解析階段。主要對類自身以外的資訊(常量池中的各種符號引用)進行匹配性的校驗。

驗證階段是非常重要但是非必要的一個階段。如果確保程式碼對程式執行期沒有影響,則可以通過 -Xverify:node 引數關閉大部分的驗證,以縮短類載入的總時間。

準備

準備階段是類變數分配記憶體並設定初始值的階段。這裡的類變數指的是被static修飾的變數,而不包括例項變數。類變數被分配到方法區中,而例項變數存放在堆中。

這裡的初始值指的是資料型別的預設值,而不是程式碼中所賦的值。例如

public static int value = 1 ;

在準備階段之後,value值為0,而不是1。賦值為1的動作發生在初始化階段。

但是,也要特殊情況,如果變數被static 和 final同時修飾,則準備階段直接賦值為指定值。如

public static int value = 1 ;

在準備階段之後,value的值即為1.

各資料型別的初始預設值如下:

資料型別 預設值
short (short)0
int 0
long 0L
float 0.0f
double 0.0d
char '\u0000'
byte byte(0)
boolean false
reference null

解析

解析階段是將常量池中的符號引用轉換為直接引用的過程。那什麼是符號引用和直接引用呢?

符號引用是用一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能無歧義的定位到目標即可(前面JVM的模型中,也提到了符號引用,它存在於常量池中,包括類和介面的全限定名、欄位的名稱和描述符、方法的名稱和描述符)。看概念可能比較抽象,可以理解為它就是一個代號,就像你有一個大名,同時也有一個小名,但是不管怎麼叫指代的都是你本人。

直接引用可以是直接指向目標的指標、相對偏移量或是一個能間接定位到目標的控制代碼。

解析動作主要針對類或介面、欄位、類方法、介面方法、方法屬性、方法控制代碼、呼叫點限定符7類符號引用。此處分別介紹一下前四種的解析過程。

1)類或介面的解析

如果類C不是陣列型別,那麼虛擬機器會把類C直接傳給類載入器。如果類C是陣列型別並且元素型別是物件(如String[]),那麼先用類載入器載入元素型別(String型別),再由虛擬機器建立代表此陣列維度和元素的陣列物件。判斷呼叫類是否有許可權訪問被載入類,如果不允許的話,就丟擲IllegalAccessError異常。

2)欄位的解析

首先解析欄位所屬的類或介面的符號引用。如果類中有欄位的符號引用(欄位的名稱和描述符)和目標欄位相匹配,則返回這個欄位的直接引用。如果沒有,則自下而上查詢其實現的介面和父介面,若匹配到,則返回這個欄位的直接引用。如果還沒有,就自下而上查詢其繼承的父類,若匹配到,則返回這個欄位的直接引用。否則,查詢失敗,丟擲NoSuchFieldError異常。最後如果查詢成功的話,會判斷欄位訪問許可權,如果該欄位不允許訪問,則丟擲 IllegalAccessError異常。

這麼一大段,如果乍看沒明白,下面用程式碼解釋一下就懂了。

public class ResolveTest {
    public static void main(String[] args) {
        System.out.println(Child.a);
    }

    interface Interface0 {
        int a = 0;
    }

    static class Parent {
        static int a = 1;
    }

    //①
    static class Child {
        static int a = 2;
    }
    //①
}

比如,我去查詢類Child中的a欄位,目前來看可以直接查到,就是a=2。如果我把①所包圍的程式碼修改為

static class Child implements Interface0 {
        
}

則表示在本類中找不到a欄位,因此去Child類實現的介面Interface0中查詢,於是,成功找到 a=0。

再次把①程式碼修改為

static class Child extends Parent {

}

本類找不到a,則去它的父類查詢,於是查詢成功,a=1。

那麼聰明的同學可能想到了,如果我修改程式碼為既繼承父類又實現介面會怎麼樣呢?

static class Child extends Parent implements Interface0 {

}

這樣是不行的,編譯器會拒絕編譯。其實,想一下,就能明白,這個時候Child應該取父類中欄位的值還是介面中欄位的值呢,編譯器是不知道的,所以不能編譯。其實,如果是在編譯期,程式碼開發工具會給一條這樣的報錯資訊:Reference to 'a' is ambiguous, both 'Parent.a' and 'Interface0.a' match.

如果強制執行這段程式碼,控制檯則會報錯如下資訊:

思考一下,如果,我非要既繼承父類又實現介面,應該怎樣修改程式碼才能編譯通過呢?

3)類方法解析

類方法解析第一步同欄位解析一樣,也需要先解析方法所屬的類或介面的符號引用。類方法和介面方法符號引用的常量型別是分開的。如果,在類方法中解析出來的是一個介面,則會丟擲 IncompatibleClassChangeError 異常。如果在類中有方法的符號引用(方法的名稱和描述符)和目標方法相匹配,則返回這個方法的直接引用,查詢結束。否則,在類的父類中遞迴查詢,若找到則返回,查詢結束。否則,查詢它實現的介面和父介面,如果找到,說明此類是一個抽象類,丟擲 AbstractMethodError異常。若都找不到,就丟擲NoSuchMethodError 異常。最後,如果查詢成功,會判斷此方法是否有訪問許可權,若沒有,則丟擲 IllegalAccessError異常。

下面通過程式碼解釋:

public class ResolveTest2 {
    public static void main(String[] args) {
        Child child = new Child();
        child.method0();
    }

    interface Interface0 {
        void method0();
    }

    static class Parent {
        void method0(){
            System.out.println("parent method0");
        }
    }

    //②
    static class Child extends Parent {
        void method0(){
            System.out.println("child method0");
        }
    }
    //②
}

②中,如果當前類Child中有method0方法,則直接返回此方法,列印結果child method0。若把Child中的method0方法註釋掉,則會去找父類Parent的method0,列印結果 parent method0 。最後一點,如果類是實現了介面Interface0,並在介面中找到了method0方法,則說明Child類一定是抽象類。因為,只有抽象類才可以選擇不重寫介面的抽象方法。如果不是抽象類,則需要實現介面的全部方法,此時就可以直接在當前Child類中找到method0方法,而不必去介面中查詢方法了。

//必須是抽象類,否則,需要實現介面的全部方法
static abstract class Child implements Interface0 {

}

4)介面方法的解析

首先解析方法所屬的類或介面的符號引用,和類方法解析同理,如果發現解析出來是一個類方法,則會丟擲 IncompatibleClassChangeError 異常。如果所屬介面中匹配到目標方法,則返回此方法的直接引用。否則,在父介面中查詢,若找到,則返回。否則,查詢失敗,丟擲 NoSuchMethodError 異常。由於介面的方法都是public的,所以不存在訪問許可權的問題。

初始化

這是類載入的最後一步,到這才真正開始執行Java程式碼。在準備階段,已經為類變數分配記憶體,並賦值了預設值。在初始階段,則可以根據需要來賦值了。可以說,初始化階段是執行類構造器 < clinit > 方法的過程。

首先說下類構造器 < clinit > 方法和例項構造器 < init > 方法有什麼區別。< clinit > 方法是在類載入的初始化階段執行,是對靜態變數、靜態程式碼塊進行的初始化。而< init > 方法是new一個物件,即呼叫類的 constructor方法時才會執行,是對非靜態變數進行的初始化。

類構造器方法有如下特點:

  • 保證父類的 < clinit > 方法執行完畢,再執行子類的 < clinit > 方法。
  • 由於父類的 < clinit > 方法先執行,所以父類的靜態程式碼塊也優於子類執行。
  • 如果類中沒有靜態程式碼塊,也沒有為變數賦值,則可以不生成 < clinit > 方法。
  • 執行介面的 < clinit > 方法時,不需要先執行父介面的 < clinit > 方法。只有父介面中定義的變數使用時,父接口才會初始化。另外,介面的實現類在初始化時也不執行介面的 < clinit > 方法。
  • 虛擬機器會保證在多執行緒環境下 < clinit > 方法能被正確的加鎖、同步。如果有多個執行緒同時請求載入一個類,那麼只會有一個執行緒去執行這個類的 < clinit > 方法,其他執行緒都會阻塞,直到方法執行完畢。同時,其他執行緒也不會再去執行 < clinit > 方法了。這就保證了同一個類載入器下,一個類只會初始化一次。(這也是為什麼說餓漢式單例模式是執行緒安全的,因為類只會載入一次。)

類的初始化時機:只有對類主動使用的時候才會觸發初始化,主動使用的場景如下:

  • 使用new關鍵詞建立物件時,訪問某個類的靜態變數或給靜態變數賦值時,呼叫類的靜態方法時。
  • 反射呼叫時,會觸發類的初始化(如Class.forName())
  • 初始化一個類的時候,如其父類未初始化,則會先觸發父類的初始化。
  • 虛擬機器啟動時,會先初始化主類(即包含main方法的類)。

另外,也有些場景並不會觸發類的初始化:

  • 通過子類呼叫父類的靜態變數,只會觸發父類的初始化,而不會觸發子類的初始化(因為,對於靜態變數,只有直接定義這個變數的類才會初始化)。
  • 通過陣列來建立物件不會觸發此類的初始化。(如定義一個自定義的Person[] 陣列,不會觸發Person類的初始化)
  • 通過呼叫靜態常量(即static final修飾的變數),並不會觸發此類的初始化。因為,在編譯階段,就已經把final修飾的變數放到常量池中了,本質上並沒有直接引用到定義常量的類,因此不會觸發類的初始化。

原文首發地址: 類載入機制你真的瞭解嗎?
文末可獲取《深入理解Java虛擬機器第二版》pdf電子書,及JVM視