類加載機制


類加載時機

既可以eagerly load(只要有其他類引用了它就加載),也可以lazy load(等類初始化發生的時候才加載 ),與JVM實現有關。


類加載過程

它們開始的順序如下圖所示:


在這五個階段中,除了解析,其餘六個過程必須按就班的"開始",而解析階段則不一定,它在某些情況下可以在初始化階段之後開始,這是爲了支持 Java 語言的運行時綁定(也成爲動態綁定或晚期綁定)。另外注意這裏的幾個階段是按順序開始,而不是按順序進行或完成,因爲這些階段通常都是互相交叉地混合進行的,通常在一個階段執行的過程中調用或激活另一個階段。



在加載階段,虛擬機需要完成以下三件事情:
  • 通過一個類的全限定名來獲取其定義的二進制字節流。
  • 將這個字節流所代表的靜態存儲結構轉化爲方法區的運行時數據結構。
  • 在 Java 堆中生成一個代表這個類的 java.lang.Class 對象,作爲對方法區中這些數據的訪問入口。

注意,通過不同的類加載器,可以從不同來源加載類的二進制數據,通常有如下幾種來源:

  • 從本地文件系統加載class文件。
  • 從JAR包加載class文件
  • 通過網絡加載(最典型的應用便是 Applet)
  • 由其他文件生成(JSP 應用)



加載階段完成後,虛擬機外部的 二進制字節流就按照虛擬機所需的格式存儲在方法區之中,而且在 Java 堆中也創建一個 java.lang.Class 類的對象,這樣便可以通過該對象訪問方法區中的這些數據。

說到加載,不得不提到類加載器,下面就具體講述下類加載器。

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


類加載器可以大致劃分爲以下三類:

  • 啓動類加載器:Bootstrap ClassLoader。它負責加載存放在JDK\jre\li(JDK 代表 JDK 的安裝目錄,下同)下,或被-Xbootclasspath參數指定的路徑中的,並且能被虛擬機識別的類庫(如 rt.jar,所有的java.*開頭的類均被 Bootstrap ClassLoader 加載)。啓動類加載器是無法被 Java 程序直接引用的。
  • 擴展類加載器:Extension ClassLoader,該加載器由sun.misc.Launcher$ExtClassLoader實現,它負責加載JDK\jre\lib\ext目錄中,或者由 java.ext.dirs 系統變量指定的路徑中的所有類庫(如javax.*開頭的類),開發者可以直接使用擴展類加載器。
  • 應用程序類加載器:Application ClassLoader,該類加載器由 sun.misc.Launcher$AppClassLoader 來實現,它負責加載用戶類路徑(ClassPath)所指定的類,開發者可以直接使用該類加載器,如果應用程序中沒有自定義過自己的類加載器,一般情況下這個就是程序中默認的類加載器。

應用程序都是由這三種類加載器互相配合進行加載的,如果有必要,我們還可以加入自定義的類加載器。


驗證

驗證的目的是爲了確保 Class 文件中的字節流包含的信息符合當前虛擬機的要求,而且不會危害虛擬機自身的安全。不同的虛擬機對類驗證的實現可能會有所不同,但大致都會完成以下四個階段的驗證:文件格式的驗證、元數據的驗證、字節碼驗證和符號引用驗證。

  • 文件格式的驗證:驗證字節流是否符合 Class 文件格式的規範,並且能被當前版本的虛擬機處理,該驗證的主要目的是保證輸入的字節流能正確地解析並存儲於方法區之內。經過該階段的驗證後,字節流纔會進入內存的方法區中進行存儲,後面的三個驗證都是基於方法區的存儲結構進行的。
  • 元數據驗證:對類的元數據信息進行語義校驗(其實就是對類中的各數據類型進行語法校驗),保證不存在不符合 Java 語法規範的元數據信息。
  • 字節碼驗證:該階段驗證的主要工作是進行數據流和控制流分析,對類的方法體進行校驗分析,以保證被校驗的類的方法在運行時不會做出危害虛擬機安全的行爲。
  • 符號引用驗證:這是最後一個階段的驗證,它發生在虛擬機將符號引用轉化爲直接引用的時候(解析階段中發生該轉化,後面會有講解),主要是對類自身以外的信息(常量池中的各種符號引用)進行匹配性的校驗。

準備

