【進階之路】深入理解Java虛擬機的類加載機制(長文)


我們在參加面試的時候,經常被問到一些關於類加載機制的問題,也都會在面試之前准備的時候背好答案,但是我們是否有去深入了解什么是類加載機制呢?這段時間因為一些事情在家看了些書,這次就和大家分享一些關於Java類加載機制的知識。

虛擬機的類加載機制:Java虛擬機把數據加載到內存,同時對數據進行校驗、解析、初始化等一些列操作,最終把Class文件變為虛擬機可以直接使用的Java類型文件。

一個類從被加載到虛擬機內存開始,直到卸載出內存為止,他的生命周期會經歷加載驗證准備解析初始化使用卸載七個階段(其中驗證、准備、解析三個階段被稱為連接)

連接就是將已經讀入到內存的類的二進制數據合並到虛擬機的運行時環境中去,所以這三個階段可以看成是一整個階段。

image.png

一、加載階段

其中,加載、驗證、准備、初始化和卸載這五個階段的順序是確定的,類加載的順序必須按照這種順序按部就班的開始,而解析階段則不一樣,有時候為了支持動態綁定它可以在初始化階段之后再進行解析。
至於何時會進行加載階段,《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);
}

這是一個很有名的例子,告訴我們子類引用父類的靜態字段,並不會導致子類的初始化,只有直接定義這個字段的類才會被初始化

image.png

我們再來看看如果在編譯階段把數據放入常量池,是否會進行初始化。

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天生可以動態擴展的語言特性就是一類運行期動態加載和動態鏈接這個特性實現的。

有需要的同學可以加我的公眾號,以后的最新的文章第一時間都在里面,也可以找我要思維導圖


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM