JVM-類加載過程

目錄

 

一.概述

二.類加載的時機

三.類加載的過程

3.1 加載

3.2 驗證

3.3 準備

3.4 解析

3.5 初始化


一.概述

虛擬機把描述類的數據從Class文件加載到內存,並對數據進行校驗,轉換解析和初始化,最終形成可以被虛擬機直接使用的Java類型,這就是虛擬機的類加載機制。

與那些在編譯時需要進行連接工作的語言不同,在Java語言裏面,類型的加載,連接和初始化過程都是在程序運行期間完成的,這種策略雖然會令類加載時稍微增加一些性能開銷,但是會爲Java應用程序提供高度靈活性,Java裏天生可以動態擴展的語言特性就是依賴運行期動態加載和動態連接這個特點實現的。

二.類加載的時機

類從被加載到虛擬機內存中開始,到卸載出內存爲止,它的整個生命週期包括:加載(Loading),驗證(Verification),準備(Preparation),解析(Resolution),初始化(Initialization),使用(Using)和卸載(Unloading)7個階段。其中驗證,準備,解析3個部分統稱爲連接(Linking)。

加載,驗證,準備,初始化和卸載這5個階段的順序是確定的,類的加載過程必須按照這種順序按部就班地開始,而解析階段則不一定:它在某些情況下可以在初始化階段之後再開始,這是爲了支持Java語言的運行時綁定(也稱爲動態綁定或晚期綁定)。按部就班的開始是指這些階段通常是互相交叉地混合式進行的,通常會在一個階段執行的過程調用,激活另外一個階段。

什麼情況下需要開始類加載過程的第一個階段:加載?Java虛擬機規範中並沒有進行強制規約,這點可以交給虛擬機的具體實現來自由把握。但是對於初始化階段,虛擬機規範則是嚴格規定了有且只有5種情況必須立即對類進行“初始化”(而加載,驗證,準備自然需要在此之前開始):

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

對於這5種會觸發類進行初始化的場景,虛擬機規範中使用一個很強烈的限定語:“有且只有”,這5種場景中的行爲稱爲對一個類進行主動引用。除此之外,所有引用類的方式都不會觸發初始化,稱爲被動引用。

三.類加載的過程

3.1 加載

“加載”是“類加載”過程的一個階段。在加載階段,虛擬機需要完成以下3件事情:

1)通過一個類的全限定名來獲取定義此類的二進制字節流。

2)將這個字節流所代表得的靜態存儲結構轉化爲方法區的運行時數據結構。

3)在內存中生成一個代表這個類的java.lang.Class對象,作爲方法區這個類的各種數據的訪問入口。

虛擬機規範的這3點要求並不算具體,因此虛擬機實現與具體應用的靈活度都是相當大的。例如“通過一個類的全限定名來獲取此類的二進制字節流”這條,它沒有指明二進制字節流要從一個Class文件中獲取,準備來說是根本沒有指定要從哪裏獲取,怎樣獲取。比如:比較常見的就是從 ZIP 包中讀取(日後出現的JAR、EAR、WAR格式的基礎)、其他文件生成(典型應用就是JSP)等等。

相對於類加載過程的其他階段,一個非數組類的加載階段(準確來說,是加載階段中獲得類的二進制字節流的動作)是開發人員可控性最強的,因爲加載階段既可以使用系統提供的引導類加載器完成,也可以由用戶自定義的類加載器區去完成,開發人員可以通過定義自己的類加載器區控制字節流的獲取方式。

對於數組類而言,情況就有所不同,數組類本身不通過類加載器創建,它是由Java虛擬機直接創建的。但數組類與類加載器仍然有很密切的關係,因爲數組類的元素類型最終是要靠類加載器去創建,一個數組類創建過程遵循以下規則:

  • 如果數組的組件類型(指的是數組去掉一個維度的類型)是引用類型,那就遞歸採用定義的記載過程去加載這個組件類型,數組C將在加載該組件類型的類加載器的類名稱空間上被標識。
  • 如果數組的組件類型不是引用類型,Java虛擬機將會把數組C標記爲與引導類加載器關聯。
  • 數組類的可見性與它的組件類型的可見性是一致,如果組件類型不是引用類型,那數組的可見性將默認爲public。

