Java 虛擬機類加載機制

原文地址java

虛擬機把描述類的數據從Class文件加載到內存,並對數據進行校驗,轉換解析和初始化,最終造成能夠被虛擬機直接使用的Java類型,This
is the class loading mechanism of the virtual machine

本文基於HotSpot虛擬機程序員

類加載

類從被加載到虛擬機內存開始,到卸載出內存爲止,整個過程包括加載(Loading)、驗證(Verification)、準備(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸載(Unloading)7個階段。其中驗證準備解析3部分統稱爲鏈接數據庫

類加載.png

其中類加載的過程包括了加載、驗證、準備、解析、初始化五個階段。在這五個階段中,加載、驗證、準備和初始化這四個階段發生的順序是肯定的,而解析階段則不必定,它在某些狀況下能夠在初始化階段以後開始,這是爲了支持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的動做將在初始化階段纔會執行。

基本數據類型的零值以下:

QQ截圖20180506160653.png

這一階段還須要注意以下幾點:

  • 對基本數據類型來講,對於類變量(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技術。

QQ截圖20180506155219.png

如上圖展現的類加載之間的這種層次關係,稱爲類加載器的雙親委派模型 咱們把每一層上面的類加載器叫作當前層類加載器的父加載器,固然,它們之間的父子關係並非經過繼承關係來實現的,而是使用組合關係來複用父加載器中的代碼。該模型在JDK1.2期間被引入並普遍應用於以後幾乎全部的Java程序中,但它並非一個強制性的約束模型,而是Java設計者們推薦給開發者的一種類的加載器實現方式。

雙親委派模型的工做流程是:若是一個類加載器收到了類加載的請求,它首先不會本身去嘗試加載這個類,而是把請求委託給父加載器去完成,依次向上,所以,全部的類加載請求最終都應該被傳遞到頂層的啓動類加載器中,只有當父加載器在它的搜索範圍中沒有找到所需的類時,即沒法完成該加載,子加載器纔會嘗試本身去加載該類。

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

相關文章
相關標籤/搜索