深入JVM:(九)類載入機制
虛擬機器把描述類的資料從Class檔案載入到記憶體,並對資料進行 校驗
、 轉換解析
和 初始化
,最終形成可以被虛擬機器直接使用的Java型別,這就是虛擬機器的類載入機制。
與那些在編譯時需要進行連線工作的語言不同,在Java語言裡面,型別的載入、連線和初始化過程都是在程式執行期間完成的,這種策略雖然會令類載入時稍微增加一些效能開銷,但是會為Java應用程式提供高度的靈活性
類載入的過程:

類載入過程.png
載入
、
連線
、
初始化
、
使用
四個階段,其中連線又包含了
驗證
、
準備
、
解析
三個步驟。這些步驟總體上是按照圖中順序進行的,但是Java語言本身支援執行時繫結,所以解析階段也可以是在初始化之後進行的。以上順序都只是說開始的順序,實際過程中是交叉進行的,載入過程中可能就已經開始驗證了。
類的載入時機
什麼情況下需要開始類載入過程的第一個階段: 載入
,Java虛擬機器規範中並沒有進行強制約束。但是對於初始化階段,虛擬機器規範則是嚴格規定了有且只有5種情況必須立即對類進行“初始化”(而載入、驗證、準備自然需要在此之前開始):
- 遇到
new
、getstatic
、putstatic
或invokestatic
這4條位元組碼指令時,如果類沒有進行過初始化,則需要先觸發其初始化。生成這4條指令的最常見的Java程式碼場景是:使用new關鍵字例項化物件的時候、讀取或設定一個類的靜態欄位(被final修飾、已在編譯期把結果放入常量池的靜態欄位除外)的時候,以及呼叫一個類的靜態方法的時候。 - 使用
java.lang.reflec
t包的方法對類進行反射呼叫的時候,如果類沒有進行過初始化,則需要先觸發其初始化。 - 當初始化一個類的時候,如果發現其父類還沒有進行過初始化,則需要先觸發其父類的初始化。
- 當虛擬機器啟動時,使用者需要指定一個要執行的主類(包含main()方法的那個類),虛擬機器會先初始化這個主類。
- 當使用JDK 1.7的動態語言支援時,如果一個java.lang.invoke.MethodHandle例項最後的解析結果REF_getStatic、REF_putStatic、REF_invokeStatic的方法控制代碼,並且這個方法控制代碼所對應的類沒有進行過初始化,則需要先觸發其初始化。
對於這5種會觸發類進行初始化的場景,虛擬機器規範中使用了一個很強烈的限定語:“有且只有”,這5種場景中的行為稱為對一個類進行 主動引用
。除此之外,所有引用類的方式都不會觸發初始化,稱為 被動引用
。
被動引用的例子一
/** * 通過子類引用父類的靜態欄位,不會導致子類初始化 */ 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 NotInitialization { public static void main(String[] args) { System.out.println(SubClass.value); } }
上述程式碼執行之後,只會輸出"SuperClass init!",而不會輸出"SubClass init!"。 對於靜態欄位,只有直接定義這個欄位的類才會被初始化
,因此通過其子類來引用父類中定義的靜態欄位,只會觸發父類的初始化而不會觸發子類的初始化。至於是否要觸發子類的載入和驗證,在虛擬機器規範中並未明確規定,這點取決於虛擬機器的具體實現。
被動引用的例子二
/** * 通過陣列定義來引用類,不會觸發此類的初始化 **/ public class NotInitialization { public static void main(String[] args) { SuperClass[] sca = new SuperClass[10]; } }
並沒有觸發類org.fenixsoft.classloading.SuperClass的初始化階段。但是這段程式碼裡面觸發了另外一個名為 [Lorg.fenixsoft.classloading.SuperClass
的類的初始化階段;這個類代表了一個元素型別為org.fenixsoft.classloading.SuperClass的一維陣列,陣列中應有的屬性和方法(使用者可直接使用的只有被修飾為public的length屬性和clone()方法)都實現在這個類裡。
被動引用的例子三
/** * 常量在編譯階段會存入呼叫類的常量池中,本質上並沒有直接引用到定義常量的類,因此不會觸發定義常量的類的初始化。 */ public class ConstClass { static { System.out.println("ConstClass init!"); } public static final String HELLOWORLD = "hello world"; } /** * 非主動使用類欄位演示 */ public class NotInitialization { public static void main(String[] args) { System.out.println(ConstClass.HELLOWORLD); } }
一、載入
載入
是“類載入”(Class Loading)過程的一個階段。在載入階段,虛擬機器需要完成以下3件事情:
- 通過一個類的全限定名來獲取定義此類的二進位制位元組流。
- 將這個位元組流所代表的靜態儲存結構轉化為方法區的執行時資料結構。
- 在記憶體中生成一個代表這個類的java.lang.Class物件,作為方法區這個類的各種資料的訪問入口。
通過一個類的全限定名來獲取定義此類的二進位制位元組流
這條,它沒有指明二進位制位元組流要從一個Class檔案中獲取,準確地說是根本沒有指明要從哪裡獲取、怎樣獲取。許多舉足輕重的Java技術都建立在這一基礎之上,例如:
- 從ZIP包中讀取,這很常見,最終成為日後JAR、EAR、WAR格式的基礎。
- 從網路中獲取,這種場景最典型的應用就是Applet。
- 執行時計算生成,這種場景使用得最多的就是動態代理技術,在java.lang.reflect.Proxy中,就是用了ProxyGenerator.generateProxyClass來為特定介面生成形式為"*$Proxy"的代理類的二進位制位元組流。
相對於類載入過程的其他階段,一個非陣列類的載入階段(準確地說,是載入階段中獲取類的二進位制位元組流的動作)是開發人員可控性最強的,因為載入階段既可以使用系統提供的引導類載入器來完成,也可以由使用者自定義的類載入器去完成,開發人員可以通過定義自己的類載入器去控制位元組流的獲取方式(即重寫一個類載入器的loadClass()方法)。
對於陣列類而言,情況就有所不同,陣列類本身不通過類載入器建立,它是由Java虛擬機器直接建立的。但陣列類與類載入器仍然有很密切的關係,因為陣列類的元素型別(Element Type,指的是陣列去掉所有維度的型別)最終是要靠類載入器去建立的
二、驗證
這一階段的目的是為了確保Class檔案的位元組流中包含的資訊符合當前虛擬機器的要求,並且不會危害虛擬機器自身的安全。
驗證階段是非常重要的,這個階段是否嚴謹,直接決定了Java虛擬機器是否能承受惡意程式碼的攻擊,從執行效能的角度上講,驗證階段的工作量在虛擬機器的類載入子系統中又佔了相當大的一部分。如果驗證到輸入的位元組流不符合Class檔案格式的約束,虛擬機器就應丟擲一個java.lang.VerifyError異常或其子類異常。
驗證階段大致上會完成下面4個階段的檢驗動作: 檔案格式驗證
、 元資料驗證
、 位元組碼驗證
、 符號引用驗證
:
-
檔案格式驗證
第一階段要驗證位元組流是否符合Class檔案格式的規範,並且能被當前版本的虛擬機器處理。這一階段可能包括下面這些驗證點:
- 是否以魔數0xCAFEBABE開頭
- 主、次版本號是否在當前虛擬機器處理範圍之內。
- 常量池的常量中是否有不被支援的常量型別(檢查常量tag標誌)。
- 指向常量的各種索引值中是否有指向不存在的常量或不符合型別的常量。
- CONSTANT_Utf8_info型的常量中是否有不符合UTF8編碼的資料。
- Class檔案中各個部分及檔案本身是否有被刪除的或附加的其他資訊。
...
該驗證階段的主要目的是保證輸入的位元組流能正確地解析並存儲於方法區之內,格式上符合描述一個Java型別資訊的要求。
-
元資料驗證
第二階段是對位元組碼描述的資訊進行語義分析,以保證其描述的資訊符合Java語言規範的要求,這個階段可能包括的驗證點如下:
- 這個類是否有父類(除了java.lang.Object之外,所有的類都應當有父類)。
- 這個類的父類是否繼承了不允許被繼承的類(被final修飾的類)。
- 如果這個類不是抽象類,是否實現了其父類或介面之中要求實現的所有方法。
- 類中的欄位、方法是否與父類產生矛盾(例如覆蓋了父類的final欄位,或者出現不符合規則的方法過載,例如方法引數都一致,但返回值型別卻不同等)。
……
第二階段的主要目的是對類的元資料資訊進行語義校驗,保證不存在不符合Java語言規範的元資料資訊。
-
位元組碼驗證
第三階段是整個驗證過程中最複雜的一個階段,主要目的是通過資料流和控制流分析,確定程式語義是合法的、符合邏輯的。在第二階段對元資料資訊中的資料型別做完校驗後,這個階段將對類的方法體進行校驗分析,保證被校驗類的方法在執行時不會做出危害虛擬機器安全的事件,例如:
- 保證任意時刻運算元棧的資料型別與指令程式碼序列都能配合工作,例如不會出現類似這樣的情況:在操作棧放置了一個int型別的資料,使用時卻按long型別來載入入本地變量表中。
- 保證跳轉指令不會跳轉到方法體以外的位元組碼指令上。
- 保證方法體中的型別轉換是有效的,例如可以把一個子類物件賦值給父類資料型別,這是安全的,但是把父類物件賦值給子類資料型別,甚至把物件賦值給與它毫無繼承關係、完全不相干的一個數據型別,則是危險和不合法的。
……
-
符號引用驗證
最後一個階段的校驗發生在虛擬機器將符號引用轉化為直接引用的時候,這個轉化動作將在連線的第三階段——解析階段中發生。符號引用驗證可以看做是對類自身以外(常量池中的各種符號引用)的資訊進行匹配性校驗,通常需要校驗下列內容:
- 符號引用中通過字串描述的全限定名是否能找到對應的類。
- 在指定類中是否存在符合方法的欄位描述符以及簡單名稱所描述的方法和欄位。
- 符號引用中的類、欄位、方法的訪問性(private、protected、public、default)是否可被當前類訪問。
……
符號引用驗證的目的是確保解析動作能正常執行,如果無法通過符號引用驗證,那麼將會丟擲一個java.lang.IncompatibleClassChangeError異常的子類,如java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError等。
三、準備
準備階段是正式為類變數分配記憶體並設定類變數初始值的階段,這些變數所使用的記憶體都將在方法區中進行分配。
這時候進行記憶體分配的僅包括類變數(被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。
四、解析
解析階段是虛擬機器將常量池內的符號引用替換為直接引用的過程
符號引用(Symbolic References) 直接引用(Direct References)
虛擬機器規範之中並未規定解析階段發生的具體時間,只要求了在執行 anewarray
、 checkcast
、 getfield
、 getstatic
、 instanceof
、 invokedynamic
、 invokeinterface
、 invokespecial
、 invokestatic
、 invokevirtual
、 ldc
、 ldc_w
、 multianewarray
、 new
、 putfield
和 putstatic
這16個用於操作符號引用的位元組碼指令之前,先對它們所使用的符號引用進行解析。
解析動作主要針對類或介面、欄位、類方法、介面方法、方法型別、方法控制代碼和呼叫點限定符7類符號引用進行
-
類或介面的解析
假設當前程式碼所處的類為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[2]項中索引的CONSTANT_Class_info符號引用進行解析,也就是欄位所屬的類或介面的符號引用。如果在解析這個類或介面符號引用的過程中出現了任何異常,都會導致欄位符號引用解析的失敗。如果解析成功完成,那將這個欄位所屬的類或介面用C表示,虛擬機器規範要求按照如下步驟對C進行後續欄位的搜尋。
1)如果C本身就包含了簡單名稱和欄位描述符都與目標相匹配的欄位,則返回這個欄位的直接引用,查詢結束。
2)否則,如果在C中實現了介面,將會按照繼承關係從下往上遞迴搜尋各個介面和它的父介面,如果介面中包含了簡單名稱和欄位描述符都與目標相匹配的欄位,則返回這個欄位的直接引用,查詢結束。
3)否則,如果C不是java.lang.Object的話,將會按照繼承關係從下往上遞迴搜尋其父類,如果在父類中包含了簡單名稱和欄位描述符都與目標相匹配的欄位,則返回這個欄位的直接引用,查詢結束。
4)否則,查詢失敗,丟擲java.lang.NoSuchFieldError異常。
如果查詢過程成功返回了引用,將會對這個欄位進行許可權驗證,如果發現不具備對欄位的訪問許可權,將丟擲java.lang.IllegalAccessError異常。
3. 類方法解析
類方法解析的第一個步驟與欄位解析一樣,也需要先解析出類方法表的class_index[3]項中索引的方法所屬的類或介面的符號引用,如果解析成功,我們依然用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[4]項中索引的方法所屬的類或介面的符號引用,如果解析成功,依然用C表示這個介面,接下來虛擬機器將會按照如下步驟進行後續的介面方法搜尋。
1)與類方法解析不同,如果在介面方法表中發現class_index中的索引C是個類而不是介面,那就直接丟擲java.lang.IncompatibleClassChangeError異常。
2)否則,在介面C中查詢是否有簡單名稱和描述符都與目標相匹配的方法,如果有則返回這個方法的直接引用,查詢結束。
3)否則,在介面C的父介面中遞迴查詢,直到java.lang.Object類(查詢範圍會包括Object類)為止,看是否有簡單名稱和描述符都與目標相匹配的方法,如果有則返回這個方法的直接引用,查詢結束。
4)否則,宣告方法查詢失敗,丟擲java.lang.NoSuchMethodError異常。
由於介面中的所有方法預設都是public的,所以不存在訪問許可權的問題,因此介面方法的符號解析應當不會丟擲java.lang.IllegalAccessError異常。
五、初始化
類初始化階段是類載入過程的最後一步,前面的類載入過程中,除了在載入階段使用者應用程式可以通過自定義類載入器參與之外,其餘動作完全由虛擬機器主導和控制。到了初始化階段,才真正開始執行類中定義的Java程式程式碼。
在準備階段,變數已經賦過一次系統要求的初始值。初始化階段是執行類構造器<clinit>()方法的過程
<clinit>()方法是由編譯器自動收集類中的所有類變數的賦值動作和靜態語句塊(static{}塊)中的語句合併產生的,編譯器收集的順序是由語句在原始檔中出現的順序所決定的,靜態語句塊中只能訪問到定義在靜態語句塊之前的變數,定義在它之後的變數,在前面的靜態語句塊可以賦值,但是不能訪問。
<clinit>()方法與類的建構函式(或者說例項構造器<init>()方法)不同,它不需要顯式地呼叫父類構造器,虛擬機器會保證在子類的<clinit>()方法執行之前,父類的<clinit>()方法已經執行完畢。因此在虛擬機器中第一個被執行的<clinit>()方法的類肯定是java.lang.Object。
由於父類的<clinit>()方法先執行,也就意味著父類中定義的靜態語句塊要優先於子類的變數賦值操作。
<clinit>()方法對於類或介面來說並不是必需的,如果一個類中沒有靜態語句塊,也沒有對變數的賦值操作,那麼編譯器可以不為這個類生成<clinit>()方法。
介面中不能使用靜態語句塊,但仍然有變數初始化的賦值操作,因此介面與類一樣都會生成<clinit>()方法。但介面與類不同的是,執行介面的<clinit>()方法不需要先執行父介面的<clinit>()方法。只有當父介面中定義的變數使用時,父接口才會初始化。另外,介面的實現類在初始化時也一樣不會執行介面的<clinit>()方法。
虛擬機器會保證一個類的<clinit>()方法在多執行緒環境中被正確地加鎖、同步,如果多個執行緒同時去初始化一個類,那麼只會有一個執行緒去執行這個類的<clinit>()方法,其他執行緒都需要阻塞等待,直到活動執行緒執行<clinit>()方法完畢。如果在一個類的<clinit>()方法中有耗時很長的操作,就可能造成多個程序阻塞,在實際應用中這種阻塞往往是很隱蔽的。