加載階段完成之後,虛擬機外部的二進制字節流就按照虛擬機所需的格式存儲在方法區之中,方法區中的數據存儲格式由虛擬機實現自行定義,虛擬機規範未規定此區域的具體數據結構。然後在內存中實例化一個java.lang.Class類的對象,這個對象將作爲程序訪問方法區中這些類型數據的外部接口。

加載階段與連接階段的部分內容是交叉進行的,加載階段尚未完成,連接階段可能已經開始,但這些夾在加載階段之中進行的動作,仍然屬於連接階段的內容。這兩個階段的開始時間仍然保持固定的先後順序。

3.2 驗證

驗證是連接階段的第一步,這個階段的目的是爲了確保Class文件的字節流中包含的信息符合當前虛擬機的要求,並且不會危害虛擬機自身的安全。

Java語言本身是相對安全的語言(相對於C/C++),使用純粹的Java代碼無法做到諸如訪問數組邊界之外的數據,將一個對象類型轉型爲它併爲實現的類型,跳轉到不存在的代碼行之類的事情,如果這樣做了,編譯器將拒絕編譯。但前面已經說過,Class文件不一定要求用Java源碼編譯而來,可以使用任何途徑產生,甚至包括用十六進制編輯器直接編寫來產生Class文件。在字節碼語言層面上,上述Java代碼無法做到的事情都是可以實現的,至少語義是可以表達出來的。虛擬機如果不檢查輸入的字節流,對其完全信任的話,很可能會因爲載入了有害的字節流而導致系統奔潰,所以驗證是虛擬機對自身保護的一項重要工作。

規範列舉了一些Class文件格式中的靜態和結構化約束,如果驗證到輸入的字節流不符合Class文件格式的約束,虛擬機就應拋出一個java.lang.VerifyError異常或其子類異常。

驗證階段大致上會完成下面4個階段的校驗動作:

3.2.1 文件格式驗證

第一階段要驗證字節流是否符合Class文件格式的規範,並且能被當前版本的虛擬機處理。這一階段可能包括下面這些驗證點:

  • 是否以魔數0xCAFFBABE開頭。
  • 主次版本號是否在當前虛擬機處理範圍之內。
  • 常量池中的常量是否有不被支持的常量類型(檢查常量tag標誌)。
  • 指向常量的各種索引值中是否有不存在的常量或不符合類型的常量。
  • ......

該驗證階段的主要目的是保證輸入的字節流能正確地解析並存儲於方法區之內,格式上符合描述一個Java類型信息的要求。這階段的驗證是基於二進制字節流進行的,只有通過這個階段的驗證後,字節流纔會進入內存的方法區中進行存儲,所以後面的3個驗證階段全部是基於方法區的存儲結構進行的,不會再直接操作字節流。

3.2.2 元數據驗證

第二階段是對字節碼描述的信息進行語義分析,以保證其描述的信息符合Java語言規範的要求,這個階段可能包括的驗證點如下:

  • 這類是否有父類。
  • 這個類的父類是否繼承了不允許被繼承的類(被final修飾的類)。
  • 如果這個類不是抽象類,是否實現了其父類或接口之中要求實現的方法。
  • 類中的字段,方法是否與父類產生矛盾(例如覆蓋了父類的final字段,或者出現了不符合規則的方法重載,例如方法參數一致,但返回值類型不同等)。
  • ....

第二階段的主要目的是對類的元數據信息進行語義校驗,保證不存在不符合Java語言規範的元數據信息。

3.2.3 字節碼驗證

第三個階段是整個驗證過程中最複雜的一個階段,主要目的是通過數據流和控制流分析,確定程序語義是合法的,符合邏輯的。這個階段是將對類的方法體進行檢驗分析,保證校驗類的方法在運行時不會做出危害虛擬機安全的事件。例如:

  • 保證任意時刻操作數棧的數據類型與指令代碼序列都能配合工作。
  • 保證跳轉指令不會跳轉到方法體外的字節碼指令上。
  • 保證方法體中的類型轉換是有效的。
  • .....

如果一個類方法體的字節碼沒有通過字節碼驗證,那肯定是有問題的。但如果一個方法體通過了字節碼驗證,也不能說明其一定就是安全的。

