簡述:虛擬機把描述類的數據從class文件加載到內存,并對數據進行校驗、轉換解析和初始化,最終形成可以被虛擬機直接使用的Java類型,這就是虛擬機的類加載機制。
下面我們具體來看類加載的過程:類從被加載到內存中開始,到卸載出內存,經歷了加載、連接、初始化、使用四個階段,其中連接又包含了驗證、準備、解析三個步驟。這些步驟總體上是按照圖中順序進行的,但是Java語言本身支持運行時綁定,所以解析階段也可以是在初始化之后進行的。以上順序都只是說開始的順序,實際過程中是交叉進行的,加載過程中可能就已經開始驗證了。
類加載的時機
首先要知道什么時候類需要被加載,Java虛擬機規范并沒有約束這一點,但是卻規定了類必須進行初始化的5種情況,很顯然加載、驗證、準備得在初始化之前,下面具體來說說這5種情況:
其中情況1中的4條字節碼指令在Java里最常見的場景是:
1 . new一個對象時
2 . set或者get一個類的靜態字段(除去那種被final修飾放入常量池的靜態字段)
3 . 調用一個類的靜態方法
類加載的過程
下面我們一步一步分析類加載的每個過程
1. 加載
加載是整個類加載過程的第一步,如果需要創建類或者接口,就需要現在Java虛擬機方法區創建于虛擬機實現規定相匹配的內部表示。一般來說類的創建是由另一個類或者接口觸發的,它通過自己的運行時常量池引用到了需要創建的類,也可能是由于調用了Java核心類庫中的某些方法,譬如反射等。
一般來說加載分為以下幾步:
- 通過一個類的全限定名獲取此類的二進制字節流
- 將這個字節流所代表的靜態存儲結構轉化為方法區的運行時數據結構
- 在內存中生成一個代表這個類的java.lang.Class對象,作為方法區這個類的各種數據的訪問入口
創建名字為C的類,如果C不是數組類型,那么它就可以通過類加載器加載C的二進制表示(即Class文件)。如果是數組,則是通過Java虛擬機創建,虛擬機遞歸地采用上面提到的加載過程不斷加載數組的組件。
Java虛擬機支持兩種類加載器:
- 引導類加載器(Bootstrap ClassLoader)
- 用戶自定義類加載器(User-Defined Class Loader)
用戶自定義的類加載器應該是抽象類ClassLoader的某個子類的實例。應用程序使用用戶自定義的類加載器是為了擴展Java虛擬機的功能,支持動態加載并創建類。比如,在加載的第一個步驟中,獲取二進制字節流,通過自定義類加載器,我們可以從網絡下載、動態產生或者從一個加密文件中提取類的信息。
關于類加載器,會新開一篇文章描述。
2. 驗證
驗證作為鏈接的第一步,用于確保類或接口的二進制表示結構上是正確的,從而確保字節流包含的信息對虛擬機來說是安全的。Java虛擬機規范中關于驗證階段的規則也是在不斷增加的,但大體上會完成下面4個驗證動作。
1 . 文件格式驗證:主要驗證字節流是否符合Class文件格式規范,并且能被當前版本的虛擬機處理。
主要驗證點:
- 是否以魔數
0xCAFEBABE
開頭 - 主次版本號是否在當前虛擬機處理范圍之內
- 常量池的常量是否有不被支持的類型 (檢查常量tag標志)
- 指向常量的各種索引值中是否有指向不存在的常量或不符合類型的常量
- CONSTANT_Utf8_info型的常量中是否有不符合UTF8編碼的數據
- Class文件中各個部分及文件本身是否有被刪除的或者附加的其他信息
...
實際上驗證的不僅僅是這些,關于Class文件格式可以參考我的深入理解JVM類文件格式,這階段的驗證是基于二進制字節流的,只有通過文件格式驗證后,字節流才會進入內存的方法區中進行存儲。
2 . 元數據驗證:主要對字節碼描述的信息進行語義分析,以保證其提供的信息符合Java語言規范的要求。
主要驗證點:
- 該類是否有父類(只有Object對象沒有父類,其余都有)
- 該類是否繼承了不允許被繼承的類(被final修飾的類)
- 如果這個類不是抽象類,是否實現了其父類或接口之中要求實現的所有方法
- 類中的字段、方法是否與父類產生矛盾(例如覆蓋了父類的final字段,出現不符合規則的方法重載,例如方法參數都一致,但是返回值類型卻不同)
...
3 . 字節碼驗證:主要是通過數據流和控制流分析,確定程序語義是合法的、符合邏輯的。在第二階段對元數據信息中的數據類型做完校驗后,字節碼驗證將對類的方法體進行校驗分析,保證被校驗類的方法在運行時不會做出危害虛擬機安全的事件。
主要有:
- 保證任意時刻操作數棧的數據類型與指令代碼序列都能配合工作,例如不會出現類似的情況:操作數棧里的一個int數據,但是使用時卻當做long類型加載到本地變量中
- 保證跳轉不會跳到方法體以外的字節碼指令上
- 保證方法體內的類型轉換是合法的。例如子類賦值給父類是合法的,但是父類賦值給子類或者其它毫無繼承關系的類型,則是不合法的。
-
符號引用驗證:最后一個階段的校驗發生在虛擬機將符號引用轉化為直接引用的時候,這個轉化動作將在連接的第三階段解析階段發生。符號引用是對類自身以外(常量池中的各種符號引用)的信息進行匹配校驗。
通常有:
- 符號引用中通過字符串描述的全限定名是否找到對應的類
- 在指定類中是否存在符合方法的字段描述符以及簡單名稱所描述的方法和字段
- 符號引用中的類、方法、字段的訪問性(private,public,protected、default)是否可被當前類訪問
符號引用驗證的目的是確保解析動作能夠正常執行,如果無法通過符號引用驗證,那么將會拋出一個java.lang.IncompatibleClassChangeError異常的子類,如java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError等。
驗證階段非常重要,但不一定必要,如果所有代碼極影被反復使用和驗證過,那么可以通過虛擬機參數-Xverify: none
來關閉驗證,加速類加載時間。
3. 準備
準備階段的任務是為類或者接口的靜態字段分配空間,并且默認初始化這些字段。這個階段不會執行任何的虛擬機字節碼指令,在初始化階段才會顯示的初始化這些字段,所以準備階段不會做這些事情。假設有:
public static int value = 123;
value在準備階段的初始值為0而不是123,只有到了初始化階段,value才會為0。
下面看一下Java中所有基礎類型的零值:
數據類型 | 零值 |
---|---|
int | 0 |
long | 0L |
short | (short)0 |
char | '\u0000' |
byte | (byte)0 |
boolean | false |
float | 0.0f |
double | 0.0d |
reference | null |
一種特殊情況是,如果字段屬性表中包含ConstantValue屬性,那么準備階段變量value就會被初始化為ConstantValue屬性所指定的值,比如上面的value如果這樣定義:
public static final int value = 123;
編譯時,value一開始就指向ConstantValue,所以準備期間value的值就已經是123了。
4. 解析
解析階段是把常量池內的符號引用替換成直接引用的過程,符號引用就是Class文件中的CONSTANT_Class_info、** CONSTANT_Fieldref_info、CONSTANT_Methodref_info**等類型的常量。下面我們看符號引用和直接引用的定義。
符號引用(Symbolic References):符號引用以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要可以唯一定位到目標即可。符號引用于內存布局無關,所以所引用的對象不一定需要已經加載到內存中。各種虛擬機實現的內存布局可以不同,但是接受的符號引用必須是一致的,因為符號引用的字面量形式已經明確定義在Class文件格式中。
直接引用(Direct References):直接引用時直接指向目標的指針、相對偏移量或是一個能間接定位到目標的句柄。直接引用和虛擬機實現的內存布局相關,同一個符號引用在不同虛擬機上翻譯出來的直接引用一般不會相同。如果有了直接引用,那么它一定已經存在于內存中了。
以下Java虛擬機指令會將符號引用指向運行時常量池,執行任意一條指令都需要對它的符號引用進行解析:
對同一個符號進行多次解析請求是很常見的,除了invokedynamic指令以外,虛擬機基本都會對第一次解析的結果進行緩存,后面再遇到時,直接引用,從而避免解析動作重復。
對于invokedynamic指令,上面規則不成立。當遇到前面已經由invokedynamic指令觸發過解析的符號引用時,并不意味著這個解析結果對于其他invokedynamic指令同樣生效。這是由invokedynamic指令的語義決定的,它本來就是用于動態語言支持的,也就是必須等到程序實際運行這條指令的時候,解析動作才會執行。其它的命令都是“靜態”的,可以再剛剛完成記載階段,還沒有開始執行代碼時就解析。
下面來看幾種基本的解析:
類與接口的解析: 假設Java虛擬機在類D的方法體中引用了類N或者接口C,那么會執行下面步驟:
- 如果C不是數組類型,D的定義類加載器被用來創建類N或者接口C。加載過程中出現任何異常,可以被認為是類和接口解析失敗。
- 如果C是數組類型,并且它的元素類型是引用類型。那么表示元素類型的類或接口的符號引用會通過遞歸調用來解析。
- 檢查C的訪問權限,如果D對C沒有訪問權限,則會拋出
java.lang.IllegalAccessError
異常。
字段解析:
要解析一個未被解析過的字段符號引用,首先會對字段表內class_index項中索引的CONSTANT_Class_info
符號引用進行解析,這邊記不清的可以繼續回顧深入理解JVM類文件格式,也就是字段所屬的類或接口的符號引用。如果在解析這個類或接口符號引用的過程中出現了任何異常,都會導致字段解析失敗。如果解析完成,那將這個字段所屬的類或者接口用C表示,虛擬機規范要求按照如下步驟對C進行后續字段的搜索。
1 . 如果C本身包含了簡單名稱和字段描述符都與目標相匹配的字段,則直接返回這個字段的直接引用,查找結束。
2 . 否則,如果在C中實現了接口,將會按照繼承關系從下往上遞歸搜索各個接口和它的父接口,如果接口中包含了簡單名稱和字段描述符都與目標相匹配的字段,則返回這個字段的直接引用,查找結束。
3 . 再不然,如果C不是java.lang.Object
的話,將會按照繼承關系從下往上遞歸搜索其父類,如果在類中包含
了簡單名稱和字段描述符都與目標相匹配的字段,則返回這個字段的直接引用,查找結束。
4 . 如果都沒有,查找失敗退出,拋出java.lang.NoSuchFieldError
異常。如果返回了引用,還需要檢查訪問權限,如果沒有訪問權限,則會拋出java.lang.IllegalAccessError
異常。
在實際的實現中,要求可能更嚴格,如果同一字段名在C的父類和接口中同時出現,編譯器可能拒絕編譯。
類方法解析
類方法解析也是先對類方法表中的class_index項中索引的方法所屬的類或接口的符號引用進行解析。我們依然用C來代表解析出來的類,接下來虛擬機將按照下面步驟對C進行后續的類方法搜索。
1 . 首先檢查方法引用的C是否為類或接口,如果是接口,那么方法引用就會拋出IncompatibleClassChangeError
異常
2 . 方法引用過程中會檢查C和它的父類中是否包含此方法,如果C中確實有一個方法與方法引用的指定名稱相同,并且聲明是簽名多態方法(Signature Polymorphic Method),那么方法的查找過程就被認為是成功的,所有方法描述符所提到的類也需要解析。對于C來說,沒有必要使用方法引用指定的描述符來聲明方法。
3 . 否則,如果C聲明的方法與方法引用擁有同樣的名稱與描述符,那么方法查找也是成功。
4 . 如果C有父類的話,那么按照第2步的方法遞歸查找C的直接父類。
5 . 否則,在類C實現的接口列表及它們的父接口之中遞歸查找是否有簡單名稱和描述符都與目標相匹配的方法,如果存在相匹配的方法,說明類C時一個抽象類,查找結束,并且拋出java.lang.AbstractMethodError
異常。
- 否則,宣告方法失敗,并且拋出
java.lang.NoSuchMethodError
。
最后的最后,如果查找過程成功返回了直接引用,將會對這個方法進行權限驗證,如果發現不具備對此方法的訪問權限,那么會拋出java.lang.IllegalAccessError
異常。
接口方法解析
接口方法也需要解析出接口方法表的class_index項中索引的方法所屬的類或接口的符號引用,如果解析成功,依然用C表示這個接口,接下來虛擬機將會按照如下步驟進行后續的接口方法搜索。
1 . 與類方法解析不同,如果在接口方法表中發現class_index對應的索引C是類而不是接口,直接拋出java.lang.IncompatibleClassChangeError
異常。
2 . 否則,在接口C中查找是否有簡單名稱和描述符都與目標匹配的方法,如果有則直接返回這個方法的直接引用,查找結束。
3 . 否則,在接口C的父接口中遞歸查找,直到java.lang.Object
類為止,看是否有簡單名稱和描述符都與目標相匹配的方法,如果有則返回這個方法的直接引用,查找結束。
4 . 否則,宣告方法失敗,拋出java.lang.NoSuchMethodError
異常。
由于接口的方法默認都是public的,所以不存在訪問權限問題,也就基本不會拋出java.lang.IllegalAccessError
異常。
5. 初始化
初始化是類加載的最后一步,在前面的階段里,除了加載階段可以通過用戶自定義的類加載器加載,其余部分基本都是由虛擬機主導的。但是到了初始化階段,才開始真正執行用戶編寫的java代碼了。
在準備階段,變量都被賦予了初始值,但是到了初始化階段,所有變量還要按照用戶編寫的代碼重新初始化。換一個角度,初始化階段是執行類構造器<clinit>()
方法的過程。
<clinit>()
方法是由編譯器自動收集類中的所有類變量的賦值動作和靜態語句塊(static語句塊)中的語句合并生成的,編譯器收集的順序是由語句在源文件中出現的順序決定的,靜態語句塊中只能訪問到定義在靜態語句塊之前的變量,定義在它之后的變量,在前面的靜態語句塊中可以賦值,但是不能訪問。
public class Test {
static {
i=0; //可以賦值
System.out.print(i); //編譯器會提示“非法向前引用”
}
static int i=1;
}
<clinit>()
方法與類的構造函數<init>()
方法不同,它不需要顯示地調用父類構造器,虛擬機會寶成在子類的<clinit>()
方法執行之前,父類的<clinit>()
已經執行完畢,因此在虛擬機中第一個被執行的<clinit>()
一定是java.lang.Object
的。
也是由于<clinit>()
執行的順序,所以父類中的靜態語句塊優于子類的變量賦值操作,所以下面的代碼段,B的值會是2。
static class Parent {
public static int A=1;
static {
A=2;
}
}
static class Sub extends Parent{
public static int B=A;
}
public static void main(String[] args) {
System.out.println(Sub.B);
}
<clinit>()
方法對于類來說不是必須的,如果一個類中既沒有靜態語句塊也沒有靜態變量賦值動作,那么編譯器都不會為類生成<clinit>()
方法。
接口中不能使用靜態語句塊,但是允許有變量初始化的賦值操作,因此接口與類一樣都會生成<clinit>()
方法,但是接口中的<clinit>()
不需要先執行父類的,只有當父類中定義的變量使用時,父接口才會初始化。除此之外,接口的實現類在初始化時也不會執行接口的<clinit>()
方法。
虛擬機會保證一個類的<clinit>()
方法在多線程環境中能被正確的枷鎖、同步。如果多個線程初始化一個類,那么只有一個線程會去執行<clinit>()
方法,其它線程都需要等待。
6. Java虛擬機退出
Java虛擬機退出的一般條件是:某些線程調用Runtime類或System類的exit方法,或者時Runtime類的halt方法,并且Java安全管理器也允許這些exit或者halt操作。
除此之外,在JNI(Java Native Interface)規范中還描述了當使用JNI API來加載和卸載(Load & Unload)Java虛擬機時,Java虛擬機退出過程。