Java——類加載機制詳解

一、什麼是類加載

虛擬機把描述類的數據從class文件(代表類和接口,是一串二進制流)加載到內存,並對數據進行校驗、轉換解析和初始化,最終形成可以被虛擬機直接使用的java類型。

 

二、什麼時候進行類加載

與那些在編譯時需要進行連接工作的語言不同,在java語言裏面,類型的加載、連接和初始化過程都是在程序運行期間完成的。

這種策略雖然會令類加載時稍微增加一些性能開銷 ,但是會爲Java應用程序提供高度的靈活性,Java裏天生可以動態擴展的語言特性就是依賴運行期動態加載和動態連接這個特點實現的。

 

三、類加載的時機

類從被加載到虛擬機內存中開始,到卸載出內存爲止,它的整個生命週期包括:

加載 ( Loading ) 、驗證( Verification ) 、準備( Preparation ) 、解析( Resolution )、初始化( Initialization ) 、使用( Using ) 和卸載( Unloading ) 7個階段。其中驗證、準備、解析3個部分統稱爲連接( Linking ) ,這7個階段的發生順序如圖7-1所示。

圖7-1中 ,加載、驗證、準備、初始化和卸載這5個階段的順序是確定的,類的加載過程必須按照這種順序按部就班地開始,而解析階段則不一定:它在某些情況下可以在初始化階段之後再開始,這是爲了支持Java語言的運行時綁定(也稱爲動態綁定或晚期綁定)。

注意:說的是按部就班地“開始” ,而不是按部就班地“進行”或“完成”,強調這點是因爲這些階段通常都是互相交叉地混合式進行的,通常會在一個階段執行的過程中調用、激活另外一個階段。

 

1、加載

1.1、when?

Java虛擬機規範中沒有進行強制約束,這點可以交給虛擬機的具體實現來自由把握。

 

1.2、作用

1.通過一個類的全限定名來獲取此類的二進制字節流(沒有說一定要從class文件獲取)

2.將這個字節流所代表的靜態存儲結構轉化爲方法區的運行時數據結構

3.最後在java堆中生成一個代表這個類的Class對象,作爲方法區這些數據的訪問入口。

總的來說就是查找並加載類的二進制數據。 

 

注意:加載階段與連接階段的部分內容(如一部分字節碼文件格式驗證動作)是交叉進行的,加載階段尚未完成,連接階段可能已經開始,但這些夾在加載階段之中進行的動作,仍然屬於連接階段的內容,這兩個階段開始時間仍然保持着固定的先後順序。

 

2、驗證

2.1、作用

爲了確保Class文件的字節流中包含的信息符合當前虛擬機的要求,並且不會危害虛擬機自身的安全。

 

Java語言本身是相對安全的語言,如果中間有危險操作,編譯器會拒絕編譯,那爲什麼還不夠安全?

但是前面我們說了,Class文件並不一定要求用Java源碼編譯而來。虛擬機如果不檢查輸入的的字節流,對其完全信任的話,很可能會因爲載入了有害的字節流而導致系統崩潰。從執行性能的角度上講,驗證階段的工作量在虛擬機的類加載子系統中又佔了相當大的一部分。

 

2.2、動作

2.2.1、文件格式驗證

第一階段要驗證字節流是否符合Class文件格式的規範,並且能被當前版本的虛擬機處理。

該驗證階段的主要目的是保證輸入的字節流能正確的解析並存儲於方法區之內,格式上符合描述一個Java類型的信息的要求。這階段的驗證是基於字節流進行的,經過這個階段的驗證之後,字節流纔會進入內存的方法區進行存儲,所以後面三個驗證階段全部是基於方法區的存儲結構進行的。

 

2.2.2、元數據驗證

