虛擬機類載入的時機和過程 一、類載入的時機 類從被載入到虛擬機記憶體中開始,到卸載出記憶體為止,它的整個生命周期包括:載入(Loading)、驗證(Verification)、準備(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸載( ...
一、類載入的時機
類從被載入到虛擬機記憶體中開始,到卸載出記憶體為止,它的整個生命周期包括:載入(Loading)、驗證(Verification)、準備(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸載(Unloading)7個階段。其中驗證、準備、解析3個部分統稱為連接(Linking),這7個階段的發生順序如圖7-1所示。
上圖中載入、驗證、準備、初始化和卸載這5個階段的順序是確定的,類的載入過程必須按照這種順序按部就班地開始,而解析階段則不一定:它在某些情況下可以在初始化階段之後再開始,這是為了支持Java語言的運行時綁定(也稱為動態綁定或晚期綁定)
什麼情況下需要開始類載入過程的第一個階段:載入?Java虛擬機規範中並沒有進行強制約束,這點可以交給虛擬機的具體實現來自由把握。但是對於初始化階段,虛擬機規範則是嚴格規定了有且只有5種情況必須立即對類進行“初始化”(而載入、驗證、準備自然需要在此之前開始):
(1)遇到new、getstatic、putstatic或invokestatic這4條位元組碼指令時,如果類沒有進行過初始化,則需要先觸發其初始化。生成這4條指令的最常見的Java代碼場景是:使用new關鍵字實例化對象的時候、讀取或設置一個類的靜態欄位(被final修飾、已在編譯期把結果放入常量池的靜態欄位除外)的時候,以及調用一個類的靜態方法的時候。
(2)使用java.lang.reflect包的方法對類進行反射調用的時候,如果類沒有進行過初始化,則需要先觸發其初始化。
(3)當初始化一個類的時候,如果發現其父類還沒有進行過初始化,則需要先觸發其父類的初始化。
(4)當虛擬機啟動時,用戶需要指定一個要執行的主類(包含main()方法的那個類),虛擬機會先初始化這個主類。(5)當使用JDK 1.7的動態語言支持時,如果一個
java.lang.invoke.MethodHandle實例最後的解析結果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,並且這個方法句柄所對應的類沒有進行過初始化,則需要先觸發其初始化。
對於這5種會觸發類進行初始化的場景,虛擬機規範中使用了一個很強烈的限定語:“有且只有”,這5種場景中的行為稱為對一個類進行主動引用。除此之外,所有引用類的方式都不會觸發初始化,稱為被動引用。
介面的載入過程與類載入過程稍有一些不同,針對介面需要做一些特殊說明:介面也有初始化過程,這點與類是一致的,上面的代碼都是用靜態語句塊“static{}”來輸出初始化信息的,而介面中不能使用“static{}”語句塊,但編譯器仍然會為介面生成“<clinit>()”類構造器[插圖],用於初始化介面中所定義的成員變數。介面與類真正有所區別的是前面講述的5種“有且僅有”需要開始初始化場景中的第3種:當一個類在初始化時,要求其父類全部都已經初始化過了,但是一個介面在初始化時,並不要求其父介面全部都完成了初始化,只有在真正使用到父介面的時候(如引用介面中定義的常量)才會初始化。
二、類載入的過程
1、 載入
載入”是“類載入”(Class Loading)過程的一個階段,希望讀者沒有混淆這兩個看起來很相似的名詞。在載入階段,虛擬機需要完成以下3件事情:
(1)通過一個類的全限定名來獲取定義此類的二進位位元組流。
(2)將這個位元組流所代表的靜態存儲結構轉化為方法區的運行時數據結構。
(3)在記憶體中生成一個代表這個類的java.lang.Class對象,作為方法區這個類的各種數據的訪問入口。
虛擬機規範的這3點要求其實並不算具體,因此虛擬機實現與具體應用的靈活度都是相當大的。Java發展歷程中,充滿創造力的開發人員則在這個“舞臺”上玩出了各種花樣,許多舉足輕重的Java技術都建立在這一基礎之上,例如:
(1)從ZIP包中讀取,這很常見,最終成為日後JAR、EAR、WAR格式的基礎。
(2)從網路中獲取,這種場景最典型的應用就是Applet。
(3)運行時計算生成,這種場景使用得最多的就是動態代理技術,在java.lang.reflect.
(3)]Proxy中,就是用了
ProxyGenerator.generateProxyClass來為特定介面生成形式為“*$Proxy”的代理類的二進位位元組流。
(4)由其他文件生成,典型場景是JSP應用,即由JSP文件生成對應的Class類。
(5)從資料庫中讀取,這種場景相對少見些,例如有些中間件伺服器(如SAP Netweaver)可以選擇把程式安裝到資料庫中來完成程式代碼在集群間的分發。
……
載入階段完成後,虛擬機外部的二進位位元組流就按照虛擬機所需的格式存儲在方法區之中,方法區中的數據存儲格式由虛擬機實現自行定義,虛擬機規範未規定此區域的具體數據結構。然後在記憶體中實例化一個java.lang.Class類的對象(並沒有明確規定是在Java堆中,對於HotSpot虛擬機而言,Class對象比較特殊,它雖然是對象,但是存放在方法區裡面),這個對象將作為程式訪問方法區中的這些類型數據的外部介面。
載入階段與連接階段的部分內容(如一部分位元組碼文件格式驗證動作)是交叉進行的,載入階段尚未完成,連接階段可能已經開始,但這些夾在載入階段之中進行的動作,仍然屬於連接階段的內容,這兩個階段的開始時間仍然保持著固定的先後順序。
2 、驗證
驗證是連接階段的第一步,這一階段的目的是為了確保Class文件的位元組流中包含的信息符合當前虛擬機的要求,並且不會危害虛擬機自身的安全。
從整體上看,驗證階段大致上會完成下麵4個階段的檢驗動作:文件格式驗證、元數據驗證、位元組碼驗證、符號引用驗證。
(1)文件格式驗證
第一階段要驗證位元組流是否符合Class文件格式的規範,並且能被當前版本的虛擬機處理。這一階段可能包括下麵這些驗證點:
- 是否以魔數0xCAFEBABE開頭。
- 主、次版本號是否在當前虛擬機處理範圍之內。
- 常量池的常量中是否有不被支持的常量類型(檢查常量tag標誌)。
- 指向常量的各種索引值中是否有指向不存在的常量或不符合類型的常量。
- CONSTANT_Utf8_info型的常量中是否有不符合UTF8編碼的數據。
- Class文件中各個部分及文件本身是否有被刪除的或附加的其他信息。
- ……
實際上,第一階段的驗證點還遠不止這些,上面這些只是從HotSpot虛擬機源碼[插圖]中摘抄的一小部分內容,該驗證階段的主要目的是保證輸入的位元組流能正確地解析並存儲於方法區之內,格式上符合描述一個Java類型信息的要求。這階段的驗證是基於二進位位元組流進行的,只有通過了這個階段的驗證後,位元組流才會進入記憶體的方法區中進行存儲,所以後面的3個驗證階段全部是基於方法區的存儲結構進行的,不會再直接操作位元組流。
(2)元數據驗證
第二階段是對位元組碼描述的信息進行語義分析,以保證其描述的信息符合Java語言規範的要求,這個階段可能包括的驗證點如下:
- 這個類是否有父類(除了java.lang.Object之外,所有的類都應當有父類)。
- 這個類的父類是否繼承了不允許被繼承的類(被final修飾的類)。
- 如果這個類不是抽象類,是否實現了其父類或介面之中要求實現的所有方法。
- 類中的欄位、方法是否與父類產生矛盾(例如覆蓋了父類的final欄位,或者出現不符合規則的方法重載,例如方法參數都一致,但返回值類型卻不同等)。
……
第二階段的主要目的是對類的元數據信息進行語義校驗,保證不存在不符合Java語言規範的元數據信息。
(3)位元組碼驗證
第三階段是整個驗證過程中最複雜的一個階段,主要目的是通過數據流和控制流分析,確定程式語義是合法的、符合邏輯的。在第二階段對元數據信息中的數據類型做完校驗後,這個階段將對類的方法體進行校驗分析,保證被校驗類的方法在運行時不會做出危害虛擬機安全的事件,例如:
- 保證任意時刻操作數棧的數據類型與指令代碼序列都能配合工作,例如不會出現類似
- 這樣的情況:在操作棧放置了一個int類型的數據,使用時卻按long類型來載入入本地變數表中。
- 保證跳轉指令不會跳轉到方法體以外的位元組碼指令上。
- 保證方法體中的類型轉換是有效的,例如可以把一個子類對象賦值給父類數據類型,這是安全的,但是把父類對象賦值給子類數據類型,甚至把對象賦值給與它毫無繼承關係、完全不相干的一個數據類型,則是危險和不合法的。
……
(4)符號引用驗證
最後一個階段的校驗發生在虛擬機將符號引用轉化為直接引用的時候,這個轉化動作將在連接的第三階段——解析階段中發生。符號引用驗證可以看做是對類自身以外(常量池中的各種符號引用)的信息進行匹配性校驗,通常需要校驗下列內容:
- 符號引用中通過字元串描述的全限定名是否能找到對應的類。
- 在指定類中是否存在符合方法的欄位描述符以及簡單名稱所描述的方法和欄位
- 符號引用中的類、欄位、方法的訪問性(private、protected、public、default)是否可被當前類訪問。
……
符號引用驗證的目的是確保解析動作能正常執行,如果無法通過符號引用驗證,那麼將會拋出一個
java.lang.IncompatibleClassChangeError異常的子類,如java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError等。對於虛擬機的類載入機制來說,驗證階段是一個非常重要的、但不是一定必要(因為對程式運行期沒有影響)的階段。如果所運行的全部代碼(包括自己編寫的及第三方包中的代碼)都已經被反覆使用和驗證過,那麼在實施階段就可以考慮使用-Xverify:none參數來關閉大部分的類驗證措施,以縮短虛擬機類載入的時間。
3 、準備
準備階段是正式為類變數分配記憶體並設置類變數初始值的階段,這些變數所使用的記憶體都將在方法區中進行分配。這個階段中有兩個容易產生混淆的概念需要強調一下,首先,這時候進行記憶體分配的僅包括類變數(被static修飾的變數),而不包括實例變數,實例變數將會在對象實例化時隨著對象一起分配在Java堆中。其次,這裡所說的初始值“通常情況”下是數據類型的零值,假設一個類變數的定義為:
public static int value =123;
那變數value在準備階段過後的初始值為0而不是123,因為這時候尚未開始執行任何Java方法,而把value賦值為123的putstatic指令是程式被編譯後,存放於類構造器<clinit>()方法之中,所以把value賦值為123的動作將在初始化階段才會執行。
那變數value在準備階段過後的初始值為0而不是123,因為這時候尚未開始執行任何Java方法,而把value賦值為123的putstatic指令是程式被編譯後,存放於類構造器<clinit>()方法之中,所以把value賦值為123的動作將在初始化階段才會執行。
Java中的基本數據類型有8種:byte、short、int、long、float、double、char和boolean。它們的預設值如下所示:
- byte:0
- short:0
- int:0
- long:0L
- float:0.0f
- double:0.0d
- char:‘\u0000’
- boolean:false
上面提到,在“通常情況”下初始值是零值,那相對的會有一些“特殊情況”:如果類欄位的欄位屬性表中存在ConstantValue屬性,那在準備階段變數value就會被初始化為ConstantValue屬性所指定的值,假設上面類變數value的定義變為:
public static final int value =123;
編譯時Javac將會為value生成ConstantValue屬性,在準備階段虛擬機就會根據ConstantValue的設置將value賦值為123。
4 、解析
解析階段是虛擬機將常量池內的符號引用替換為直接引用的過程,符號引用在前一章講解Class文件格式的時候已經出現過多次,在Class文件中它以CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等類型的常量出現,那解析階段中所說的直接引用與符號引用又有什麼關聯呢?
符號引用(Symbolic References)
符號引用以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可。符號引用與虛擬機實現的記憶體佈局無關,引用的目標並不一定已經載入到記憶體中。各種虛擬機實現的記憶體佈局可以各不相同,但是它們能接受的符號引用必須都是一致的,因為符號引用的字面量形式明確定義在Java虛擬機規範的Class文件格式中。
直接引用(Direct References)
直接引用可以是直接指向目標的指針、相對偏移量或是一個能間接定位到目標的句柄。直接引用是和虛擬機實現的記憶體佈局相關的,同一個符號引用在不同虛擬機實例上翻譯出來的直接引用一般不會相同。如果有了直接引用,那引用的目標必定已經在記憶體中存在。
- 類或介面的解析
假設當前代碼所處的類為D,如果要把一個從未解析過的符號引用N解析為一個類或介面C的直接引用,那虛擬機完成整個解析的過程需要以下3個步驟:
1)如果C不是一個數組類型,那虛擬機將會把代表N的全限定名傳遞給D的類載入器去載入這個類C。在載入過程中,由於元數據驗證、位元組碼驗證的需要,又可能觸發其他相關類的載入動作,例如載入這個類的父類或實現的介面。一旦這個載入過程出現了任何異常,解析過程就宣告失敗。
2)如果C是一個數組類型,並且數組的元素類型為對象,也就是N的描述符會是類似“[Ljava/lang/Integer”的形式,那將會按照第1點的規則載入數組元素類型。如果N的描述符如前面所假設的形式,需要載入的元素類型就是“java.lang.Integer”,接著由虛擬機生成一個代表此數組維度和元素的數組對象。
3)如果上面的步驟沒有出現任何異常,那麼C在虛擬機中實際上已經成為一個有效的類或介面了,但在解析完成之前還要進行符號引用驗證,確認D是否具備對C的訪問許可權。如果發現不具備訪問許可權,將拋出
java.lang.IllegalAccessError異常。
- 欄位解析
要解析一個未被解析過的欄位符號引用,首先將會對欄位表內class_index項中索引的CONSTANT_Class_info符號引用進行解析,也就是欄位所屬的類或介面的符號引用。如果在解析這個類或介面符號引用的過程中出現了任何異常,都會導致欄位符號引用解析的失敗。如果解析成功完成,那將這個欄位所屬的類或介面用C表示,虛擬機規範要求按照如下步驟對C進行後續欄位的搜索。
1)如果C本身就包含了簡單名稱和欄位描述符都與目標相匹配的欄位,則返回這個欄位的直接引用,查找結束。
2)否則,如果在C中實現了介面,將會按照繼承關係從下往上遞歸搜索各個介面和它的父介面,如果介面中包含了簡單名稱和欄位描述符都與目標相匹配的欄位,則返回這個欄位的直接引用,查找結束。
3)否則,如果C不是java.lang.Object的話,將會按照繼承關係從下往上遞歸搜索其父類,如果在父類中包含了簡單名稱和欄位描述符都與目標相匹配的欄位,則返回這個欄位的直接引用,查找結束。
4)否則,查找失敗,拋出
java.lang.NoSuchFieldError異常。
- 類方法解析
類方法解析的第一個步驟與欄位解析一樣,也需要先解析出類方法表的class_index[插圖]項中索引的方法所屬的類或介面的符號引用,如果解析成功,我們依然用C表示這個類,接下來虛擬機將會按照如下步驟進行後續的類方法搜索。
1)類方法和介面方法符號引用的常量類型定義是分開的,如果在類方法表中發現class_index中索引的C是個介面,那就直接拋出
java.lang.IncompatibleClassChangeError異常。
2)如果通過了第1步,在類C中查找是否有簡單名稱和描述符都與目標相匹配的方法,如果有則返回這個方法的直接引用,查找結束。
3)否則,在類C的父類中遞歸查找是否有簡單名稱和描述符都與目標相匹配的方法,如果有則返回這個方法的直接引用,查找結束。
4)否則,在類C實現的介面列表及它們的父介面之中遞歸查找是否有簡單名稱和描述符都與目標相匹配的方法,如果存在匹配的方法,說明類C是一個抽象類,這時查找結束,拋出
java.lang.AbstractMethodError異常。
5)否則,宣告方法查找失敗,拋出
java.lang.NoSuchMethodError。
最後,如果查找過程成功返回了直接引用,將會對這個方法進行許可權驗證,如果發現不具備對此方法的訪問許可權,將拋出
java.lang.IllegalAccessError異常。
- 介面方法解析
介面方法也需要先解析出介面方法表的class_index項中索引的方法所屬的類或介面的符號引用,如果解析成功,依然用C表示這個介面,接下來虛擬機將會按照如下步驟進行後續的介面方法搜索。
1)與類方法解析不同,如果在介面方法表中發現class_index中的索引C是個類而不是介面,那就直接拋出
java.lang.IncompatibleClassChangeError異常。
2)否則,在介面C中查找是否有簡單名稱和描述符都與目標相匹配的方法,如果有則返回這個方法的直接引用,查找結束。
3)否則,在介面C的父介面中遞歸查找,直到java.lang.Object類(查找範圍會包括Object類)為止,看是否有簡單名稱和描述符都與目標相匹配的方法,如果有則返回這個方法的直接引用,查找結束。
4)否則,宣告方法查找失敗,拋出java.lang.NoSuchMethodError異常。由於介面中的所有方法預設都是public的,所以不存在訪問許可權的問題,因此介面方法的符號解析應當不會拋出java.lang.IllegalAccessError異常。
5 、初始化
類初始化階段是類載入過程的最後一步,前面的類載入過程中,除了在載入階段用戶應用程式可以通過自定義類載入器參與之外,其餘動作完全由虛擬機主導和控制。到了初始化階段,才真正開始執行類中定義的Java程式代碼(或者說是位元組碼)。
在準備階段,變數已經賦過一次系統要求的初始值,而在初始化階段,則根據程式員通過程式制定的主觀計划去初始化類變數和其他資源,或者可以從另外一個角度來表達:初始化階段是執行類構造器<clinit>()方法的過程。
- <clinit>()方法是由編譯器自動收集類中的所有類變數的賦值動作和靜態語句塊(static{}塊)中的語句合併產生的,編譯器收集的順序是由語句在源文件中出現的順序所決定的,靜態語句塊中只能訪問到定義在靜態語句塊之前的變數,定義在它之後的變數,在前面的靜態語句塊可以賦值。
- <clinit>()方法與類的構造函數(或者說實例構造器<init>()方法)不同,它不需要顯式地調用父類構造器,虛擬機會保證在子類的<clinit>()方法執行之前,父類的<clinit>()方法已經執行完畢。因此在虛擬機中第一個被執行的<clinit>()方法的類肯定是java.lang.Object。
- 由於父類的<clinit>()方法先執行,也就意味著父類中定義的靜態語句塊要優先於子類的變數賦值操作.
- <clinit>()方法對於類或介面來說並不是必需的,如果一個類中沒有靜態語句塊,也沒有對變數的賦值操作,那麼編譯器可以不為這個類生成<clinit>()方法。
- 介面中不能使用靜態語句塊,但仍然有變數初始化的賦值操作,因此介面與類一樣都會生成<clinit>()方法。但介面與類不同的是,執行介面的<clinit>()方法不需要先執行父介面的<clinit>()方法。只有當父介面中定義的變數使用時,父介面才會初始化。另外,介面的實現類在初始化時也一樣不會執行介面的<clinit>()方法。
- 虛擬機會保證一個類的<clinit>()方法在多線程環境中被正確地加鎖、同步,如果多個線程同時去初始化一個類,那麼只會有一個線程去執行這個類的<clinit>()方法,其他線程都需要阻塞等待,直到活動線程執行<clinit>()方法完畢。如果在一個類的<clinit>()方法中有耗時很長的操作,就可能造成多個進程阻塞,在實際應用中這種阻塞往往是很隱蔽的。代碼清單7-7演示了這種場景。