由於數據流驗證的高複雜性,虛擬機設計團隊爲了避免過多的時間消耗在字節碼驗證階段,在JDK1.6之後的javac編譯器和Java虛擬機中進行了一項優化,給方法體中的Code屬性的屬性表中增加了一項名爲“StackMapTable”的屬性,這項屬性描述了方法體中的所有基本塊開始時的本地變量表和操作棧應有的狀態,在字節碼驗證期間,就不需要根據程序推導這些 狀態的合法性,只需要檢查StackMapTable屬性中的記錄是否合法即可。這樣將字節碼驗證的類型推導轉變爲類型的檢查從而節省一些時間。

3.2.4 符號引用驗證

最後一個階段的校驗發生在虛擬機將符號引用轉化爲直接引用的時候,這個轉化動作將在連接的第三個階段-解析階段中發生。符號引用驗證可以看做是對類自身以外(常量池中的各種符號引用)的信息進行匹配性校驗,通常需要校驗下列內容:

  • 符號引用中通過字符串描述的全限定名是否能找到對應的類。
  • 在指定類中是否存在符合方法的字段描述符以及簡單名稱所描述的方法和字段。
  • 符合引用中的類,字段,方法的訪問性是否能被當前類訪問。
  • ....

符合引用驗證的目的是確保解析動作能正常執行,如果無法通過符號引用驗證,那麼將會拋出一個java.lang.IncompatibleClassChangeError異常的子類,如java.lang.IlleagalAccessError。

3.3 準備

準備階段是正式爲類變量分配內存並設置類變量初始值的階段,這些變量所使用的內存都在方法區中進行分配。這個階段中有兩個容易混淆的概念需要強調一下,首先,這時候進行內存分配的僅包括類變量(被static修飾的變量),而不包括實例變量,實例變量將會在對象實例化時隨着對象一起分配在Java堆中。其次,這裏說的初始值,通常情況下是指數據類型的零值。比如我們定義了public static int value=111 ,那麼 value 變量在準備階段的初始值就是 0 而不是111(初始化階段纔會賦值)。特殊情況:比如給 value 變量加上了 fianl 關鍵字public static  final int value=111,那麼準備階段 value 的值就被賦值爲 111。

基本數據類型的零值:

åºæ¬æ°æ®ç±»åçé¶å¼

3.4 解析

解析階段是虛擬機將常量池內的符號引用替換成直接引用的過程。

  • 符號引用:符號引用以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可。符號引用與虛擬機實現的內部佈局無關,引用的目標並不一定已經加載到內存中。各種虛擬機實現的內部佈局可以各不相同,但是它們能接受的符號引用必須都是一致的,因爲符號引用的字面量形式明確定義在Java虛擬機規範的Class文件格式中。
  • 直接引用:直接引用可以直接指向目標的指針,相對偏移量或是一個能間接定位到目標的句柄。直接引用是和虛擬機實現的內存佈局相關的,同一個符號引用在不同虛擬機實例上翻譯出來的直接引用一般不會相同。如果有了直接引用,那引用的目標必定出現在內存中。

虛擬機規範之中併爲規定解析階段發生的具體時間,只要求了在執行16個用於操作符號引用的字節碼指令之前,先對它們所使用的符號引用進行解析。所以虛擬機實現可以根據需要來判斷到底是在類被加載器加載時就對常量池中的符號引用進行解析,還是等到一個符號引用將要被使用採取解析它。

對同一個符號引用進行多次解析請求是很常見的事情,除invokedynamic指令之外,虛擬機實現可以對第一次解析結果進行緩存(在運行時常量池中記錄直接引用,並把常量標識爲已解析狀態)從未避免解析動作重複進行。無論是否真正執行了多次解析動作,虛擬機需要保證的是在同一個實體中,如果一個符號引用之前已經被成功解析過,那麼後續的引用解析請求就應當一直成功;同樣的,如果第一次解析失敗了,那麼其他指令對這個符號的解析請求也應該收到相同的異常。