第二階段是對字節碼描述的信息進行語義分析,以保證其描述的信息符合Java語言的規範要求,這個階段可能包括的驗證點如下:

  • 這個類是否有父類(除了java.lang.Object之外,所有的類應當有父類)
  • 這個類的父類是否繼承了不允許被繼承的類(被final修飾)
  • 如果這個類不是抽象類,是否實現了其父類或接口之中要求實現的所有方法。
  • 類中的字段、方法是否與父類產生了矛盾
  • .....

 

2.2.3、字節碼驗證

第三個階段是驗證過程中最複雜的一個,其主要工作是進行數據流和控制流分析。第二階段對元數據信息中的數據類型做完校驗後,這階段將對類的方法體進行校驗分析。這階段的任務是保證被校驗類的方法在運行時不會做出危害虛擬機安全的行爲,例如

  • 保證任意時刻操作數棧的數據類型與指令代碼序列都能配合工作,例如不會出現類似這種情況:在操作棧中放置了一個int類型的數據,使用時卻按long類型來加載入本地變量表中。
  • 保證跳轉指令不會跳轉到方法體以外的字節碼指令上。
  • 保證方法體中的類型轉換是有效的,例如可以把一個子類對象賦值給父類數據類型,這是安全的,反之不合法。
  • .......

 

2.2.4、符號引用驗證

最後一個階段的校驗發生在虛擬機將符號引用轉化爲直接引用的時候,這個轉化動作將在連接的第三個階段——解析階段中發生。符號引用驗證可以看做是對類自身以外的信息進行匹配性的校驗,通常需要校驗以下內容:

  • 符號引用中通過字符串描述的全限定名是否能找到對應的類。
  • 在指定類中是否存在符合方法的字段描述符以及簡單名稱所描述的方法和字段。
  • 符號引用中的類、字段和方法的訪問性(private、protected、public、default)是否可被當前類訪問。
  • .........

 

3、準備

準備階段是正式爲類變量分配內存並設置類變量初始值得階段,這些內存都將在方法區中進行分配。這個階段中有兩個容易產生混淆的概念需要強調一下,首先是這時候進行內存分配的僅包括類變量(被static修飾的變量),而不包括實例變量,實例變量將會在對象實例化時睡着對象一起分配在Java堆中。其次是這裏所說的初始值“通常情況下”是數據類型的零值。

 

public static int value=123;

此時value初始化爲0,而不是123,因爲這時候尚未開始執行任何Java方法,而把value賦值爲123的putstatic指令是程序被編譯後,存放於類構造器<clinit>()方法之中,所以把value賦值爲123的動作將在初始化階段纔會執行。

 

public static final int value = 123;

類字段的字段屬性表中存在ConstantValue屬性,編譯時Javac將會爲value生成ConstantValue屬性,在準備階段虛擬機就會根據ConstantValue的設置將value賦值爲123。

 

4、解析

先了解一些相關知識:

符號引用(Symbolic References): 符號引用以一組符號來描述所引用的目標,符號可以使任何形式的字面量,只要使用時能無歧義地定位到目標即可。符號引用於虛擬機實現的內存佈局無關,引用的目標不一定已經加載到內存中。但各個虛擬機能接受的符號引用必須都是一致的,因爲符號引用的字面量形式明確定義在Java虛擬機規範的Class文件格式中。

直接引用(Direct References):直接引用可以是直接指向目標的指針,相對偏移量或是一個能簡介定位到目標的句柄,直接引用是與虛擬機實現的內存佈局相關的,同一符號引用在不同虛擬機實例上翻譯出來的直接引用一般不會相同,如果有了直接引用,那引用的目標必定已經在內存中存在。

 

4.1、什麼是解析階段

解析階段是虛擬機將常量池內的符號引用替換爲直接引用的過程。

 

4.2、解析階段什麼時候發生

虛擬機規範之中並未規定解析階段發生的具體時間,只要求在執行anewarray、checkcast、getfield、getstatic、instanceof、invokeinterface、invokespecial、invokestatic、invokevirtual、multianewarray、new、putfield和putstatic這13個用於操作符引用的字節碼指令之前,先對它們使用的符號引用進行解析。

 

4.3、解析階段

解析動作主要針對類或接口、字段、類方法、接口方法、方法類型、方法句柄和調用點限定符7類符號引用進行,下面講解前面四種引用的解析過程。

4.3.1、類或接口的解析

假設當前代碼所處的類爲D,如果要把一個從未解析過的符號引用N解析爲一個類或接口C的直接引用,那虛擬機完成整個解析的過程需要包括以下三個步驟:
    1)如果C不是一個數組類型,那虛擬機將會把代表N的全限定名傳遞給D的類加載器去加載這個類C。在加載過程中,由於無數據驗證、字節碼驗證的需要,又將可能觸發其他相關類的加載動作,例如加載這個類的父類或實現的接口。一旦這個加載過程出現了任何異常,解析過程就將宣告失敗。

    2)如果C是一個數組類型,並且數組的元素類型爲對象,也就是N的描述符會是類似“Ljava.lang.Integer”的形式,那將會按照第一點的規則加載數組元素類型。如果N的描述符如前面所假設的形式,需要加載的元素類型就是“java.lang.Integer”,接着由虛擬機生成一個代表此數組維度和元素的數組對象。

    3)如果上面步驟沒有出現任何異常,那麼C在虛擬機中實際上已經成爲一個有效的類或接口了,但在解析完成之前還要進行符號引用驗證,確認D是否具備對C的訪問權限。如果發現不具備訪問權限,將拋出java.lang.illegalAccessError異常。

 

4.3.2、字段解析

要解析一個未被解析過的字段符號引用,首先對堆字段表內class_index項中索引CONSTANT_Class_info符號引用進行解析,也就是字段所屬的類或接口的符號引用。如果在解析這個類或接口符號引用的過程中出現了任何異常,都會導致字段符號引用解析的失敗。如果解析成功完成,那將這個字段所屬的類或接口用C表示,虛擬機規範要求按照如下步驟對C進行後續字段的搜索:

    1)如果C本身就包含了簡單名稱和字段描述符都與目標匹配的字段,則返回這個字段的直接引用,查找結束。

    2)否則,如果在C中實現了接口,將會按照繼承關係從上往下遞歸搜索各個接口和它的父接口。如果接口中包含了簡單名稱和字段描述符都與目標匹配的字段,則返回這個字段的直接引用,查找結束。

    3)否則,如果C不是java.lang.Object的話,將會按照繼承關係從上往下遞歸搜索其父類,如果父類中包含了簡單名稱和字段描述符都與目標相匹配的字段,則返回這個字段的直接引用,查找結束。

    4)否則,查找失敗,拋出java.lang.NoSuchFieldError異常。

如果查找過程成功返回了引用,將會對這個字段進行權限驗證,如果發現不具備對字段的訪問權限,將拋出java.lang.IllegalAccessError異常。

注意:在實際應用中,虛擬機的編譯器實現可能會比上述規範要求得更加嚴格一些,如果有一個同名字段同時出現在C的接口和父類中,或者同時在自己或父類的多個接口中出現,那編譯器將可能拒絕編譯。

 

4.3.3、類方法解析

類方法解析第一步和字段解析一樣,線解析出class_index項中索引的方法所屬的類或接口的符號引用,如果解析成功,用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異常。

 

4.3.4、接口方法解析

接口方法也是需要先解析出接口方法表的class_index項中索引的方法所屬的類或接口的符號引用,如果解析成功,依然用C表示這個接口,接下來虛擬機將會按照如下步驟進行後續的接口方法搜索:
    1)與類方法解析相反,如果在接口方法表中發現class_index中索引C是個類而不是接口,那就直接拋出java.lang.IncompatibleClassChangeError異常。

    2)否則,在接口中查找是否有此方法,如果有則返回這個方法的直接引用,查找結束。

    3)否則,在接口C的父接口中遞歸查找,直到java.lang.Object類爲止,看是否有此方法,如果有則返回這個方法直接引用,查找結束。

    4)否者,宣告方法查找失敗,拋出java.lang.NoSuchMethodError異常。

