JVM虛擬機(五)類加載機制

虛擬機把描述類的數據從Class文件加載到內存,並對數據進行校驗、轉換解析和初始化,最終形成可以被虛擬機直接使用的java類型,這就是虛擬機的類加載機制。

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

1.類加載的時機

類從被加載到虛擬機內存中開始,到卸載出內存爲止,它的整個生命週期包括:加載(Loading)、驗證(Verification)、準備(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸載(Unloading)7個階段,其中驗證、準備和解析3個部分統稱爲連接(Linking),這7個階段發生的順序如下:


加載、驗證、準備、初始化和卸載這5個階段的順序是確定的,類加載過程必須按照這種順序按部就班的開始,而解析階段不一定:在某些情況下可以在初始化階段之後再開始,這是爲了支持Java語言的運行時綁定(也稱爲動態綁定或晚期綁定)。這裏說按部就班的“開始”,強調這點是因爲這些階段通常都會在互相交叉地混合式進行的,通常會在一個階段執行過程中調用、。激活另外一個階段。

什麼時候加載由虛擬機具體實現具體把握。但是初始化階段,虛擬機規範有了嚴格規定了有且只有5種情況必須立即對類進行“初始化”(而加載、驗證、準備自然在此之前開始):

    1).遇到new、getstatic、putstatic或invokestatic這4條字節碼指令時,如果類沒有進行過初始化,則需先觸發初始化。

    2).使用java.lang.reflect包的方法對類進行反射調用的時候,如果沒有進行過初始化,則需要先觸發初始化。

    3).當初始化一個類的時候,如果發現其父類還沒有進行初始化,則需要先觸發其父類的初始化。

    4).當虛擬機啓動時,用戶需要指定一個要執行的主類(包含main()方法的那個類),虛擬機會先初始化這個主類。

    5).當使用JDK1.7的動態語言支持時,如果一個java.lang.invoke.MethodHandle實例最後的解析結果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,並且這個方法句柄所對應的類沒有進行過初始化,則需要先觸發其初始化。

接口與類真正有所區別的是前面講述的5中“有且僅有”需要開始初始化場景中的第3種:當一個類在初始化時,要求其父類全部都已經初始化了,但是一個接口初始化時,並不要求其父類接口全部都完成了初始化,只有在真正使用到父類接口的時候纔會初始化。

2.類加載的過程

類加載全過程,也就是加載、驗證、準備、解析和初始化這5個階段所執行的具體操作。

2.1加載

“加載”是“類加載”過程中的一個階段,希望讀者沒有混淆這兩個看起來相似的名詞。在加載階段,虛擬機需要完成以下3件事情:

    1)通過一個類的全限定名來獲取定義此類的二進制字節流。

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

    3)內存中生成一個代表這個類的java.lang.Class對象,作爲方法區這個類的各種數據的訪問入口。

虛擬機規範的這3點要求其實並不算具體,因此虛擬機實現與具體應用的靈活度都是相當大的。

相對於類加載過程的其他階段,一個非數組類的加載階段是開發人員可控性最強的,因爲加載階段既可以使用系統提供的引導類加載器來完成,也可以由用戶自定義的類加載器區完成,開發人員可以通過定義自己的類加載器去控制字節流的獲取方式(即重寫一個類加載器的loadClass()方法)。

對於數組類而言,情況就有所不同,數組類本身不通過類加載器創建,它是由Java虛擬機直接創建的。但數組類與類加載器仍然有很密切的關係,因爲數組類的元素類型(Element Type,指的是數組去掉所有維度的類型)最終是要靠加載器區創建,一個數組類(下面簡稱爲C)創建過程就遵循以下規則:

    如果數組的組件類型(Component Type,指的是數組去掉一個維度的類型)是引用類型,那就遞歸採用本節中定義的加載過程去加載這個組件類型,數組C將在加載該組件類型的類加載器的類名稱空間上被標識。

    如果數組的組件類型不是引用類型(例如int[]),Java虛擬機將會把數組C標記爲與引導類加載器關聯。

數組類的可見性與它的組件類型的可見性一致,如果組件類型不是引用類型,那數組的可見性將默認爲public。

加載階段完成後,虛擬機外部的二進制字節流就按照虛擬機所需的格式存儲在方法區之中,方法區中的數據存儲格式有虛擬機實現自行定義,虛擬機規範未規定此區域的具體數據結構。然後在內存中實例化一個java.lang.Class類的對象,這個對象將作爲程序訪問方法區中的這些類型數據的外部接口。

