類加載機制詳解

以前在介紹JVM內存模型的時候(參看:JVM內存模型),提到了在運行時數據區以前,有個Class Loader,這個就是類加載器。用以把Class文件中的描述信息加載到內存中運行和使用。如下是《深刻理解Java虛擬機第二版》對類加載器機制的定義原文:java

虛擬機把描述類的數據從Class文件加載到內存,並對數據進行校驗、轉換解析和初始化,最終造成能夠被虛擬機直接使用的Java類型,這就是虛擬機的類加載機制。node

通常咱們把類從加載到內存到卸載出內存的整個過程分爲七個階段:加載,驗證,準備,解析,初始化,使用和卸載。其中,驗證、準備和解析統稱爲鏈接。數據庫

file

在這幾個階段中,加載、驗證、準備、初始化和卸載這五個階段的順序是固定的,而解析階段則不必定,它有時候可能會在初始化以後開始,這是爲了支持Java的運行時綁定。須要特別注意的是,這裏邊的順序指的是按順序開始,而不是按順序進行或完成,由於這些階段一般會互相交叉的混合進行。數組

瞭解類的加載機制很是有必要,下面將逐個解釋說明類加載的全過程(即加載,驗證,準備,解析,初始化五個階段)。相信看完以後,你會對Java類某些問題有更深入的理解(例如,爲何子類能夠覆蓋父類的字段和方法?餓漢式單例爲何天生是線程安全的?)安全

加載

加載過程分爲三步:網絡

1)經過一個類的全限定名來獲取定義此類的二進制字節流。數據結構

2)將這個字節流所表明的的靜態存儲結構轉化爲方法區的運行時數據結構。多線程

3)在內存中生成一個表明這個類的Class對象,做爲方法區這個類的各類數據的訪問入口。jsp

上面的第一步獲取二進制字節流,並無限定只能從編譯好的.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.

若是強制執行這段代碼,控制檯則會報錯以下信息:

file

思考一下,若是,我非要既繼承父類又實現接口,應該怎樣修改代碼才能編譯經過呢?

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視頻

相關文章
相關標籤/搜索