深入JVM:(九)類加載機制

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

與那些在編譯時需要進行連接工作的語言不同,在Java語言里面,類型的加載、連接和初始化過程都是在程序運行期間完成的,這種策略雖然會令類加載時稍微增加一些性能開銷,但是會為Java應用程序提供高度的靈活性

類加載的過程:

類加載過程.png

類從被加載到內存中開始,到卸載出內存,經歷了加載連接初始化使用四個階段,其中連接又包含了驗證準備解析三個步驟。這些步驟總體上是按照圖中順序進行的,但是Java語言本身支持運行時綁定,所以解析階段也可以是在初始化之后進行的。以上順序都只是說開始的順序,實際過程中是交叉進行的,加載過程中可能就已經開始驗證了。

類的加載時機

什么情況下需要開始類加載過程的第一個階段:加載,Java虛擬機規范中并沒有進行強制約束。但是對于初始化階段,虛擬機規范則是嚴格規定了有且只有5種情況必須立即對類進行“初始化”(而加載、驗證、準備自然需要在此之前開始):

  1. 遇到newgetstaticputstaticinvokestatic這4條字節碼指令時,如果類沒有進行過初始化,則需要先觸發其初始化。生成這4條指令的最常見的Java代碼場景是:使用new關鍵字實例化對象的時候、讀取或設置一個類的靜態字段(被final修飾、已在編譯期把結果放入常量池的靜態字段除外)的時候,以及調用一個類的靜態方法的時候。
  2. 使用java.lang.reflect包的方法對類進行反射調用的時候,如果類沒有進行過初始化,則需要先觸發其初始化。
  3. 當初始化一個類的時候,如果發現其父類還沒有進行過初始化,則需要先觸發其父類的初始化。
  4. 當虛擬機啟動時,用戶需要指定一個要執行的主類(包含main()方法的那個類),虛擬機會先初始化這個主類。
  5. 當使用JDK 1.7的動態語言支持時,如果一個java.lang.invoke.MethodHandle實例最后的解析結果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且這個方法句柄所對應的類沒有進行過初始化,則需要先觸發其初始化。

對于這5種會觸發類進行初始化的場景,虛擬機規范中使用了一個很強烈的限定語:“有且只有”,這5種場景中的行為稱為對一個類進行主動引用。除此之外,所有引用類的方式都不會觸發初始化,稱為被動引用

被動引用的例子一

    /**
     * 通過子類引用父類的靜態字段,不會導致子類初始化
     */
    public class SuperClass {
        static {
            System.out.println("SuperClass init!");
        }

        public static int value = 123;
    }

    public class SubClass extends SuperClass {
        static {
            System.out.println("SubClass init!");
        }
    }

    /**
     * 非主動使用類字段
     */
    public class NotInitialization {
        public static void main(String[] args) {
            System.out.println(SubClass.value);
        }
    }

上述代碼運行之后,只會輸出"SuperClass init!",而不會輸出"SubClass init!"。對于靜態字段,只有直接定義這個字段的類才會被初始化,因此通過其子類來引用父類中定義的靜態字段,只會觸發父類的初始化而不會觸發子類的初始化。至于是否要觸發子類的加載和驗證,在虛擬機規范中并未明確規定,這點取決于虛擬機的具體實現。

被動引用的例子二

    /**
     * 通過數組定義來引用類,不會觸發此類的初始化
     **/
    public class NotInitialization {
        public static void main(String[] args) {
            SuperClass[] sca = new SuperClass[10];
        }
    }

