深入理解JVM(5)——虚拟机类加载机制
在Class文件中描述的各種信息,最終都需要加載到虛擬機中之后才能運行和使用。而虛擬機中,而虛擬機如何加載這些Class文件?Class文件中的信息進入到虛擬機中會發生什么變化?本文將逐步解答這些問題。
類加載過程概覽
類從被加載到虛擬機內存中開始,到卸載出內存為止,它的整個生命周期包括以下7個階段:
- 加載(Loading)
- 驗證(Verification)
- 準備(Preparation)
- 解析(Resolution)
- 初始化(Initialization)
- 使用(Using)
- 卸載(Unloading)
其中前五個階段即為類加載的全過程。在后面會進行詳細的介紹。而驗證、準備、解析3個部分統稱為連接(Linking)。這7個階段的發生順序如下圖:
在上圖中,加載、驗證、準備、初始化和卸載這5個階段的順序是確定的,類的加載過程必須按照這種順序按部就班地開始(開始而不是完成,這些階段是互相交叉著進行的,在一個階段執行過程中就會激活另一個階段),而解析階段則不一定:它在某些情況下可以在初始化階段之后再開始,這是為了支持Java的運行時綁定(也稱為動態綁定或晚期綁定)。
類初始化的時機
對于類加載過程的第一個階段:加載,jvm規范中并沒有進行強制約束其開始時機,可交由jvm的具體實現來自由把握。但是對于初始化階段,jvm規范嚴格規定了有且只有下列5種情況必須對類進行“初始化”(很自然地,加載、驗證、準備需要在此之前開始):
- 遇到new、getstatic、putstatic、invokestatic這四條字節碼指令時,如果類沒有進行過初始化,則必須先觸發其初始化。最常見的生成這4條指令的場景是:使用new關鍵字實例化對象的時候;讀取或設置一個類的靜態字段(被final修飾、已在編譯期把結果放入常量池的靜態字段除外)的時候;以及調用一個類的靜態方法的時候。
- 使用?java.lang.reflect包的方法對類進行反射調用的時候,如果類沒有進行初始化,則需要先觸發其初始化。
- 當初始化一個類的時候,如果發現其父類還沒有進行過初始化,則需要先觸發其父類的初始化。
- 當虛擬機啟動時,用戶需要制定一個要執行的主類(包含main方法的那個類),虛擬機會先初始化這個主類;
- 當使用jdk1.7 的動態語言支持時,如果一個java.lang.invoke.MethodHandle實例最后的解析結果REF_getStatic,?REF_putStatic,?REF_invokeStatic?的方法句柄,并且這個方法句柄所對應的類沒有進行過初始化,則需要先觸發其初始化;
以上5種場景中的行為稱為對一個類進行主動引用。除此之外,所有引用類的方式都不會觸發初始化,稱為被動引用。被動引用的常見例子包括:
- 通過子類引用父類的靜態字段,不會導致子類初始化。
- 通過數組定義來引用類,不會觸發此類的初始化,如SuperClass[] sca = new SuperClass[10];。
- 常量在編譯階段會存入調用類的常量池中,本質上并沒有直接引用到定義常量的類,因此不會觸發定義常量的類的初始化。
接口的加載過程和類加載過程略有不同,它們真正的區別在于在前文提到的5種需要開始初始化場景中的第3種:當一個類在初始化時,要求其父類全部都已經初始化過了,但是一個接口在初始化時,并不要求其父接口全部都完成了初始化,只有在真正使用到父接口的時候(如引用接口中定義的常量)才會初始化。
類加載過程詳解
加載
加載是類加載(Class Loading)過程的一個階段,兩者不要混淆。虛擬機規范規定了在在加載階段,jvm需要完成以下三件事情:
- 通過一個類的全限定名來獲取定義此類的二進制字節流。
- 將這個字節流所代表的靜態存儲結構轉化為方法區的運行時存儲結構。
- 在內存中生成一個代表這個類的java.lang.Class對象,作為方法區這個類的各種數據的訪問入口。
這三點要求不算具體,在jvm實現時靈活度很大。例如上面的第一條,它沒有指明二進制字節流要從一個Class文件中獲取,準確地說沒有指明要從哪里獲取、怎樣獲取。這也為許多Java技術提供了基礎,例如:
- 從ZIP包讀取,這很常見,最終成為日后JAR、EAR、WAR格式的基礎。
- 從網絡中獲取,這種場景最典型的應用是Applet。
- 運行時計算生成,這種場景使用得最多得就是動態代理技術,在java.lang.reflect.Proxy中,就是用了ProxyGenerator.generateProxyClass的代理類的二進制字節流。
- 由其他文件生成,典型場景是JSP應用,即由JSP文件生成對應的Class類。
- 從數據庫讀取,這種場景相對少見,例如有些中間件服務器(如SAP Netweaver)可以選擇把程序安裝到數據庫中來完成程序代碼在集群間的分發。
非數組類的加載
相對于類加載過程的其他階段,一個非數組類的加載階段(準確地說,是加載階段中獲取類的二進制字節流的動作)是開發人員可控性最強的,因為加載階段既可以使用系統提供的引導類加載器完成,也可以由用戶自定義的類加載器完成,通過自定義類加載器去控制字節流的獲取方式,即重寫一個類加載器的loadClass()方法。關于類加載器的內容將在系列的下一篇文章中介紹。
數組類的加載
數組類本身不通過類加載器創建,它是由jvm直接創建的。但數組類的元素類型(Element Type,指的是數組去掉所有維度的類型)最終是要靠類加載器去創建,一個數據類C的創建過程遵循以下規則:
- 如果數組的組件類型(ComponentType,指的是數組去掉一個維度的類型)是引用類型,就遞歸采用本節中定義的加載過程去加載此組件類型,數組類將在加載該組件類型的類加載器的類名稱空間上被標識(這很重要,在下一篇文章中會講到,一個類必須與類加載器一起確定唯一性)。
- 如果數組的組件類型不是引用類型(例如int[]數組),Java虛擬機將會把數組類標記為與引導類加載器關聯。
- 數組類的可見性與它的組件類型的可見性一致,如果組件類型不是引用類型,那數組類的可見性將默認為public。
加載階段完成后,虛擬機外部的二進制字節流就按照虛擬機所需的格式存儲在方法區之中,方法區的數據存儲格式由虛擬機實現自行定義,虛擬機規范未規定此區域的具體數據結構。然后在內存中實例化一個java.lang.Class類的對象(并無明確規定是在Java 堆中,對于HotSpot虛擬機而言,Class對象比較特殊,它雖是對象,但存放在方法區里),這個對象將作為程序訪問方法區中的這些類型數據的外部接口。
驗證
驗證是連接階段的第一步,這一階段的目的是確保輸入的Class文件的字節流能正確地解析并存儲于方法區之內,格式上符合描述一個Java類型信息的要求,并且不會危害虛擬機自身的安全。驗證階段是否嚴謹,直接決定了Java虛擬機是否能承受惡意代碼的攻擊。 從整體上看,驗證階段大致上會完成下面四個階段的檢驗動作:文件格式驗證、元數據驗證、字節碼驗證、符號引用驗證。
1. 文件格式驗證
第一階段要驗證字節流是否符合Class文件格式的規范,并且能被當前版本的虛擬機處理。這一階段可能包括下面這些驗證點:
- 是否以魔數0xCAFEBABE開頭。
- 主次版本號是否在當前虛擬機的處理范圍之內
- 常量池的常量中是否有不被支持的常量類型(tag標志)。
- 指向常量的各種索引值中是否有指向不存在的常量或不符合類型的常量。
- Class文件中各個部分及文件本身是否有被刪除的或附加的其他信息。 ……
這階段的驗證是基于二進制字節流進行的,只有通過了這個階段的驗證后,字節流才會進入方法區中進行存儲,所以后面的3個驗證階段全部是基于方法區的存儲結構進行的,不會再直接操作字節流。
2. 元數據驗證
第二階段是對字節碼描述的信息(即類的元數據信息)進行語義分析,以保證其描述的信息符合Java語言規范的要求。例如下面這些驗證點:
- 該類是否有父類(除了java.lang.Object之外,所有的類都應有父類)
- 該類的父類是否繼承了不允許被繼承的類(final修飾的類)
- 若此類不是抽象類,是否實現了其父類或接口之中要求實現的所有方法 ……
該階段的主要目的是對類的元數據信息進行語義檢驗,保證不存在不符合Java語言規范的元數據信息。
3. 字節碼驗證
第三階段的主要目的是進行數據流和控制流分析,確定程序語義是合法的、符合邏輯的。在第二階段對元數據信息中的數據類型做完校驗之后,這個階段將對類的方法體進行校驗分析,以保證被校驗類的方法在運行時不會做出危害虛擬機安全的行為。例如:
- 保證任意時刻操作數棧的數據類型與指令代碼序列都能配合工作。
- 保證跳轉指令不會跳轉到方法體以外的字節碼指令上。
- 保證方法體中類型轉換是有效的,例如子類對象可以賦值給父類數據類型,但父類對象賦值給子類數據類型是危險和不合法的。 ……
4. 符號引用驗證
最后一個階段的校驗發生在虛擬機將符號引用轉化為直接引用的時候,這個轉化動作將在連接的第三階段——解析階段中發生。符號引用驗證可以看做是對類自身以外(常量池中的各種符號引用)的信息進行匹配性校驗,通常需要校驗下列內容:
- 符號引用中通過字符串描述的全限定名是否能找到對應的類。
- 指定的類中是否存在符合描述符與簡單名稱描述的方法與字段。
- 符號引用中的類、字段、方法的訪問性(private、protected、public、default)是否可被當前類訪問。 ……
符號引用的目的是確保解析動作能正常執行。
對于jvm的類加載機制來說,驗證階段是一個非常重要但不是一定必要(因為對運行期沒有影響)的階段。如果所運行的全部代碼都已經被反復驗證過,那么在實施階段就可以考慮使用`-Xverify:none`參數來關閉大部分的類驗證措施,以縮短虛擬機類加載的時間。
準備
準備階段的主要任務是如下兩點:
- 為類變量分配內存
- 設置類變量初始值
這些變量所使用的內存都將在方法區中分配。
首先,在準備階段進行內存分配的僅包括類變量(被static修飾的變量),而不包括實例變量,實例變量將會在對象實例化時隨著對象一起分配在Java堆中。
其次,這里所說的初始值“通常情況”下是數據類型的零值,假設一個類變量的定義為:
public static int value = 123;那變量value在準備階段過后的初始值為0而不是123,因為這時候尚未開始執行任何Java方法,而把value賦值為123的putstatic指令是程序被編譯后,存放于類構造器<clinit>()方法之中,所以把value賦值為123的動作在初始化階段才會執行。 值得注意的是,如果類字段的字段屬性中存在ConstantValue屬性,那在準備階段變量value就會被初始化為ConstantValue屬性所指定的值,假設上面類變量value的定義變為:
public static final int value = 123;編譯時Javac將會為value生成ConstantValue屬性,在準備階段虛擬機就會根據ConstantValue的設置將value賦值為123。
解析
解析階段是虛擬機將常量池內的符號引用替換為直接引用的過程。符號引用和直接引用的關聯如下:
- 符號引用(Symbol References): 符號引用以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可。符號引用與虛擬機實現的內存布局無關,引用的目標并不一定已經加載到內存中。各種虛擬機實現的內存布局可以各不相同,但是它們能接受的符號引用必須一致,因為符號引用的字面量形式明確定義在Java虛擬機規范的Class文件格式中。
- 直接引用(Direct References): 直接引用可以是直接目標的指針、相對偏移量或是一個能間接定位到目標的句柄。直接引用是和虛擬機實現的內存布局有關的,同一個符號引用在不同虛擬機實例上翻譯出來的直接引用一般不會相同。如果有了直接引用,那么引用的目標必定已經在內存中存在。
虛擬機規范并未規定解析動作發生的具體時間,僅要求在執行anewarray、checkcast、getfield、getstatic、instanceof、invokeinterface、invokespecial、invokestatic、invokevirtual、multianewarray、new、putfield和putstatic這13個用于操作符號引用的字節碼指令之前,先對它們所使用的符號引用進行解析。所以虛擬機實現可以根據需要來判斷到底是在類被加載器加載時就對常量池中的符號進行解析,還是等到一個符號引用將要被使用前才去解析它。
對同一個符號引用進行多次解析請求是很常見的,除 invokedynamic 指令外( invokedynamic指令是用于動態語言支持的,它所對應的引用稱為“動態調用點限定符”,必須等到程序實際運行到這條指令的時候,解析動作才能進行)虛擬機實現可能會對第一次解析的結果進行緩存(將直接引用保存在運行時常量池中),無論是否真正執行了多次解析動作,虛擬機實現必須保證在同一個實體中,如果一個符號引用之前已經被成功解析過,后續的引用解析請求就應當一直成功,反之亦然。
解析動作主要針對以下7類符號引用
- 類或接口
- 字段
- 類方法(靜態方法)
- 接口方法
- 方法類型
- 方法句柄
- 調用點限定符
其中后三種與java的動態語言支持息息相關。
初始化
類初始化階段是“類加載過程”中最后一步,在之前的階段,除了在加載階段用戶應用程序可以通過自定義類加載器參與之外,其它動作完全由虛擬機主導和控制,直到初始化階段,才真正開始執行類中定義的Java程序代碼(或者說是字節碼)。
在準備階段,變量已經賦過一次系統要求的初始值,而在初始化階段,根據程序員通過程序制定的主觀計劃去初始化類變量和其它資源,簡單說,初始化階段即虛擬機執行類構造器<clinit>()方法的過程。
下面來詳細講解<clinit>()方法是怎么生成的,首先來了解此方法執行過程中可能會影響到程序運行行為的特點和細節:
- <clinit>()方法是由編譯器自動收集類中所有類變量的賦值動作和靜態語句塊(static{} 塊)中的語句合并產生的,編譯器收集的順序由語句在源文件中出現的順序決定,特別注意的是,靜態語句塊只能訪問到定義在它之前的類變量,定義在它之后的類變量只能賦值,不能訪問。例如以下代碼:
- <clinit>()方法與類的構造函數(或者說實例構造器<init>()?方法)不同,不需要顯式的調用父類的()方法。虛擬機會自動保證在子類的<clinit>()方法運行之前,父類的<clinit>()方法已經執行結束。因此虛擬機中第一個執行<clinit>()方法的類肯定為java.lang.Object。
- 由于父類的<clinit>()方法先執行,也就意味著父類中定義的靜態語句塊要優于子類的變量賦值操作。例如以下代碼:
- <clinit>()方法對于類或接口不是必須的,如果一個類中不包含靜態語句塊,也沒有對類變量的賦值操作,編譯器可以不為該類生成<clinit>()方法。
- 接口中不可以使用靜態語句塊,但仍然有類變量初始化的賦值操作,因此接口與類一樣都會生成<clinit>()方法。但接口與類不同的是,執行接口的<clinit>()方法不需要先執行父接口的<clinit>()方法。只有當父接口中定義的變量使用時,父接口才會初始化。另外,接口的實現類在初始化時也一樣不會執行接口的<clinit>()方法。
- 虛擬機會保證一個類的<clinit>()方法在多線程環境下被正確的加鎖和同步,如果多個線程同時初始化一個類,只會有一個線程執行這個類的<clinit>()方法,其它線程都會阻塞等待,直到活動線程執行<clinit>()方法完畢。如果在一個類的<clinit>()方法中有耗時的操作,就可能造成多個進程阻塞,在實際過程中此種阻塞很隱蔽。
參考資料
- 《深入理解Java虛擬機——JVM高級特性與最佳實踐》-周志明
總結
以上是生活随笔為你收集整理的深入理解JVM(5)——虚拟机类加载机制的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 深入理解JVM(4)——如何优化Java
- 下一篇: 深入理解JVM(6)——类加载器