天天看點

細說JVM(類加載機制)

一、類加載的時機

從類被加載到虛拟機記憶體中開始,到解除安裝出記憶體為止,它的整個生命周期分為7個階段,加載(Loading)、驗證(Verification)、準備(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)、解除安裝(Unloading)。其中驗證、準備、解析三個部分統稱為連接配接。

7個階段發生的順序如下:

細說JVM(類加載機制)

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

對于類加載的時機,Java虛拟機規範中并沒有進行強制的限制,但是對于初始化階段,虛拟機規範嚴格規定了5種情況下必須對類進行初始化(類的加載、驗證、準備就在這之前開始),這5中情況分别是:

  • 遇到

    new、getstatic、putstatic或invokestatic

    這四條位元組碼指令時,如果類沒有進行過初始化,就需要進行類的初始化。這些場景包括:使用new關鍵字執行個體化對象時、讀取或設定一個類的靜态字段(被final修飾、已在編譯器把結果放入常量池的靜态字段除外)時,以及調用一個類的靜态方法時;
  • 使用

    java.lang.reflect

    包的方法對類進行反射調用的時候,如果類沒有進行過初始化,就需要先進行類的初始化;
  • 當初始化一個類的時候,如果發現父類還沒有進行過初始化,則需要先初始化父類;
  • 當虛拟機啟動時,使用者需要指定一個要執行的主類(包含main方法的那個類),虛拟機會先初始化這個主類;
  • 當使用JDK 1.7的動态語言支援時,如果一個

    java.lang.invoke.MethodHandle

    執行個體最後的解析結果

    REF_getStatic、REF_putStatic

    REF_invokeStatic

    的方法句柄,并且這個方法句柄所對應的類沒有進行過初始化,則需要先觸發其初始化;

這五種情況是Java虛拟機規範規定的主動初始化一個類的情況,除此之外的任何其他引用一個類的情況都不會主動初始化這個類,這叫做被動引用。下面以三個例子說明什麼是被動引用:

1、通過子類引用父類的靜态字段,并不會初始化子類

代碼如下:

package temp;
class SuperClass {
    static{
        System.out.println("Super Class init.");
    }
    public static int value=;
}

public class SubClass extends SuperClass {
    static{
        System.out.println("Sub Class init.");
    }
}

class Test1 {
    @SuppressWarnings("unused")
    public static void main(String[] args) {
        System.out.println(SubClass.value);
    }
}
           

結果為:

細說JVM(類加載機制)

可以看到,并沒有輸出

SubClass init.

,對于靜态字段,隻有直接定義這個字段的類才會被初始化。

2、通過數組定義來引用類,不會觸發此類的初始化

代碼如下:

package temp;
class SuperClass {
    static{
        System.out.println("Super Class init.");
    }
    public static int value=;
}

public class SubClass extends SuperClass {
    static{
        System.out.println("Sub Class init.");
    }
}

class Test1 {
    @SuppressWarnings("unused")
    public static void main(String[] args) {
        SuperClass[] sca=new SuperClass[];
    }
}
           

運作結果是什麼也沒有列印。

3、調用類中的常量不會觸發該類的初始化

代碼如下:

package temp;

class ConstClass{
    static{
        System.out.println("ConstClass init.");
    }
    public static final String GREETING="Hello World.";
}

public class Test1{
    public static void main(String[] args){
        System.out.println(ConstClass.GREETING);
    }
}
           

運作結果:

細說JVM(類加載機制)

這裡的ConstClass裡定義了一個常量,運作後也沒有

ConstClass init.

輸出,這時因為在編譯階段将這個常量的值存儲到了Test1類的常量池中,以後Test1類對這個常量的引用實際上都會被轉化為Test類對自身常量池的引用,即使在Java源碼中引用了ConstClass的GREETING常量。也就是說,實際上Test1的Class檔案中并沒有對ConstClass類的符号引用入口,兩者在編譯成Class檔案後就沒有關系了。

接口的加載過程和類的加載過程有一些不同,不過接口也有初始化過程,上面的代碼都是用靜态代碼塊

static{}

來輸出初始化資訊的,而接口中不能使用

static{}

語句塊,但編譯器仍然會為接口生成

<clinit()>

類構造器,用于初始化接口中所定義的成員變量。接口與類真正有所差別的是在前面五種情況中的第三種:當一個類在初始化時,它的所有父類都完成了初始化,但對一個接口初始化時,并不要求所有的父接口都完成初始化,隻有在真正用藥父接口的時候才會初始化。