2.2 驗證

驗證是連接階段的第一步,這一階段的目的是爲了確保Class文件的字節流中包含的信息符合當前虛擬機的要求,並且不會危害虛擬機自身的安全。

字節碼文件可以使用多種突降產生。虛擬機如果不檢查輸入的字節流。很可能導致系統崩潰。所以驗證是非常重要的,這個階段是否嚴謹,直接決定了虛擬機是否能承受惡意代碼的攻擊。如果驗證的字節流不符合Class文件格式的約束,虛擬機應拋出一個java.lang.VerifyError異常或子類異常。

驗證階段大致上完成4個階段的檢驗動作:文件格式的驗證、元數據驗證、字節碼驗證、符號引用驗證。

    文件格式驗證:

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

        是否以魔數0xCAFEBABE開頭。

        主、此版本號是否在當前虛擬機處理範圍之內。

        常量池的常量中是否有不被支持的常量類型(查看常量tag標誌)。

        指向常量的各種索引值中是否有指向不存在的常量或者不符合類型的常量。

        CONSTANT_Utf8_info型的常量中是否有不符合UTF8編碼的數據。

        Class文件中各個部分及文件本身是否有被刪除的或附加的其他信息。

        ……

    這階段的驗證是基於二進制字節流進行的,只有通過了這個階段的驗證後,字節流纔會進入內存區中進行存儲,所以後面的3個驗證階段全部是基於方法區的存儲結構進行的,不會直接操作字節流。

    元數據驗證

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

        這個類是否有父類(除了Object類之外,所有類都應當有父類)。

        這個類的父類是否繼承了 不允許被繼承的類(被final修飾的類)。

        如果這個類不是抽象類,是否實現了其父類或接口之中要求實現的方法。

        類中的字段、方法、是否與父類產生矛盾(如:覆蓋了父類的final字段,或者出現不符合規則的方法重載(如:方法參數都一致,但返回類型卻不同))

        ……

第二階段的主要目的是對類的元數據信息進行語義校驗,保存不存在不符合Java語言規範的元數據信息。

    字節碼驗證

    第三階段是整個驗證過程中最複雜的一個階段,主要目的是通過數據流和控制流分析,確定程序語義是合法的、符合邏輯的。這個階段將對類的方法體進行校驗分析,保證被校驗的方法在運行時不會在做出危害虛擬機安全的事件。

    符號引用驗證

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

        符號引用中通過字符串描述的全限定名是否能找到對應的類。

        在指定類中是否存在符合方法的字段描述符以及簡單名稱多描述的方法和字段。

        ……

    符號引用驗證的目的是確保解析動作能正常運行。

    對於虛擬機類加載機制來說。驗證階段是非常重要的、但不是一定必要的階段。如果所運行的全部代碼都已經被反覆的使用和驗證過,那麼實施階段可以考慮使用-Xverify:none參數關閉打不上分的類驗證措施。

2.3 準備

準備階段是正式爲類變量(static修飾)分配內存並設置類變量初始值的階段,這些變量所使用的內存都是在方法區中進行分配。

強調兩個易混淆的概念:

    首先,這時候內存分配的僅包括類變量(static修飾的變量),而不包括實例變量,實例變量將會在對象實例化時隨着對象一起分配在java堆中。

    其次,這裏所說的初始值“通常情況”下是數據類型的零值。在一些“特殊情況”下:如果類字段的字段屬性表中存在ConstantValue屬性,那在準備階段變量值就會被初始化爲ConstantValue屬性所指定的值。(如:public static final int value = 123;)

2.4 解析

解析階段是虛擬機將常量池內的符號引用替換爲直接引用的過程。在Class文件中它以CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等類型的常量出現,那解析階段中所說的直接引用與符號引用又有什麼關聯呢?

符號引用(Symbolic References):符號引用以一組符號來描述多引用的目標,符號可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可。

直接引用(Direct References):直接引用可以是直接指向目標的指針、相對偏移量或是一個能間接定位到目標的句柄。直接引用是和虛擬機實現的內存佈局相關的,同一符號引用在不同虛擬機上翻譯出來的直接引用一般不會相同。

虛擬機規範之中並未規定解析階段發生的具體時間,只要求了在執行anewarray、checkcast、getfield、getstatic、instanceof、invokedynamic、invokeinterface、invokespecial、invokestatic、invokevirtual、ldc、ldc_w、multianewarray、new、putfield和putstatic這16個用於操作符號引用的字節碼指令之前,先對它們所使用的符號引用進行解析。所以虛擬機實現可以根據需要來判斷到底是在類被加載器加載時就對常量池中的符號引用進行解析,還是等到一個符號引用將要被使用前纔去解析它。

除invokedynamic指令以外,虛擬機實現都可以對第一次解析的結果進行緩存從而避免解析動作重複進行。無論是否真正執行了多次解析動作,虛擬機需要保證的是在同一個實體中,如果一個符號引用之前已經被成功解析過,那麼後續的引用解析請求就應當一直成功;同樣的,如果第一次解析失敗了,那麼其他指令對這個符號的解析請求也應該收到相同的異常。

對於invokedynamic指令,上面規則不成立。當碰到某個前面已經由invokedynamic指令觸發過解析得符號引用時,並不意味着這個解析結果對於其他invokedynamic指令也同樣生效。因爲invokedynamic指令的目的本來就是用於支持動態語言支持,它所對應的引用稱爲“動態調用點限定符”。這裏“動態”的含義是必須等到程序實際運行到這條指令的時候,解析動作才能進行。其餘觸發解析的指令都是“靜態”的,可以在剛剛完成加載階段,還沒有開始執行代碼時就進行解析。

解析動作只要針對類或接口、字段、類方法、接口方法、方法類型、方法句柄和調用點限定符7類符號引用進行,分別對應於常量池的

CONSTANT_Class_info、

CONSTANT_Fieldref_info、

CONSTANT_Methodref_info、

CONSTANT_InterfaceMethodref_info、

CONSTANT_MethodType_info、

CONSTANT_MethodHandle_info和CONSTANT_InvokeDynamic_info7種常量類型。

下面講解前4種引用的解析過程:

    類或接口的解析

    假設當前代碼所處的類爲D,如果要把一個從未解析過的符號引用N解析爲一個類或接口C的直接引用,那麼虛擬機完成整個解析過程需要以下3個步驟:

        1)如果C不是一個數組類型,那虛擬機將會把代表N的全限定名傳遞給D的類加載器區加載這個類C。

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

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

    字段解析

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

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

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

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

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

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

    類方法解析

    類方法解析的第一步與字段解析一樣,也需先解析類方法表中的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異常。

    接口方法解析

    接口方法解析需先解析類方法表中的class_index項中索引的方法所屬的類或接口的符號引用,如果解析成功,我們依然使用C表示這個類,接下來按如下步驟進行後續的接口方法的搜索:

        1)與類方法解析不同,如果在接口方法表中發現了class_index中索引的C是個類而不是接口,那就直接拋出java.lang.IncompatibleClassChangeError異常。

        2)否則,在接口C中查找是否有簡單明和描述符與目標相匹配的方法,如果有則返回這個方法的直接引用,查找結束。

        3)否則,在接口C的父接口中遞歸查找,直到java.lang.Object類(查找範圍會包括Object類)爲止,看是否有簡單名和描述符都與目標相匹配的方法,如果有則返回這個方法的直接引用,查找結束

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

由於接口中的所有方法默認都是public的,所以不存在訪問權限問題,因此接口方法的符號解析應當不會拋出java.lang.IllegalAccessError異常。

2.5 初始化

初始化階段是類加載過程的最後一步,前面類加載過程,除了在加載階段用戶應用程序可以通過自定義類加載器參與外,其餘動作完全由虛擬機主導和控制。到了初始化階段才真正開始執行類中定義的Java程序代碼(或者說是字節碼)。

在初始化階段,根據程序員通過程序制定的主觀計劃初始化類變量和其他資源,或者可以從另外一個表達:初始化階段執行類構造器<clinit>()放的的過程。

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


<clinit>()方法與類的構造器(或者說實例構造器<init>()的方法),它不需要顯示地調用父類構造器,虛擬機會保證在子類的<clinit>()方法執行之前,父類的<clinit>()方法已經執行完畢。

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

<clinit>()方法對於類或者接口來說並不是必須的,如果一個類中沒有靜態語句塊,也沒有對變量的賦值操作,納悶編譯器可以不爲這個類生成<clinit>()方法。