對於invokedynamic指令,上面規則則不成立。當碰到某個前面已經由invokedynamic指令觸發過的解析的符號引用時,並不意味着這個解析結果對於其他invokedynamic也同樣生效。因爲invokedynamic指令的目的本來就是用於動態語言支持,它所對應的引用稱爲“動態調用點限定符”,這裏動態的含義就是必須等到程序實際運行到這條指令的時候,解析動作才能進行。相對的,其他可觸發解析的指令都是靜態的,可以在剛剛完成加載階段,還沒有開始執行代碼時就進行解析。

解析動作主要針對類或接口,字段,類方法,接口方法,方法類型,方法句柄和調用點限定符7類符號引用進行。

以類或接口的解析爲例:

假設當前代碼所處的類爲D,如果要把一個從未解析過的符號引用N解析爲一個類或者接口C的直接引用,那麼虛擬機完成整個解析的過程需要以下三個步驟:

1)如果C不是一個數組類型,那虛擬機將會把代表N的全限定名傳遞給D的類加載器去加載這個類C。在加載過程中,由於元數據驗證,字節碼驗證的需要,又可能觸發其他相關類的加載動作,例如加載這個類的父類或者實現的接口。一旦這個加載過程出現了任何異常,解析過程就宣告失敗。

2)如果C是一個數組類型,並且數組的元素類型爲對象,也就是N的描述符會是“[Ljava/lang/Integer”的形式,那將會按照第一點的規則加載數組元素類型。如果N的描述符如前面所假設的形式,需要加載的元素類型就是“java.lang.Integer”,接着由虛擬機生成一個代表此數組維度和元素的數據對象

3)如果上面的步驟沒有出現任何異常現象,那麼C在虛擬機中已經成爲一個有效的類或接口,但在解析完成之前還要進行符號引用驗證,確認D是否具備對C的訪問權限。如果不具備訪問權限,將拋出java.lang.IllegalAccessError異常。

3.5 初始化

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

在準備階段,變量已經賦過一次系統要求的初始值,而在初始化階段,則根據程序員通過程序定製的主觀計劃去初始化類變量和其他資源,或者說可以從另外一個角度來表達:初始化階段是執行類構造器<clinit>方法的過程。

  • <clinit>方法是由編譯器自動收集類中的所有類變量的賦值動作和靜態語句塊中的語句合併而產生的,編譯器收集的順序是由語句在源文件中出現的順序所決定的,靜態語句塊只能訪問到定義在靜態語句塊之前的變量,定義在它之後的變量,在前面的靜態語句庫可以賦值,但是不能訪問。
  • <clinit>方法與類的構造函數不同,它不需要顯式調用父類構造器,虛擬機會保證在子類<clinit>方法執行之前,父類的<clinit>方法已經執行完畢。因此在虛擬機中第一個被執行的<clinit>方法的類肯定是java.lang.Object。
  • 由於父類的<clinit>方法先執行,也就意味着父類的中定義的靜態語句塊要優先子類的變量賦值操作。
  • <clinit>方對於類或接口來說並不是必需的。如果一個類中沒有靜態語句塊,也沒有對變量的賦值操作,那麼編譯器可以不爲這個類生成<clinit>方法。
  • 接口中不能使用靜態語句塊,但仍然 有變量初始化的賦值操作,因此接口與類一樣都會生成<clinit>方法。但接口與類不同的是,執行接口的<clinit>方法不需要先執行父類接口的<clinit>方法。只有當父類接口定義的變量使用時,父類纔會初始化。另外,接口的實現類在初始化時也一樣不會執行接口的<clinit>方法。
  • 虛擬機會保證一個類的<clinit>方法在多線程環境中被正確地加載,同步。如果多個線程同時去初始化一個類,那麼只會一個線程區執行這個類的<clinit>方法,其他線程都需要阻塞等待,直到活動線程執行<clinit>方法完畢。

虛擬機規範則是嚴格規定了有且只有5種情況必須立即對類進行“初始化”(而加載,驗證,準備自然需要在此之前開始):

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

對於這5種會觸發類進行初始化的場景,虛擬機規範中使用一個很強烈的限定語:“有且只有”,這5種場景中的行爲稱爲對一個類進行主動引用。除此之外,所有引用類的方式都不會觸發初始化,稱爲被動引用。

 

 

 

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章