二、類加載過程

這裡詳細講解類加載的全過程,也就是加載、驗證、準備、解析和初始化五個階段所執行的具體操作。

1、加載階段

這在個階段,虛拟機要做的事情有如下三個:

  • 通過一個類的全限定名來擷取定義這個類的二進制位元組流;
  • 将這個位元組流所代表的靜态存儲結構轉化為方法區的運作時資料結構;
  • 在記憶體中生成一個代表這個類的

    java.lang.Class

    對象,作為方法區這個類的各種資料的通路入口;

這裡的二進制位元組流并不一定要從Class檔案擷取,隻要一段二進制位元組流符合Class檔案的規範,都可以當做一個Class檔案,例如我們可以從jar包中擷取,從網絡中擷取,運作時生成等,總之擷取Class檔案的方式非常多。

這裡需要注意的是通過一個類的全限定名來擷取定義這個類的二進制位元組流的動作是有類加載器完成的,對于非數組類的加載,我們可以通過自定義類加載器加載來進行控制。對于數組類就不行,因為它不是通過類加載器建立的,而是由Java虛拟機直接建立的。

但數組類和非數組類也有很大的聯系,畢竟組成數組的元素就是非數組類(對于一維數組來說,而對于多元數組來說,可以遞歸加載),非數組類的建立需要類加載器完成。加載建立一個數組類的過程如下:

  • 如果數組的元素類型是引用類型,就遞歸加載這個元素類型,這個數組将在加載該元素類型的類加載器的類名稱空間上被辨別;
  • 如果數組的元素類型不是引用類型(比如

    int[]

    數組),Java虛拟機将會把數組标記為與引導類加載器關聯;
  • 數組類的可見性與元素類型的可見性一緻,如果元素類型是不引用類型,那麼數組的可見性預設是

    public

加載階段完成後,原來虛拟機外部的二進制位元組流就按照虛拟機所需的格式存儲在方法區之中,不過方法區中的資料格式Java虛拟機規範沒有規定。在這之後,虛拟機會在記憶體中執行個體化一個java.lang.Class類的對象。對于HotSpot虛拟機,雖然這是一個對象,按理說應該在Java堆中建立,不過HotSpot虛拟機是在方法區中建立的。這個對象将作為程式通路方法區中的這些資料類型的外部入口。

需要注意的是,加載階段與連接配接階段的部分内容是交叉進行的。

2、驗證階段

驗證的目的是為了確定Class檔案中的位元組流包含的資訊符合目前虛拟機的要求,而且不會危害虛拟機自身的安全。不同的虛拟機對類驗證的實作可能會有所不同,但大緻都會完成以下四個階段的驗證:檔案格式的驗證、中繼資料的驗證、位元組碼驗證和符号引用驗證。

  • 檔案格式的驗證:驗證位元組流是否符合Class檔案格式的規範,并且能被目前版本的虛拟機處理,該驗證的主要目的是保證輸入的位元組流能正确地解析并存儲于方法區之内。經過該階段的驗證後,位元組流才會進入記憶體的方法區中進行存儲,後面的三個驗證都是基于方法區的存儲結構進行的。
  • 中繼資料驗證:對類的中繼資料資訊進行語義校驗(其實就是對類中的各資料類型進行文法校驗),保證不存在不符合Java文法規範的中繼資料資訊。
  • 位元組碼驗證:該階段驗證的主要工作是進行資料流和控制流分析,對類的方法體進行校驗分析,以保證被校驗的類的方法在運作時不會做出危害虛拟機安全的行為。
  • 符号引用驗證:這是最後一個階段的驗證,它發生在虛拟機将符号引用轉化為直接引用的時候(解析階段中發生該轉化,後面會有講解),主要是對類自身以外的資訊(常量池中的各種符号引用)進行比對性的校驗。
3、準備階段

準備階段是正式為類變量配置設定記憶體并設定類變量初始值的階段,這些變量所使用的記憶體都将在方法區中進行配置設定。這個階段中需要注意兩點,首先,這個時候進行記憶體配置設定的僅包括類變量(被static修飾的變量)而不包括執行個體變量,執行個體變量就在對象執行個體化時随着對象一起配置設定在Java堆中。其次,這裡所說的初始值通常是資料類型的零值,比如下面的類變量定義:

