Java進階之深入理解JVM類加載機制
前言
了解類加載的過程,有利于在類初始化時進行一些功能操作;
本文全面講解類加載過程;
一、類加載介紹
1、類加載生命周期
從類被加載到虛擬機內存中開始,到卸載出內存為止,它的整個生命周期分為7個階段,加載(Loading)、驗證(Verification)、準備(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)、卸載(Unloading);
其中驗證、準備、解析三個部分統稱為連接;
2、類加載的過程
- 類加載的過程包括了加載、驗證、準備、解析、初始化五個階段;
- 在這五個階段中,加載、驗證、準備和初始化這四個階段發生的順序是確定的,而解析階段則不一定,它在某些情況下可以在初始化階段之后開始,這是為了支持Java語言的運行時綁定(也成為動態綁定或晚期綁定);
- 另外注意這里的幾個階段是按順序開始,而不是按順序進行或完成,因為這些階段通常都是互相交叉地混合進行的,通常在一個階段執行的過程中調用或激活另一個階段;
二、類加載過程詳解
類加載的全過程:加載、驗證、準備、解析和初始化五個階段所執行的具體操作
1、加載階段
1.1、這在個階段,虛擬機要做的事情有如下三個:
通過一個類的全限定名來獲取定義這個類的二進制字節流;
將這個字節流所代表的靜態存儲結構轉化為方法區的運行時數據結構;
在內存中生成一個代表這個類的java.lang.Class對象,作為方法區這個類的各種數據的訪問入口;
這里的二進制字節流并不一定要從Class文件獲取,只要一段二進制字節流符合Class文件的規范,都可以當做一個Class文件,例如我們可以從jar包中獲取,從網絡中獲取,運行時生成等,總之獲取Class文件的方式非常多。
1.2、這里需要注意的是通過一個類的全限定名來獲取定義這個類的二進制字節流的動作是有類加載器完成的,對于非數組類的加載,我們可以通過自定義類加載器加載來進行控制。對于數組類就不行,因為它不是通過類加載器創建的,而是由Java虛擬機直接創建的;
但數組類和非數組類也有很大的聯系,畢竟組成數組的元素就是非數組類(對于一維數組來說,而對于多維數組來說,可以遞歸加載),非數組類的創建需要類加載器完成。加載創建一個數組類的過程如下:
- 如果數組的元素類型是引用類型,就遞歸加載這個元素類型,這個數組將在加載該元素類型的類加載器的類名稱空間上被標識;
- 如果數組的元素類型不是引用類型(比如int[]數組),Java虛擬機將會把數組標記為與引導類加載器關聯;
- 數組類的可見性與元素類型的可見性一致,如果元素類型是不引用類型,那么數組的可見性默認是public;
2、驗證階段
- 驗證的目的是為了確保Class文件中的字節流包含的信息符合當前虛擬機的要求,而且不會危害虛擬機自身的安全。不同的虛擬機對類驗證的實現可能會有所不同,但大致都會完成以下四個階段的驗證:文件格式的驗證、元數據的驗證、字節碼驗證和符號引用驗證;
- 文件格式的驗證:驗證字節流是否符合Class文件格式的規范,并且能被當前版本的虛擬機處理,該驗證的主要目的是保證輸入的字節流能正確地解析并存儲于方法區之內。經過該階段的驗證后,字節流才會進入內存的方法區中進行存儲,后面的三個驗證都是基于方法區的存儲結構進行的;
- 元數據驗證:對類的元數據信息進行語義校驗(其實就是對類中的各數據類型進行語法校驗),保證不存在不符合Java語法規范的元數據信息;
- 字節碼驗證:該階段驗證的主要工作是進行數據流和控制流分析,對類的方法體進行校驗分析,以保證被校驗的類的方法在運行時不會做出危害虛擬機安全的行為;
- 符號引用驗證:這是最后一個階段的驗證,它發生在虛擬機將符號引用轉化為直接引用的時候(解析階段中發生該轉化,后面會有講解),主要是對類自身以外的信息(常量池中的各種符號引用)進行匹配性的校驗;
3、準備階段
準備階段是正式為類變量分配內存并設置類變量初始值的階段,這些變量所使用的內存都將在方法區中進行分配。這個階段中需要注意兩點,首先,這個時候進行內存分配的僅包括類變量(被static修飾的變量)而不包括實例變量,實例變量就在對象實例化時隨著對象一起分配在Java堆中,其次,這里所說的初始值通常是數據類型的零值;
4、解析階段
- 解析階段是虛擬機將常量池中的符號引用轉化為直接引用的過程。在Class類文件結構一文中已經比較過了符號引用和直接引用的區別和關聯,這里不再贅述。前面說解析階段可能開始于初始化之前,也可能在初始化之后開始,虛擬機會根據需要來判斷,到底是在類被加載器加載時就對常量池中的符號引用進行解析(初始化之前),還是等到一個符號引用將要被使用前才去解析它(初始化之后);
- 對同一個符號引用進行多次解析請求時很常見的事情,虛擬機實現可能會對第一次解析的結果進行緩存(在運行時常量池中記錄直接引用,并把常量標示為已解析狀態),從而避免解析動作重復進行;
- 解析動作主要針對類或接口、字段、類方法、接口方法四類符號引用進行,分別對應于常量池中的CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info四種常量類型;
下面是四中符號符號引用的解析過程:
(1) 類或接口的解析
假設當前代碼處于類D中,如果要把一個從未解析過的符號引用N解析為一個類或接口C的直接引用,虛擬機將經歷如下的過程:
- 如果C不是一個數組類型,那虛擬機將會把代表N的全限定名傳遞給D的類加載器區加載這個類。在加載過程中,由于元數據驗證、字節碼驗證的需要,又可能觸發其他相關類的加載,比如這個類的父類或實現的接口。如果加載過程出現異常,解析過程就失敗;
- 如果C是一個數組類型,并且數組的元素類型是對象,就會按照第一點加載數組元素類型。接著由虛擬機生成一個代表著數組維度和元素的數組對象;
- 如果上面的步驟沒有異常,那么C在虛擬機中實際上已經成為一個有效的類或接口了,但在解析完成之前還要進行符號引用驗證,確認D是否具備對C的訪問權限;
(2)字段解析
要解析一個未被解析過的字段符號引用,首先將會對字段表內class_index項中索引的CONSTANT_Class_info符號引用進行解析,也就是字段所屬的類或借口的符號引用。如果解析這個類或接口時發生異常,都會導致解析字段的失敗。如果解析成功,才會繼續解析這個字段。具體的規則如下:
- 如果類或接口C本身就包含了簡單名稱和字段描述符都與目標匹配的字段,則返回這個字段的直接引用,查找結束;
- 否則,如果在C中實現了接口,將會按照繼承關系從下到上遞歸搜索各個接口和它的父接口,如果接口中包含了簡單名稱和字段描述符都與目標匹配的字段,則返回這個字段的直接引用,查找結束;
- 否則,如果C不是Object的話,將會按照繼承關系從下到上遞歸搜索父類,如果父類中包含了簡單名稱和字段描述符都與目標匹配的字段,返回這個字段的直接引用,查找結束;
- 否則,查找失敗;
- 之后,還會對返回的字段進行權限驗證,如果不具備對字段的訪問權限,將拋出java.lang.IllegalAccessError異常;
(3)類方法解析
類方法解析的第一個步驟和字段解析一樣,也需要先解析出類方法表的class_index項中索引的方法所屬的類或接口的符號引用,如果解析成功,按照如下步驟繼續(同樣以C來表示這個類):
- 類方法和接口方法符號引用的常量類型定義是分開的,如果在類方法表中發現class_index中索引的C是一個接口,直接失敗;
- 然后,會在類C中查找這個方法;
- 否則,在類C的父類中遞歸查找這個方法;
- 否則,在類C實現的接口列表中遞歸查找這個方法。如果找到一個匹配的方法,說明類C是一個抽象類,查找結束,拋出java.lang.AbstractMethodError異常;
- 否則,查找失敗,拋出java.lang.NoSuchMethodError異常;
- 同樣,成功返回后還要進行權限驗證;
(4)接口方法解析
接口方法也要解析接口方法表的class_index所屬的類或接口引用,如果解析成功,用C表示這個類或接口,虛擬機按照如下的規則搜索:
- 與類方法解析不同,如果在接口方法表中發現class_index是一個類而不是接口,直接拋出java.lang.IncompatibleClassChangeError異常;
- 否則,在接口C中查找這個方法;
- 否則,在接口C的父接口中遞歸查找;
- 否則,查找失敗,拋出java.lang.NoSuchMethodError異常;
- 返回成功后不會驗證權限,因為接口的方法都是public的;
5、初始化階段
類初始化階段是類加載過程的最后一步,前面的類加載過程中,除了在加載階段用戶應用程序可以通過自定義類加載器參與之外,其余動作完全由虛擬機主導和控制。到了初始化階段,才真正開始執行類中定義的Java程序。
前面已經知道,在準備階段變量已經賦值過一次系統初始值了,而在初始化階段,則會根據程序員通過程序制定的主觀計劃去初始化類變量和其它內容。即,初始化階段是執行類構造器<Test>
<Test>()方法是由編譯器自動收集類中的所有類變量的賦值動作和靜態語句塊中的語句合并產生的,編譯器收集的順序由語句塊在源文件中的順序決定的,靜態語句塊中只能訪問到定義在靜態語句塊之前的變量,定義在之后的變量不能訪問,但能賦值; <Test>()方法與類構造器<Test> ()不同,它不需要顯式調用父類的<Test> ()方法,虛擬機保證在子類<Test> ()方法執行之前,父類的<Test> ()方法已經執行完畢; - 由于父類的<Test>
()方法先執行,意味著父類中定義的靜態語句塊要優先于子類的變量賦值操作; <Test>()方法對于類或接口來說并不是必須的,如果一個類中沒有靜態語句塊,也沒有對變臉的賦值操作,那么編譯器就不會生成<Test> ()方法; - 接口中不能使用靜態語句塊,但仍然有變量初始化的賦值操作,因此接口與類一樣都會生成<Test>
()方法。但接口與類不同的是,執行接口的<Test> ()方法不需要先執行父接口的<Test> ()方法。即,只有使用一個接口中的變量時,才會執行這個接口的<Test> ()方法; - 虛擬機會保證一個類的<Test>
()方法在多線程環境下中被正確的加鎖、同步,如果多個線程同時去初始化一個類,那么只有一個線程會去執行這個類的<Test> ()方法,其他線程都需要阻塞等待,直到活動線程執行<Test> ()方法完畢;
總結
類加載這類的文章會陸續出幾篇,希望大家喜歡!
本文轉載自微信公眾號「Android開發編程」