并沒有觸發類org.fenixsoft.classloading.SuperClass的初始化階段。但是這段代碼里面觸發了另外一個名為[Lorg.fenixsoft.classloading.SuperClass的類的初始化階段;這個類代表了一個元素類型為org.fenixsoft.classloading.SuperClass的一維數組,數組中應有的屬性和方法(用戶可直接使用的只有被修飾為public的length屬性和clone()方法)都實現在這個類里。

被動引用的例子三

    /**
     * 常量在編譯階段會存入調用類的常量池中,本質上并沒有直接引用到定義常量的類,因此不會觸發定義常量的類的初始化。
     */
    public class ConstClass {
        static {
            System.out.println("ConstClass init!");
        }

        public static final String HELLOWORLD = "hello world";
    }

    /**
     * 非主動使用類字段演示
     */
    public class NotInitialization {
        public static void main(String[] args)
        {
            System.out.println(ConstClass.HELLOWORLD);
        }
    }

一、加載

加載是“類加載”(Class Loading)過程的一個階段。在加載階段,虛擬機需要完成以下3件事情:

  1. 通過一個類的全限定名來獲取定義此類的二進制字節流。
  2. 將這個字節流所代表的靜態存儲結構轉化為方法區的運行時數據結構。
  3. 在內存中生成一個代表這個類的java.lang.Class對象,作為方法區這個類的各種數據的訪問入口。

通過一個類的全限定名來獲取定義此類的二進制字節流這條,它沒有指明二進制字節流要從一個Class文件中獲取,準確地說是根本沒有指明要從哪里獲取、怎樣獲取。許多舉足輕重的Java技術都建立在這一基礎之上,例如:

  • 從ZIP包中讀取,這很常見,最終成為日后JAR、EAR、WAR格式的基礎。
  • 從網絡中獲取,這種場景最典型的應用就是Applet。
  • 運行時計算生成,這種場景使用得最多的就是動態代理技術,在java.lang.reflect.Proxy中,就是用了ProxyGenerator.generateProxyClass來為特定接口生成形式為"*$Proxy"的代理類的二進制字節流。

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

對于數組類而言,情況就有所不同,數組類本身不通過類加載器創建,它是由Java虛擬機直接創建的。但數組類與類加載器仍然有很密切的關系,因為數組類的元素類型(Element Type,指的是數組去掉所有維度的類型)最終是要靠類加載器去創建的

二、驗證

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

驗證階段是非常重要的,這個階段是否嚴謹,直接決定了Java虛擬機是否能承受惡意代碼的攻擊,從執行性能的角度上講,驗證階段的工作量在虛擬機的類加載子系統中又占了相當大的一部分。如果驗證到輸入的字節流不符合Class文件格式的約束,虛擬機就應拋出一個java.lang.VerifyError異常或其子類異常。

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

  1. 文件格式驗證

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

  • 是否以魔數0xCAFEBABE開頭
  • 主、次版本號是否在當前虛擬機處理范圍之內。
  • 常量池的常量中是否有不被支持的常量類型(檢查常量tag標志)。
  • 指向常量的各種索引值中是否有指向不存在的常量或不符合類型的常量。
  • CONSTANT_Utf8_info型的常量中是否有不符合UTF8編碼的數據。
  • Class文件中各個部分及文件本身是否有被刪除的或附加的其他信息。
    ...

該驗證階段的主要目的是保證輸入的字節流能正確地解析并存儲于方法區之內,格式上符合描述一個Java類型信息的要求。

  1. 元數據驗證

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

  • 這個類是否有父類(除了java.lang.Object之外,所有的類都應當有父類)。
  • 這個類的父類是否繼承了不允許被繼承的類(被final修飾的類)。
  • 如果這個類不是抽象類,是否實現了其父類或接口之中要求實現的所有方法。
  • 類中的字段、方法是否與父類產生矛盾(例如覆蓋了父類的final字段,或者出現不符合規則的方法重載,例如方法參數都一致,但返回值類型卻不同等)。
    ……

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

  1. 字節碼驗證

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

  • 保證任意時刻操作數棧的數據類型與指令代碼序列都能配合工作,例如不會出現類似這樣的情況:在操作棧放置了一個int類型的數據,使用時卻按long類型來加載入本地變量表中。
  • 保證跳轉指令不會跳轉到方法體以外的字節碼指令上。
  • 保證方法體中的類型轉換是有效的,例如可以把一個子類對象賦值給父類數據類型,這是安全的,但是把父類對象賦值給子類數據類型,甚至把對象賦值給與它毫無繼承關系、完全不相干的一個數據類型,則是危險和不合法的。
    ……
  1. 符號引用驗證

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

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

符號引用驗證的目的是確保解析動作能正常執行,如果無法通過符號引用驗證,那么將會拋出一個java.lang.IncompatibleClassChangeError異常的子類,如java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError等。

三、準備

準備階段是正式為類變量分配內存并設置類變量初始值的階段,這些變量所使用的內存都將在方法區中進行分配。
這時候進行內存分配的僅包括類變量(被static修飾的變量),而不包括實例變量,實例變量將會在對象實例化時隨著對象一起分配在Java堆中。其次,這里所說的初始值通常情況下是數據類型的零值,假設一個類變量的定義為:

public static int value = 123;

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

相對的會有一些特殊情況:如果類字段的字段屬性表中存在ConstantValue屬性,那在準備階段變量value就會被初始化為ConstantValue屬性所指定的值,假設上面類變量value的定義變為:

public static final int value = 123;

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

四、解析

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

  • 符號引用(Symbolic References):符號引用以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可。符號引用與虛擬機實現的內存布局無關,引用的目標并不一定已經加載到內存中。各種虛擬機實現的內存布局可以各不相同,但是它們能接受的符號引用必須都是一致的,因為符號引用的字面量形式明確定義在Java虛擬機規范的Class文件格式中。
  • 直接引用(Direct References):直接引用可以是直接指向目標的指針、相對偏移量或是一個能間接定位到目標的句柄。直接引用是和虛擬機實現的內存布局相關的,同一個符號引用在不同虛擬機實例上翻譯出來的直接引用一般不會相同。如果有了直接引用,那引用的目標必定已經在內存中存在。

虛擬機規范之中并未規定解析階段發生的具體時間,只要求了在執行anewarraycheckcastgetfieldgetstaticinstanceofinvokedynamicinvokeinterfaceinvokespecialinvokestaticinvokevirtualldcldc_wmultianewarraynewputfieldputstatic這16個用于操作符號引用的字節碼指令之前,先對它們所使用的符號引用進行解析。

解析動作主要針對類或接口、字段、類方法、接口方法、方法類型、方法句柄和調用點限定符7類符號引用進行

  1. 類或接口的解析

假設當前代碼所處的類為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異常。

  1. 字段解析

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

1)如果C本身就包含了簡單名稱和字段描述符都與目標相匹配的字段,則返回這個字段的直接引用,查找結束。
2)否則,如果在C中實現了接口,將會按照繼承關系從下往上遞歸搜索各個接口和它的父接口,如果接口中包含了簡單名稱和字段描述符都與目標相匹配的字段,則返回這個字段的直接引用,查找結束。
3)否則,如果C不是java.lang.Object的話,將會按照繼承關系從下往上遞歸搜索其父類,如果在父類中包含了簡單名稱和字段描述符都與目標相匹配的字段,則返回這個字段的直接引用,查找結束。
4)否則,查找失敗,拋出java.lang.NoSuchFieldError異常。

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

3.類方法解析

類方法解析的第一個步驟與字段解析一樣,也需要先解析出類方法表的class_index[3]項中索引的方法所屬的類或接口的符號引用,如果解析成功,我們依然用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異常。

  1. 接口方法解析

接口方法也需要先解析出接口方法表的class_index[4]項中索引的方法所屬的類或接口的符號引用,如果解析成功,依然用C表示這個接口,接下來虛擬機將會按照如下步驟進行后續的接口方法搜索。

1)與類方法解析不同,如果在接口方法表中發現class_index中的索引C是個類而不是接口,那就直接拋出java.lang.IncompatibleClassChangeError異常。
2)否則,在接口C中查找是否有簡單名稱和描述符都與目標相匹配的方法,如果有則返回這個方法的直接引用,查找結束。
3)否則,在接口C的父接口中遞歸查找,直到java.lang.Object類(查找范圍會包括Object類)為止,看是否有簡單名稱和描述符都與目標相匹配的方法,如果有則返回這個方法的直接引用,查找結束。
4)否則,宣告方法查找失敗,拋出java.lang.NoSuchMethodError異常。

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

五、初始化

類初始化階段是類加載過程的最后一步,前面的類加載過程中,除了在加載階段用戶應用程序可以通過自定義類加載器參與之外,其余動作完全由虛擬機主導和控制。到了初始化階段,才真正開始執行類中定義的Java程序代碼。
在準備階段,變量已經賦過一次系統要求的初始值。初始化階段是執行類構造器<clinit>()方法的過程

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

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

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

<clinit>()方法對于類或接口來說并不是必需的,如果一個類中沒有靜態語句塊,也沒有對變量的賦值操作,那么編譯器可以不為這個類生成<clinit>()方法。

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

虛擬機會保證一個類的<clinit>()方法在多線程環境中被正確地加鎖、同步,如果多個線程同時去初始化一個類,那么只會有一個線程去執行這個類的<clinit>()方法,其他線程都需要阻塞等待,直到活動線程執行<clinit>()方法完畢。如果在一個類的<clinit>()方法中有耗時很長的操作,就可能造成多個進程阻塞,在實際應用中這種阻塞往往是很隱蔽的。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容