public static int value=;
           

那麼value在準備階段的初始值是0而不是10,因為這個時候還沒開始執行任何Java方法,而将value指派為10的putstatic指令是程式被編譯後,存放于類構造器

<clinit>()

方法中,是以把value指派為10的動作将在初始化階段才會執行。下标列出了所有基本類型的零值:

細說JVM(類加載機制)

不過也有特殊情況,将類變量指派為非零值。如果類字段的字段屬性表中存在ConstantValue屬性,那麼在準備階段變量value就會被初始化為ConstantValue屬性所指定的值,比如:

public static final int value=;
           

編譯時Javac将會為value生成一個ConstantValue的屬性,在準備階段虛拟機就會根據ConstantValue的設定将value指派為10。

4、解析階段

解析階段是虛拟機将常量池中的符号引用轉化為直接引用的過程。在Class類檔案結構一文中已經比較過了符号引用和直接引用的差別和關聯,這裡不再贅述。前面說解析階段可能開始于初始化之前,也可能在初始化之後開始,虛拟機會根據需要來判斷,到底是在類被加載器加載時就對常量池中的符号引用進行解析(初始化之前),還是等到一個符号引用将要被使用前才去解析它(初始化之後)。

對同一個符号引用進行多次解析請求時很常見的事情,虛拟機實作可能會對第一次解析的結果進行緩存(在運作時常量池中記錄直接引用,并把常量标示為已解析狀态),進而避免解析動作重複進行。

解析動作主要針對類或接口、字段、類方法、接口方法四類符号引用進行,分别對應于常量池中的

CONSTANT_Class_info

CONSTANT_Fieldref_info

CONSTANT_Methodref_info

CONSTANT_InterfaceMethodref_info

四種常量類型。

下面是四中符号符号引用的解析過程:

(1) 類或接口的解析

假設目前代碼處于類D中,如果要把一個從未解析過的符号引用N解析為一個類或接口C的直接引用,虛拟機将經曆如下的過程:

  • 如果C不是一個數組類型,那虛拟機将會把代表N的全限定名傳遞給D的類加載器區加載這個類。在加載過程中,由于中繼資料驗證、位元組碼驗證的需要,又可能觸發其他相關類的加載,比如這個類的父類或實作的接口。如果加載過程出現異常,解析過程就失敗。
  • 如果C是一個數組類型,并且數組的元素類型是對象,就會按照第一點加載數組元素類型。接着由虛拟機生成一個代表着數組次元和元素的數組對象。
  • 如果上面的步驟沒有異常,那麼C在虛拟機中實際上已經成為一個有效的類或接口了,但在解析完成之前還要進行符号引用驗證,确認D是否具備對C的通路權限。
(2)字段解析

要解析一個未被解析過的字段符号引用,首先将會對字段表内

class_index

項中索引的

CONSTANT_Class_info

符号引用進行解析,也就是字段所屬的類或借口的符号引用。如果解析這個類或接口時發生異常,都會導緻解析字段的失敗。如果解析成功,才會繼續解析這個字段。具體的規則如下:

  • 如果類或接口C本身就包含了簡單名稱和字段描述符都與目标比對的字段,則傳回這個字段的直接引用,查找結束;
  • 否則,如果在C中實作了接口,将會按照繼承關系從下到上遞歸搜尋各個接口和它的父接口,如果接口中包含了簡單名稱和字段描述符都與目标比對的字段,則傳回這個字段的直接引用,查找結束;
  • 否則,如果C不是Object的話,将會按照繼承關系從下到上遞歸搜尋父類,如果父類中包含了簡單名稱和字段描述符都與目标比對的字段,傳回這個字段的直接引用,查找結束;
  • 否則,查找失敗。
  • 之後,還會對傳回的字段進行權限驗證,如果不具備對字段的通路權限,将抛出

    java.lang.IllegalAccessError

    異常。
(3)類方法解析

類方法解析的第一個步驟和字段解析一樣,也需要先解析出類方法表的

class_index