準備階段是正式爲類變量分配內存並設置類變量初始值的階段,這些內存都將在方法區中分配。對於該階段有以下幾點需要注意:

  • 這時候進行內存分配的僅包括類變量(static),而不包括實例變量,實例變量會在對象實例化時隨着對象一塊分配在 Java 堆中。
  • 這裏所設置的初始值通常情況下是數據類型默認的零值(如 0、0L、null、false 等),而不是被在 Java 代碼中被顯式地賦予的值。

假設一個類變量的定義爲:

public static int value = 3;

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

這裏還需要注意如下幾點:

  • 對基本數據類型來說,對於類變量(static)和全局變量,如果不顯式地對其賦值而直接使用,則系統會爲其賦予默認的零值,而對於局部變量來說,在使用前必須顯式地爲其賦值,否則編譯時不通過。
  • 對於同時被 static 和 final 修飾的常量,必須在聲明的時候就爲其顯式地賦值,否則編譯時不通過;而只被 final 修飾的常量則既可以在聲明時顯式地爲其賦值,也可以在類初始化時顯式地爲其賦值,總之,在使用前必須爲其顯式地賦值,系統不會爲其賦予默認零值。
  • 對於引用數據類型 reference 來說,如數組引用、對象引用等,如果沒有對其進行顯式地賦值而直接使用,系統都會爲其賦予默認的零值,即null。
  • 如果在數組初始化時沒有對數組中的各元素賦值,那麼其中的元素將根據對應的數據類型而被賦予默認的零值。

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

假設上面的類變量 value 被定義爲:

public static final int value = 3;

編譯時 Javac 將會爲 value 生成 ConstantValue 屬性,在準備階段虛擬機就會根據 ConstantValue 的設置將 value 賦值爲 3。回憶上一篇博文中對象被動引用的第 2 個例子,便是這種情況。我們可以理解爲 static final 常量在編譯期就將其結果放入了調用它的類的常量池中。

解析

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

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

解析動作主要針對類或接口、字段、類方法、接口方法四類符號引用進行,分別對應於常量池中的 CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info 四種常量類型。

1、類或接口的解析:判斷所要轉化成的直接引用是對數組類型,還是普通的對象類型的引用,從而進行不同的解析。

2、字段解析:對字段進行解析時,會先在本類中查找是否包含有簡單名稱和字段描述符都與目標相匹配的字段,如果有,則查找結束;如果沒有,則會按照繼承關係從上往下遞歸搜索該類所實現的各個接口和它們的父接口,還沒有,則按照繼承關係從上往下遞歸搜索其父類,直至查找結束,查找流程如下圖所示:


從下面一段代碼的執行結果中很容易看出來字段解析的搜索順序:

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

另外,很明顯這就是上篇博文中的第 1 個例子的情況,這裏我們便可以分析如下:static 變量發生在靜態解析階段,也即是初始化之前,此時已經將字段的符號引用轉化爲了內存引用,也便將它與對應的類關聯在了一起,由於在子類中沒有查找到與 m 相匹配的字段,那麼 m 便不會與子類關聯在一起,因此並不會觸發子類的初始化。

最後需要注意:理論上是按照上述順序進行搜索解析,但在實際應用中,虛擬機的編譯器實現可能要比上述規範要求的更嚴格一些。如果有一個同名字段同時出現在該類的接口和父類中,或同時在自己或父類的接口中出現,編譯器可能會拒絕編譯。如果對上面的代碼做些修改,將 Super 改爲接口,並將 Child 類繼承 Father 類且實現 Super 接口,那麼在編譯時會報出如下錯誤:

StaticTest.java:24: 對 m 的引用不明確,Father 中的 變量 m 和 Super 中的 變量 m
都匹配
                System.out.println(Child.m);
                                        ^
1 錯誤

3、類方法解析:對類方法的解析與對字段解析的搜索步驟差不多,只是多了判斷該方法所處的是類還是接口的步驟,而且對類方法的匹配搜索,是先搜索父類,再搜索接口。

4、接口方法解析:與類方法解析步驟類似,知識接口不會有父類,因此,只遞歸向上搜索父接口就行了。

初始化

初始化是類加載過程的最後一步,到了此階段,才真正開始執行類中定義的 Java 程序代碼。在準備階段,類變量已經被賦過一次系統要求的初始值,而在初始化階段,則是根據程序員通過程序指定的主觀計劃去初始化類變量和其他資源

