【進階之路】深入理解Java虛擬機的類載入機制(長文)
我們在參加面試的時候,經常被問到一些關於類載入機制的問題,也都會在面試之前準備的時候背好答案,但是我們是否有去深入了解什麼是類載入機制呢?這段時間因為一些事情在家看了些書,這次就和大家分享一些關於Java類載入機制的知識。
虛擬機的類載入機制:Java虛擬機把數據載入到記憶體,同時對數據進行校驗、解析、初始化等一些列操作,最終把Class文件變為虛擬機可以直接使用的Java類型文件。
一個類從被載入到虛擬機記憶體開始,直到卸載出記憶體為止,他的生命周期會經歷載入、驗證、準備、解析、初始化、使用和卸載七個階段(其中驗證、準備、解析三個階段被稱為連接)
連接就是將已經讀入到記憶體的類的二進位數據合併到虛擬機的運行時環境中去,所以這三個階段可以看成是一整個階段。
一、載入階段
其中,載入、驗證、準備、初始化和卸載這五個階段的順序是確定的,類載入的順序必須按照這種順序按部就班的開始,而解析階段則不一樣,有時候為了支援動態綁定它可以在初始化階段之後再進行解析。
至於何時會進行載入階段,《Java虛擬機規範中》並未進行強制約束,只需要在載入階段完成以下三件事:
- 1、通過一個類的全限定名來獲取定義此類的二進位位元組流
- 2、將這個位元組流所代表的靜態儲存結構轉換為方法區的運行時數據結構
- 3、在記憶體中生成一個代表這個類的java.lang.Class對象,作為方法區中這個類的各種數據的訪問入口。
對於載入階段,Java虛擬機的要求非常的開放,以至於通過一個類的全限定名來獲取定義此類的二進位位元組流這個步驟就可以通過Class文件獲取、運行時計演算法(反射)、ZIP包讀取(包括JAR\WAR等)、網路運算(Web Applet)、其他文件生成(JSP)、加密文件獲取……等各式各樣的方法。
但是對於數組類而言、情況則有所不同。數組類本身不通過類載入器來創造,而是由Java虛擬機在直接在記憶體中動態構建出來的。但是構成數組類本身的元數類型(Element Type)還是需要類載入器來載入完成,所以最終還是會遵循類載入器的以下規則:
- 1、如果數組的組件類型是引用類型,那就遞歸採用定義的載入過程去載入這個組件,數組類將被標識在載入該組件類型的類載入器的類名稱空間上。(一個類必須與類載入器一起確定唯一性)
- 2、數組的組件類型不是引用類型(比如int[]數組就是int類型),Java虛擬機會把數組在載入該組件類型的類載入器的類名稱空間上標識。
- 3、數組類的可訪問性級別與它的組件的可訪問性級別一直,如果數組類型不是引用類型,則可訪問性級別將默認為public。
載入階段結束後,Java需要立即外部的二進位位元組流就會按照虛擬機所設定的格式存儲在方法區之中了,方法區中的數據存儲格式將完全由虛擬機自行實現。
與之前所說的一致,數據被存儲在方法區之後,會在Java堆記憶體中實例化一個代表這個類的java.lang.Class對象,對象將作為方法區中這個類的各種數據的外部介面。
載入階段與連接階段的部分動作是交替進行的(比如位元組碼文件格式的驗證動作),載入階段尚未結束也許連接階段就已經開始,但是兩個階段的開始時間還是保持著先後順序。
二、驗證階段
驗證是連接的第一步,這一階段的目的就是確保Class文件位元組流中包含的資訊符合《Java虛擬機規範》中的全部約束,並且確保這些資訊不會危害虛擬機本身的安全。驗證階段會完成四個階段的驗證:文件格式驗證、元數據驗證、位元組碼驗證和符號引用驗證,接下來就依次介紹這四種驗證。
1、文件格式驗證
第一階段自然是檢查位元組流是否符合Class文件格式的規範,並且能被當前版本的虛擬機理解(這一部分需要聯繫到Class的文件結構)
Class文件格式採用一種類似於C語言結構體的偽結構來存儲數據,這種偽結構中只有兩種數據類型:無符號數和表。
- 無符號數屬於基本的數據類型,以u1、u2、u4、u8來分別代表1個位元組、2個位元組、4個位元組和8個位元組的無符號數,無符號數可以用來描述數字、索引引用、數量值或者按照UTF-8編碼構成字元串值。
- 表是由多個無符號數或者其他表作為數據項構成的複合數據類型,為了便於區分,所有表的命名都習慣性地以_info結尾。表用於描述有層次關係的複合結構的數據,整個Class文件本質上也可以視作是一張表,這張表由下圖所示的數據項按嚴格順序排列構成。
下圖為詳細介紹:
類型 | 名稱 | 中文名 | 數量 | 默認值(沒有則不寫) |
---|---|---|---|---|
u4 | magic | 魔數 | 1 | |
u2 | minor_version | 次版本號 | 1 | |
u2 | major_version | 主版本號 | 1 | JDK版本(k>=2),對應的範圍為45.0~44+k.0 |
u2 | constant_pool_count | 常量池容量 | 1 | 值為常量池成員數+1,唯一一個從1開始計數的單位 |
cp_info | constant_pool | 常量池 | constant_pool_count-1 | 下標為0:表示「不引用任何一個常量池」 |
u2 | access_flags | 訪問標誌 | 1 | |
u2 | this_class; | 類索引 | 1 | 常量池表中的一個有效索引,該索引處的成員為CONSTANT_Class_info類型常量(類/介面) |
u2 | super_flags | 父類索引 | 1 | 0或者常量池有效索引,0表示該類為Object |
u2 | interfaces_count | 介面計數器 | 1 | 可以為0 |
u2 | interfaces | 介面表 | interfaces_count | 常量池中CONSTANT_Class_info的有效索引 |
u2 | fields_count | 欄位計數器 | 1 | |
field_info | fields | 欄位表 | fields_count | 成員為field_info結構,不包括父類或父介面的欄位 |
u2 | methods_count | 方法計數器 | 1 | |
method_info | methods | 方法表 | methods_count | 成員為method_info結構,包括 |
u2 | attributes_count | 屬性計數器 | 1 | |
attribute_info | attributes | 屬性表 | attributes_count | 成員為attribute_info結構,Signature、InnerClasses等 |
只有通過了這個階段的驗證,位元組流才被允許進入Java虛擬機的記憶體的方法區中進行存儲。後面的三個驗證階段全部給予方法區的存儲結構式進行的,不會再直接讀取操作位元組流了。
2、元數據驗證
第二階段是對位元組碼描述的資訊進行語義分析,以確保其描述資訊符合規範:
- 1、該類是否有父類(除了java.lang.Object之外,所有的類都應該有父類)
- 2、父類是否結成了不允許被繼承的類(被final修飾的類)
- 3、是否是抽象類,是否實現了父類或者介面中要求實現的方法
- …
3、位元組碼驗證
第三階段是整個驗證過程中最為複雜的一個階段,主要目的是通過數據流分析和控制流分析,確定語義是合法以及符合邏輯的。在第二階段對元數據資訊中的數據類型校驗完畢之後,這個階段主要對類的方法體進行校驗分析,保證被校驗類的方法在運行時不會做出危害虛擬機的行為。
4、符號引用驗證
最後一個校驗階段發生在虛擬機將符號應用轉化為直接引用的時候,這個轉化會在解析階段發生。
符號引用驗證的目的是要確保解析行為能正常秩序,如果無法通過符號引用驗證,Java虛擬機會拋出一個java.lang.IncompatibleClassChangeError的子類異常。
三、準備階段
準備階段是正式為類中定義的變數(即靜態變數)分配記憶體並設置類變數初始值的階段,從概念上來說,這些變數所使用的記憶體都應當在方法區中進行分配。
在準備階段進行記憶體分配的僅包括類變數(靜態變數),而不包括實例變數。實例變數將會在對象實例化的階段隨著對象一起分配在java堆中。
public static int value = 1;
類似於這種情況,在準備階段後依然是0而不是1,因為這時候尚未執行任何Java方法,將value賦值必須等到類的初始化階段才會被執行。
public static final int value = 1;
但是如果類欄位存在ConstantValue屬性,則在準備階段就會根據ConstantValue的設置將value賦值為1。
ConstantValue屬於屬性表集合中的一個屬性
static final修飾的欄位在javac編譯時生成comstantValue屬性,在類載入的準備階段直接把constantValue的值賦給該欄位。可以理解為在編譯期即把結果放入了常量池中,同時ConstantValue的屬性值只限於基本類型和String類型。
四、解析階段
解析階段是Java虛擬機將常量池內的符號引用替換為直接引用的過程。
- 符號引用:符號引用以一組符號來描述所引用的目標。這裡的符號可以是任何形式的字面量。符號引用與虛擬機實現的記憶體布局無關,引用的目標並不一定是已經載入到了虛擬機記憶體中的內容。符號引用的字面量形式定義在《Java虛擬機規範》的Class文件格式中。
在電腦科學中,字面量(literal)是用於表達源程式碼中一個固定值的表示法(notation)。幾乎所有電腦程式語言都具有對基本值的字面量表示,諸如:整數、浮點數以及字元串;而有很多也對布爾類型和字元類型的值也支援字面量表示;還有一些甚至對枚舉類型的元素以及像數組、記錄和對象等複合類型的值也支援字面量表示法。
- 直接引用:直接引用是可以直接指向目標的指針、相對偏移量或者是一個能間接定位到目標的句柄。直接引用是和虛擬機實現的記憶體布局直接相關的,同一個符號引用在不同虛擬機實例上翻譯出來的直接引用一般不會相同。當然,如果有了直接引用,那麼被引用的目標必定已經在虛擬機的記憶體中存在了。
解析階段包括四種類型的解析。
1、對類或者介面的解析步驟
-
1、判斷將要解析的符號引用是不是一個數組類型,如果不是,那麼虛擬機將會把該符號代表的全限定名稱傳遞給類載入器去載入這個類。這個過程由於涉及驗證過程所以可能會觸發其他相關類的載入過程。
-
2、如果該符號引用是一個數組類型,並且該數組的元素類型是對象。將會按照規則載入數組元素類型,例如需需要載入的元素類型是java.lang.Integer,則會由虛擬機將會生成一個代表此數組對象的直接引用。
-
3、如果上面的步驟正常執行,那麼該符號引用已經在虛擬機中產生了一個直接引用,但是在解析完成之前需要對符號引用進行驗證,主要是確認當前調用這個符號引用的類是否具有訪問許可權,如果沒有訪問許可權將拋出java.lang.IllegalAccess異常。
2、對欄位的解析步驟
欄位解析將會按照以下步驟進行解析。
-
1、如果該欄位符號引用就包含了簡單名稱和欄位描述符都與目標相匹配的欄位,則會直接返回這個欄位的直接引用,並且結束解析階段。
-
2、如果在該符號的類實現了介面,將會按照繼承關係從下往上遞歸搜索各個介面和它的父介面,如果在介面中包含了簡單名稱和欄位描述符都與目標相匹配的欄位,則會直接返回這個欄位的直接引用,並且結束解析階段。
-
3、如果該符號所在的類不是Object類的話,將會按照繼承關係從下往上遞歸搜索其父類,如果在父類中包含了簡單名稱和欄位描述符都相匹配的欄位,則會直接返回這個欄位的直接引用,並且結束解析階段。
-
4、如果三種情況都沒有成功解析,則為解析失敗,並拋出java.lang.NoSuchFieldError異常。
以上規則能確保Java虛擬機獲得欄位的唯一解析結果,但在實際情況中,編譯器往往會採取比上述規範更加嚴格的約束,比如同名欄位同時出現在某個類的介面和父類中,或者在自己和父類中同時出現,Javac編譯器就會直接拒編譯。
3、對方法的解析步驟
-
1、類方法和介面方法的符號引用是分開的,所以如果在類方法表中發現class_index(類中方法的符號引用)的索引是一個介面,那麼會拋出java.lang.IncompatibleClassChangeError的異常。
-
2、如果class_index的索引確實是一個類,那麼在該類中查找是否有簡單名稱和描述符都與目標欄位相匹配的方法,則會直接返回這個欄位的直接引用,並且結束解析階段。
-
3、在該類的父類中遞歸查找是否具有簡單名稱和描述符都與目標欄位相匹配的欄位,如果有,則會直接返回這個欄位的直接引用,並且結束解析階段。
-
4、在這個類實現的介面以及它的父介面中遞歸查找是否有簡單名稱和描述都與目標相匹配的方法,如果找到的話就說明這個方法是一個抽象類,解析結束,返回java.lang.AbstractMethodError異常。
-
5、否則,查找失敗,拋出java.lang.NoSuchMethodError異常。
最後如果成功返回了直接引用,還會對方法進行訪問許可權驗證,如果失敗依然要拋出java.lang.illegalAccessError異常。
4、對介面方法的解析步驟
-
1、首先會判斷是否是一個介面,如果不是,那麼會拋出java.lang.IncompatibleClassChangeError的異常。
-
2、在該介面方法的所屬的介面中查找是否具有簡單名稱和描述符都與目標欄位相匹配的方法,如果有的話就直接返回這個方法的直接引用。
-
3、在該介面以及其父介面中查找,直到Object類,如果找到則直接返回這個方法的直接引用
-
4、否則,查找失敗,拋出java.lang.NoSuchMethodError異常。
在JDK9引入模組化之後,public類型也不在意味著程式任何位置都有它的訪問許可權,還需要檢查模組之間的訪問許可權,介面方法訪問完全有可能因為訪問許可權控制而出現java.lang.illegalAccessError異常。
五、初始化階段
初始化階段是類載入過程的最後一個步驟,在之前的幾個步驟中,除了在載入階段用戶可以通過自定義類載入器的方式局部控制以外,其他時間都是完全由Java虛擬機來主導。在初始化階段,Java虛擬機才真正開始執行類中編寫的Java程式程式碼。
我們之前提過,在準備階段,變數已經經過一次系統初始賦值(大部分情況為初始值),而在初始化階段,則會根據我們設計的程式而去初始化變數。
《Java虛擬機規範》中定義了六種情況必須對類進行初始化:
-
1、使用 New 關鍵字實例化對象的時候。
-
2、讀取或設置一個類的靜態欄位的時候。
-
3、調用一個類的靜態方法的時候。
-
4、通過java.lang.reflect包中的方法對類進行反射調用的時候。
-
5、當初始化一個類時,發現其父類還沒有進行初始化,則需要先觸發其父類初始化。
-
6、當虛擬機啟動時,用戶需要指定一個要執行的包含 main 方法的主類,虛擬機會初始化這個主類。
除此之外,其他方式都無法觸發類的初始化,我們可以通過子類引用父類的靜態欄位來測試。
public class Father {
static {
System.out.println("I am Father ");
}
public static int value =1;
}
public class Son extends Father{
static {
System.out.println("I am Son ");
}
}
public static void main(String[] args) {
System.out.println(Son.value);
}
這是一個很有名的例子,告訴我們子類引用父類的靜態欄位,並不會導致子類的初始化,只有直接定義這個欄位的類才會被初始化。
我們再來看看如果在編譯階段把數據放入常量池,是否會進行初始化。
public class ConstantValueTest {
static {
System.out.println("I am ConstantValueTest ");
}
public static final int value = 1;
}
public static void main(String[] args) {
System.out.println(ConstantValueTest.value);
}
答案也是顯而易見,因為我們之前也有提過,在之前的階段已經將常量存儲在常量池中,所以並不會初始化類本身。
六、終於寫完了
類載入的主要流程大體上是這樣的,雖然還是沒有做到非常詳細,如果需要更加深入了解的同學們可以通過去讀一些JVM方面的書籍獲取更多的資訊。
類載入器是Java語言的非常重要的基石,它的提前編譯的策略會增加電腦的開銷,但卻為Java應用提高了擴展性和靈活性,Java天生可以動態擴展的語言特性就是一類運行期動態載入和動態鏈接這個特性實現的。
有需要的同學可以加我的公眾號,以後的最新的文章第一時間都在裡面,也可以找我要思維導圖