項中索引的方法所屬的類或接口的符号引用,如果解析成功,按照如下步驟繼續(同樣以C來表示這個類):

  • 類方法和接口方法符号引用的常量類型定義是分開的,如果在類方法表中發現class_index中索引的C是一個接口,直接失敗;
  • 然後,會在類C中查找這個方法;
  • 否則,在類C的父類中遞歸查找這個方法;
  • 否則,在類C實作的接口清單中遞歸查找這個方法。如果找到一個比對的方法,說明類C是一個抽象類,查找結束,抛出

    java.lang.AbstractMethodError

    異常;
  • 否則,查找失敗,抛出

    java.lang.NoSuchMethodError

    異常。
  • 同樣,成功傳回後還要進行權限驗證。
(4)接口方法解析

接口方法也要解析接口方法表的

class_index

所屬的類或接口引用,如果解析成功,用C表示這個類或接口,虛拟機按照如下的規則搜尋:

  • 與類方法解析不同,如果在接口方法表中發現

    class_index

    是一個類而不是接口,直接抛出

    java.lang.IncompatibleClassChangeError

    異常;
  • 否則,在接口C中查找這個方法;
  • 否則,在接口C的父接口中遞歸查找;
  • 否則,查找失敗,抛出java.lang.NoSuchMethodError異常。

    傳回成功後不會驗證權限,因為接口的方法都是public的。

注意:如果你比較細心的話會發現上面中提到的字段、方法、接口方法中都提到了一個

class_index

屬性,可是在相應的”字段表,方法表、接口方法表”中并沒有

class_index

這個屬性,我在網上找了很長時間,終于解決了這個疑問。首先這裡提到的字段表、方法表、接口方法表并不是字段表集合、方法表集合中的字段表和方法表,而是指常量池中的

CONSTANT_Fieldref_info

CONSTANT_Methodref_info

CONSTANT_InterfaceMethodref_info

,在這三個常量項中都有兩個引用,一個是

class_index

:指向該字段(方法、接口方法)所屬的類(接口),另一個是

name_and_type_index

:指向了該字段(方法、接口方法)的描述(等同于字段表集合中的描述),這裡限于篇幅,不多做解釋,想要深入了解的可以參考:

  • 《Java虛拟機原理圖解》 1.2.3、Class檔案中的常量池詳解(下)
  • CONSTANT_Fieldref_info和字段表中的field_info的差別?
5、初始化階段

類初始化階段是類加載過程的最後一步,前面的類加載過程中,除了在加載階段使用者應用程式可以通過自定義類加載器參與之外,其餘動作完全由虛拟機主導和控制。到了初始化階段,才真正開始執行類中定義的Java程式。

前面已經知道,在準備階段變量已經指派過一次系統初始值了,而在初始化階段,則會根據程式員通過程式制定的主觀計劃去初始化類變量和其它内容。即,初始化階段是執行類構造器

<clinit>()

方法的過程。下面是

<clinit>()

方法的特點:

  • <clinit>()

    方法是由編譯器自動收集類中的所有類變量的指派動作和靜态語句塊中的語句合并産生的,編譯器收集的順序由語句塊在源檔案中的順序決定的,靜态語句塊中隻能通路到定義在靜态語句塊之前的變量,定義在之後的變量不能通路,但能指派。
  • <clinit>()

    方法與類構造器()不同,它不需要顯式調用父類的

    <clinit>()

    方法,虛拟機保證在子類

    <clinit>()

    方法執行之前,父類的

    <clinit>()

    方法已經執行完畢。
  • 由于父類的

    <clinit>()

    方法先執行,意味着父類中定義的靜态語句塊要優先于子類的變量指派操作。
  • <clinit>()

    方法對于類或接口來說并不是必須的,如果一個類中沒有靜态語句塊,也沒有對變臉的指派操作,那麼編譯器就不會生成

    <clinit>()

    方法。
  • 接口中不能使用靜态語句塊,但仍然有變量初始化的指派操作,是以接口與類一樣都會生成

    <clinit>()

    方法。但接口與類不同的是,執行接口的

    <clinit>()

    方法不需要先執行父接口的

    <clinit>()

    方法。即,隻有使用一個接口中的變量時,才會執行這個接口的

    <clinit>()

    方法。
  • 虛拟機會保證一個類的

    <clinit>()

    方法在多線程環境下中被正确的加鎖、同步,如果多個線程同時去初始化一個類,那麼隻有一個線程會去執行這個類的

    <clinit>()

    方法,其他線程都需要阻塞等待,直到活動線程執行

    <clinit>()

    方法完畢。

這裡限于篇幅,把類加載機制中比較重要的最後一部分——類加載器放到下一篇文章中。