所謂的類加載機制就是JVM使用類加載器將編譯生成的Class文件動態加載到JVM的內存空間中,最終形成可以被JVM使用的Java類型。一般情況下,Java應用開發人員不需要直接同類加載器進行交互,Java虛擬機提供的默認類加載器就已經能夠滿足大多數情況了。但是,如果想要往更深方向延伸,如熱修復或者熱部署,了解Java類加載機制則是必經之路。本文所有示例都是基於JDK1.8.0_111,虛擬機版本Java HotSpot(TM) 64-Bit Server VM。
類加載的時機
類從被加載到內存中開始,直到被從內存中卸載為止,它的整個生命周期包括:驗證(Verification)、准備(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸載(Unloading)7個階段。其中驗證、准備、解析3個部分統稱為連接(Linking),這7個階段的發生順序如下圖所示:
加載、驗證、准備、初始化和卸載這5個階段的順序是確定的,類的加載過程必須按照這種順序按部就班地開始,而解析階段則不一定:它在某些情況下可以在初始化階段之后再開始,這是為了支持Java語言的運行時綁定(也稱為動態綁定或晚期綁定)。
實際上,什么時候開始執行類加載的第一個階段:加載?這點Java虛擬機並沒與強制約束,但是對於初始化階段,虛擬機規范則是嚴格規定了 有且只有5種情況必須立即對類進行“初始化”(而加載、驗證、准備自然需要在此之前開始)。這里的5中情況,通常被稱為類的主動引用。除此之外,所有類引用的方式都不會觸發類的初始化,稱為被動引用。
主動引用的5種類型
- 遇到new、getstatic、putstatic或invokestatic這4條字節碼指令時,如果類沒有進行過初始化,則需要先觸發其初始化。生成這4條指令的最常見的Java代碼場景是:使用new關鍵字實例化對象的時候、讀取或設置一個類的靜態字段(被final修飾、已在編譯期把結果放入常量池的靜態字段除外)的時候,以及調用一個類的靜態方法的時候。
- 使用java.lang.reflect包的方法對類進行反射調用的時候,如果類沒有進行過初始化,則需要先觸發其初始化。
- 當初始化一個類的時候,如果發現其父類還沒有進行過初始化,則需要先觸發其父類的初始化。但是一個接口在初始化時,並不要求其父接口全部都完成了初始化,只有在真正使用到父接口的時候(如引用接口中定義的常量)才會初始化。
- 當虛擬機啟動時,用戶需要指定一個要執行的主類(包含main()方法的那個類),虛擬機會先初始化這個主類。
- 當使用JDK 1.7的動態語言支持時,如果一個java.lang.invoke.MethodHandle實例最后的解析結果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,並且這個方法句柄所對應的類沒有進行過初始化,則需要先觸發其初始化。
被動應用示例
/**
* 在父類中定義靜態字段
*/
public class SuperClass { static { System.out.println("SuperClass init!"); } public static int value = 123; } public class SubClass extends SuperClass { static { System.out.println("SubClass init!"); } } public class MainTest { //非主動使用類的靜態字段 public static void main(String[] args) { System.out.println(SubClass.value); } }
當我們在eclipse中配置-XX:+TraceClassLoading參數之后,這樣在代碼調用時會跟蹤類的加載情況,部分輸出結果如下:
...
[Loaded java.lang.Void from C:\Program Files\Java\jdk1.8.0_111\jre\lib\rt.jar] [Loaded com.sunny.demo.SuperClass from file:/D:/workspace/java/002-class/bin/] [Loaded com.sunny.demo.SubClass from file:/D:/workspace/java/002-class/bin/] SuperClass init! 123 [Loaded java.lang.Shutdown from C:\Program Files\Java\jdk1.8.0_111\jre\lib\rt.jar] [Loaded java.lang.Shutdown$Lock from C:\Program Files\Java\jdk1.8.0_111\jre\lib\rt.jar]
從輸出結果“SuperClass init!”可以知道,這里雖然子類SubClass調用父類SuperClass的靜態字段value,但是並不會觸發子類SubClass初始化,而只是初始化了父類SuperClass。所以 對於靜態字段,只有直接定義這個字段的類才會被初始化 。通過輸出日志也可以知道在HotSpot虛擬機的實現中,子類雖然沒有被初始化,但是已經被加載。
public class MainTest { // 通過數組定義來引用類,不會觸發此類的初始化 public static void main(String[] args) { SuperClass[] sca = new SuperClass[10]; } }
在這里運行結果發現,代碼並沒有輸出“SuperClass init”,通過創建一個初始化SuperClass類型的數組並不會讓該類初始化。實際上這里仍然會觸發一個類的初始化,只是這個類是由虛擬機自動生成的、直
接繼承於java.lang.Object的子類,創建動作由字節碼指令newarray觸發。
public class ConstClass { static{ System.out.println("ConstClass init!"); } public static final String HELLO_WORLD="hello world"; } /** * 常量在編譯階段會存入調用類的常量池中,本質上並沒有直接引用到定義常量的類,因此不會觸發定義常量的類的初始化 */ public class MainTest { public static void main(String[] args) { System.out.println(ConstClass.HELLO_WORLD); } }
這里在代碼運行之后只是輸出了”hello world”,沒有輸出“ConstClass init!”。由於在編譯階段通過常量傳播優化,編譯器會將常量解析成調用類的本地拷貝。在示例中已經將HELLO_WORLD常量的一份拷貝存在了MainTest的常量池中,以后對常量HELLO_WORLD的引用實際上是引用了調用者本身MainTest常量池的常量,兩者已經不存在關聯關系了。
類加載的過程
加載
這里的加載只是類加載中的一個過程,更好理解的說法應該叫做裝載。如果要加載一個類型,虛擬機必須要完成3件事情。
- 通過一個類的全限定名獲取定義此類的二進制字節流。
- 解析這個二進制流為方法區的運行時數據結構。
- 創建一個該類的java.lang.Class對象,作為方法區這個類的各種數據的訪問入口。
由於虛擬機並沒有指明二進制字節流要從一個Class文件中獲取,准確的說沒有規定從哪里獲取怎樣獲取,以下都是可能獲取的方式之一:
- 從本地文件系統加載一個Java class 文件。
- 通過網絡下載一個Java class文件。
- 從ZIP、JAR、EAR、WAR格式的壓縮文件中獲取。
- 運行時生成,JDK提供的動態代理技術,在java.lang.reflect.Proxy中,就是用了ProxyGenerator.generateProxyClass來為特定接口生成形式為“*$Proxy”的代理類的二進制字節流。
- 由數據庫中讀取或者其它文件生成,如JSP等等。
有了這些二進制的字節流文件后,就可以使用類加載器進行加載了。在加載階段我們既可以使用系統提供的引導類加載器來完成,也可以由用戶自定義的類加載器去完成,可以通過定義自己的類加載器去控制字節流的獲取方式(即重寫一個類加載器的loadClass()方法)。Java有關類加載器的相關知識點在本文中不做過多敘述,后續另起一篇博文再做介紹。
加載階段完成之后,JVM就會把二進制字節流就按照自己所需的格式存儲在方法區之中。然后在內存中實例化一個java.lang.Class類的對象(並沒有明確規定是在Java堆中,對於HotSpot虛擬機而言,Class對象比較特殊,它雖然是對象,但是存放在方法區里面)。
加載階段與連接階段的部分內容(如一部分字節碼文件格式驗證動作)是交叉進行的,加載階段尚未完成,連接階段可能已經開始,但這些夾在加載階段之中進行的動作,仍然屬於連接階段的內容,這兩個階段的開始時間仍然保持着固定的先后順序。如Java類加載器在加載一個二進制文件時會執行一個checkName()的方法,用於校驗文件是否為空或者是否是有效的二進制名稱。
驗證
驗證是連接階段的第一步,這一階段的目的是為了確保Class文件的字節流中包含的信息符合當前虛擬機的要求,並且不會危害虛擬機自身的安全。一般驗證階段會完成4個階段的驗證工作:文件格式驗證、元數據驗證、字節碼驗證、符號引用驗證。
文件格式驗證主要是驗證字節流是否符合Class文件格式的規范,並且能被當前版本的虛擬機處理。如是否以魔術0xCAFEBABE開頭、主次版本號是否在當前虛擬機的處理范圍之內、常量池中的常量是否有不被支持的類型等等。該驗證階段的主要目的是保證輸入的字節流能正確地解析並存儲於方法區之內,格式上符合描述一個Java類型信息的要求。這階段的驗證是基於二進制字節流進行的,只有通過了這個階段的驗證后,字節流才會進入內存的方法區中進行存儲,所以后面的3個驗證階段全部是基於方法區的存儲結構進行的,不會再直接操作字節流。
元數據驗證是對字節碼描述的信息進行語義分析,以保證其描述的信息符合Java語言規范的要求。如這個類是否有父類、是否繼承了不允許被繼承的類,類中的字段、方法是否與父類產生矛盾等等。該階段主要目的是對類的元數據信息進行語義校驗,保證不存在不符合Java語言規范的元數據信息。
字節碼驗證是整個驗證過程中最復雜的一個階段,主要目的是通過數據流和控制流分析,確定程序語義是合法的、符合邏輯的。在第二階段對元數據信息中的數據類型做完校驗后,這個階段將對類的方法體進行校驗分析,保證被校驗類的方法在運行時不會做出危害虛擬機安全的事件。如保證任意時刻操作數棧的數據類型與指令代碼序列都能配合工作,例如不會出現類似這樣的情況:在操作棧放置了一個int類型的數據,使用時卻按long類型來加載入本地變量表中。
符號引用驗證主要是在虛擬機將符號引用轉化為直接引用的時候,這個轉化動作將在連接的第三階段——解析階段中發生。符號引用驗證的目的是確保解析動作能正常執行。
驗證階段是非常重要的,但不是必須的,它對程序運行期沒有影響,如果所引用的類經過反復驗證,那么可以考慮采用-Xverifynone參數來關閉大部分的類驗證措施,以縮短虛擬機類加載的時間。
准備
准備階段是正式為類變量分配內存並設置類變量初始值的階段,這些變量所使用的內存都將在方法區中進行分配。這時候進行內存分配的僅包括類變量(被static修飾的變量),而不包括實例變量,實例變量將會在對象實例化時隨着對象一起分配在堆中。其次,這里所說的初始值“通常情況”下是數據類型的零值,假設一個類變量的定義為:
public static int value=123;
那變量value在准備階段過后的初始值為0而不是123。因為這時候尚未開始執行任何java方法,而把value賦值為123的putstatic指令是程序被編譯后,存放於類構造器()方法之中,所以把value賦值為123的動作將在初始化階段才會執行。
至於“特殊情況”是指:
public static final int value=123;
即當類字段的字段屬性是ConstantValue時,會在准備階段初始化為指定的值,所以標注為final之后,value的值在准備階段初始化為123而非0。
解析
解析階段是虛擬機將常量池內的符號引用替換為直接引用的過程,解析動作主要針對類或接口、字段、類方法、接口方法、方法類型、方法句柄和調用點限定符7類符號引用進行