接口中不能使用靜態語句塊,但仍然有變量初始化的賦值操作,因此接口與類一樣都會生成<clinit>()方法。但接口與類不同的是,執行接口的<clinit>()方法不需要先執行父接口的<clinit>()方法。只有當父接口中定義的變量使用時,父接口才會初始化。另外,接口的實現類在初始化時也一樣不會執行接口的<clinit>()方法。

虛擬機會保證一個類的<clinit>()方法在堆線程環境中被正確的加鎖、同步,如果多個線程同時區初始化一個類,那麼會有一個線程區執行這個類<clinit>()方法,其他線程都需要阻塞等待,知道活動線程執行<clinit>()方法完畢。

3 類加載器

類加載器雖然只用於實現類的加載動作,但它在Java程序中起到的作用卻遠遠不限於加載階段。對於任意一個類,都需要由加載它的類加載器和這個類本身一同確立其在Java虛擬機中的唯一性,每個類加載器,都有一個獨立的類名稱空間。

比較兩個類是否“相等”,只有這兩個類是由同一個類加載器加載的前提下才有意義,否則,即使這兩個類來源於同一Class文件,被同一個虛擬機加載,只有加載它們的類加載器不同,那這兩個類就必定不相等。這裏所指的“相等”,包括代表類的Class對象的equals()方法、isAssignableFrom()方法、isInstance()方法的返回結果,也包括instanceof關鍵字做對象所屬關係判定等情況。

雙親委派機制

從java虛擬機的角度來講,只存在兩種不同的類加載器:一種是啓動類加載器,這個類加載器使用c++實現,是虛擬機自身的一本分;另外一種是所有其他的類加載器,這些類加載器都有java語言實現,獨立於虛擬機外部,並且全部繼承自java.lang.ClassLoader。

對於Java開發人員的角度來看,類加載器還可以劃分得更細緻些,絕大部分Java程序都會使用到以下3種系統提供的類加載器。

    啓動類加載器(Bootstrap ClassLoader):這個類加載器負責將存放在<JAVA_HOME>\lib目錄中的,或者被-Xbootclasspath參數所指定的路徑中的,並且是虛擬界識別的類庫加載到虛擬機內存中。啓動類加載器無法被Java程序直接引用,用戶編寫自定義類加載器時,如果需要把加載請求委派給引導類加載器,那直接使用null代替即可。以下是ClassLoader.getClassLoader()代碼:


    擴展類加載器(Extension ClassLoader):這個加載器由sun.misc.Lanuncher $ExtClassLoader實現,它負責加載<JAVA_HOMEM>\lib\ext目錄中的,或者被java.ext.dirs系統變量所指定的路徑中的所有類庫,開發者可以直接使用擴展類加載器。

    應用程序類加載器(Application ClassLoader):這個類加載器由sun.misc.Lanuncher $App-ClassLoader實現。由於這個類加載器是ClassLoader中的getSystemClassLoader()方法返回的值,所以一般也稱爲系統類加載器。他負責加載用戶類路徑(classpath)上所指定的類庫,開發者沒喲自定義過自己的類加載器,一般情況下這個就是默認的類加載器,

我們的應用程序都是由這3種類加載器互相配合進行加載的,如果有必要,還可以加入自己定義的類加載器。這些類加載器之間的關係一般如下圖:


類加載器之間的這種層次關係,稱爲類加載器的雙親委派模型。雙親委派模型要求除了頂層的啓動類加載器外,其餘的類加載器都應該有自己的父類加載器。這裏類加載器之間的父子關係一般不會以繼承的關係實現,而是都是用組合的關係來複用父加載器的代碼。

雙親委派模型的工作過程:如果一個類加載器收到了類加載的請求,他首先不會自己去嘗試加載這個類,而是把這個請求委派給父類加載器去完成,每一個層次的類加載器都是如此,因此所有的加載請求最終都應該傳送到頂層的啓動類加載器找那個,只有當父加載器反饋自己無法完成這個加載請求時,自加載器纔會嘗試自己去加載。

實現雙親委派模型的代碼都集中在java.lang.ClassLoader的loadClass()方法之中,如下圖:邏輯清晰易懂:先檢查是否已經被加載過,若沒有加載則調用父類加載器的loadClass()方法,若父加載器爲空則默認使用啓動類加載器。如果父類加載失敗,拋出ClassNotFoundException異常後,在調用自己的findClass()方法進行加載。




參考書籍:《深入理解虛擬機:JVM高級特性與最佳實踐》(第二版)

發佈了14 篇原創文章 · 獲贊 8 · 訪問量 5萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章