因爲接口方法都是public 所以沒有IllegalAccessError異常。

 

5、初始化

類初始化階段是類加載過程的最後一步,到了初始化階段,才真正開始執行類中定義的java程序代碼。

 

5.1、什麼時候初始化

Java類加載機制中,規定了有且僅有5種情況必須立即對類進行初始化:

 1. 遇到new,getstatic,putstatic和invokestatic這4條指令時,如果類沒有初始化時,必須初始化類。四條指令對應我們日常所見的 使用new關鍵字實例化對象,讀取一個類的靜態字段,設置一個類的靜態字段(被final修飾的靜態字段除外,因爲已在編譯期把結果放入常量池中了)和調用一個類的靜態方法。

2. 對類進行反射調用時。

3. 當初始化一個子類時,發現其父類沒有初始化,則需先出發其父類的初始化。

4. 當虛擬機啓動時,一個類包含main()方法時,當前類需要初始化

5. 當使用動態語言支持時,如果一個java.lang.invoke.MethodHandle實例後解析結果REF_putStatic,REF_getStatic,REF_invokeStatic的方法句柄時,當改方法句柄對應的類沒有初始化時,需要初始化該類。
 

這5種場景中的行爲稱爲對一個類進行主動引用。除此之外,所有引用類的方式都不會觸發初始化,稱爲被動引用。

 

注意:

1.通過子類來引用父類中定義的靜態字段,只會觸發父類的初始化而不會觸發子類的初始化。(是否觸發子類的加載和驗證,取決於虛擬機的具體實現)

2.當一個類在初始化時,要求其父類全部都已經初始化過了,但是一個接口在初始化時,並不要求其父接口全部完成了初始化,只有在真正使用到父接口的時候(如引用接口中定義的常量)纔會初始化。

 

5.2、初始化目的

在準備階段,變量已經賦過一次系統要求的初始值,而在初始化階段,則根據程序員通過程序制定的主觀計劃去初始化類變量和其他資源,或者說:初始化階段是執行類構造器<clinit>()方法的過程.

 

5.3、<clinit>()方法執行過程中的一些特點和細節

1.<clinit>()方法是由編譯器自動收集類中所有的類變量賦值動作和靜態語句塊中的語句合併產生的,收集順序取決於出現在源文件中的位置。靜態語句塊中只能訪問到定義在靜態語句塊之前的變量,定義在它之後的變量,在前面的靜態語句塊中可以複製,但是不能訪問。

2.<clinit>()方法與類的構造函數不同,它不需要顯示調用父類構造器,虛擬機保證在子類的<clinit>()方法執行之前,父類的<clinit>()方法已經執行完畢。因此虛擬機中第一個被執行的<clinit>()方法的類肯定是java.lang.Object。

3.由於父類的<clinit>()方法先執行,也就意味着父類中定義的靜態語句塊要優先於子類的變量賦值操作。

4.如果一個類中沒有靜態變量或靜態語句塊,那麼編譯器可以不爲這個類生成<clinit>()方法。

5.接口中不能使用靜態代碼塊,但可使用靜態變量,所以也會生成<clinit>()方法。與類不同的是,執行接口的<clinit>()方法不需要先執行父接口的<clinit>()方法。只有父接口中定義的變量被使用時父接口才會被初始化,另外,接口的實現類在初始化時也一樣不會執行接口的<clinit>()方法。

6.虛擬機會保證一個類的<clinit>()方法在多線程環境中被正確的加鎖和同步。多線程訪問,一個訪問其他都被阻塞。

 

參考:

《深入瞭解Java虛擬機》

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章