類初始化是類加載過程的最後一個階段,到初始化階段,才真正開始執行類中的 Java 程序代碼。虛擬機規範嚴格規定了有且只有四種情況必須立即對類進行初始化:

  • 遇到 new、getstatic、putstatic、invokestatic 這四條字節碼指令時,如果類還沒有進行過初始化,則需要先觸發其初始化。生成這四條指令最常見的 Java 代碼場景是:使用 new 關鍵字實例化對象時、讀取或設置一個類的靜態字段(static)時(被 static 修飾又被 final 修飾的,已在編譯期把結果放入常量池的靜態字段除外)、以及調用一個類的靜態方法時。
  • 使用 Java.lang.refect 包的方法對類進行反射調用時,如果類還沒有進行過初始化,則需要先觸發其初始化。
  • 當初始化一個類的時候,如果發現其父類還沒有進行初始化,則需要先觸發其父類的初始化。
  • 當虛擬機啓動時,用戶需要指定一個要執行的主類,虛擬機會先執行該主類。

虛擬機規定只有這四種情況纔會觸發類的初始化,稱爲對一個類進行主動引用,除此之外所有引用類的方式都不會觸發其初始化,稱爲被動引用。下面舉一些例子來說明被動引用。

通過子類引用父類中的靜態字段,這時對子類的引用爲被動引用,因此不會初始化子類,只會初始化父類:

class Father{  
    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);  
    }  
}  

執行後輸出的結果如下:

父類被初始化
    33

對於靜態字段,只有直接定義這個字段的類纔會被初始化,因此,通過其子類來引用父類中定義的靜態字段,只會觸發父類的初始化而不會觸發子類的初始化。

常量在編譯階段會存入調用它的類的常量池中,本質上沒有直接引用到定義該常量的類,因此不會觸發定義常量的類的初始化:

class Const{  
    public static final String NAME = "我是常量";  
    static{  
        System.out.println("初始化Const類");  
    }  
}  

public class FinalTest{  
    public static void main(String[] args){  
        System.out.println(Const.NAME);  
    }  
}  

執行後輸出的結果如下:

我是常量

雖然程序中引用了 const 類的常量 NAME,但是在編譯階段將此常量的值“我是常量”存儲到了調用它的類 FinalTest 的常量池中,對常量 Const.NAME 的引用實際上轉化爲了 FinalTest 類對自身常量池的引用。也就是說,實際上 FinalTest 的 Class 文件之中並沒有 Const 類的符號引用入口,這兩個類在編譯成 Class 文件後就不存在任何聯繫了。

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

class Const{  
    static{  
        System.out.println("初始化Const類");  
    }  
}  

public class ArrayTest{  
    public static void main(String[] args){  
        Const[] con = new Const[5];  
    }  
}  

執行後不輸出任何信息,說明 Const 類並沒有被初始化。

但這段代碼裏觸發了另一個名爲“LLConst”的類的初始化,它是一個由虛擬機自動生成的、直接繼承於java.lang.Object 的子類,創建動作由字節碼指令 newarray 觸發,很明顯,這是一個對數組引用類型的初初始化,而該數組中的元素僅僅包含一個對 Const 類的引用,並沒有對其進行初始化。如果我們加入對 con 數組中各個 Const 類元素的實例化代碼,便會觸發 Const 類的初始化,如下:

class Const{  
    static{  
        System.out.println("初始化Const類");  
    }  
}  

public class ArrayTest{  
    public static void main(String[] args){  
        Const[] con = new Const[5];  
        for(Const a:con)  
            a = new Const();  
    }  
}  

這樣便會得到如下輸出結果:

初始化Const類

根據四條規則的第一條,這裏的 new 觸發了 Const 類。

最後看一下接口的初始化過程與類初始化過程的不同。

接口也有初始化過程,上面的代碼中我們都是用靜態語句塊來輸出初始化信息的,而在接口中不能使用“static{}”語句塊,但編譯器仍然會爲接口生成類構造器,用於初始化接口中定義的成員變量(實際上是 static final 修飾的全局常量)。

二者在初始化時最主要的區別是:當一個類在初始化時,要求其父類全部已經初始化過了,但是一個接口在初始化時,並不要求其父接口全部都完成了初始化,只有在真正使用到父接口的時候(如引用接口中定義的常量),纔會初始化該父接口。這點也與類初始化的情況很不同,回過頭來看第 2 個例子就知道,調用類中的 static final 常量時並不會 觸發該類的初始化,但是調用接口中的 static final 常量時便會觸發該接口的初始化。


發佈了46 篇原創文章 · 獲贊 35 · 訪問量 34萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章