類加載過程(深入理解Java虛擬機筆記)

概述

在Java語言裏,類型的加載,連接和初始化過程都是在程序運行期間完成的。Java虛擬機把描述類的數據從Class文件加載到內存,並對數據進行校驗,轉換解析和初始化,最終形成可以被虛擬機直接使用的Java類型,這個過程稱爲類的加載機制。

這種策略爲Java提供了極高的擴展性和靈活性,Java天生可以動態動態擴展的語言特性 就是依賴運行期動態加載和動態連接這個特點實現的。例如,在編寫一個面向接口的應用程序,可以等到運行時再指定其實際的實現類。用戶可以通過Java預置的 或自定義類加載器,讓某個本地的應用程序在運行時 從網絡或其他地方加載一個二進制流作爲代碼的一部分。這種動態組裝應用的方式廣泛應用於Java程序中,如JSP,OSGi。

 

類加載時機

一個類從被加載到虛擬機內存中開始,到卸載出內存爲止,它的整個生命週期將會經歷加載 (Loading)、驗證(Verification)、準備、解析、初始化 、使用)和卸載七個階段,其中驗證、準備、解析三個部分統稱 爲連接。

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

注:按部就班地“開始” 並不意味 按部就班地“進行” 或 按部就班地“完成”。這些階段通常是互相交叉的混合進行的,會在一個階段執行的過程中調用,激活另一個階段

《Java虛擬機規範》 規定了有且只有六種情況必須立即對類進行“初始化”(加載、驗證、準備需要在此之 前開始):

(1)遇到new、getstatic、putstatic或invokestatic這四條字節碼指令時,如果類型沒有進行過初始 化,則需要先觸發其初始化階段。能夠生成這四條指令的典型Java代碼場景有:

  • ·使用new關鍵字實例化對象的時候。
  • ·讀取或設置一個類型的靜態字段(被final修飾、已在編譯期把結果放入常量池的靜態字段除外) 的時候。
  • ·調用一個類型的靜態方法的時候。

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

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

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

(5)當一個接口中定義了JDK 8新加入的默認方法(被default關鍵字修飾的接口方法)時,如果有 這個接口的實現類發生了初始化,那該接口要在其之前被初始化。

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

上述六種場景的行爲稱爲對一個類型的主動引用,除此之外的所有引用類型方式都不會觸發初始化,稱爲被動引用。下面來說明什麼是被動引用:

(1)通過子類引用父類的靜態字段,不會導致子類初始化

package org.fenixsoft.classloading;

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!”。

對於靜態字段, 只有直接定義這個字段的類纔會被初始化,因此通過其子類來引用父類中定義的靜態字段,只會觸發 父類的初始化而不會觸發子類的初始化。至於是否要觸發子類的加載和驗證階段,取決於虛擬機的具體實現。對於HotSpot虛擬機來說,可通過-XX: +TraceClassLoading參數觀察到此操作是會導致子類加載的。

(2)通過數組定義來引用類,不會觸發此類的初始化

public class NotInitialization {
    public static void main(String[] args) {
        SuperClass[] sca = new SuperClass[10];       //上面代碼的SuperClass
    }
}

運行後沒有輸出“SuperClass init!”,說明並沒有觸發類org.fenixsoft.classloading.SuperClass的初始化階段。不過這段代碼觸發了一個類的初始化:Lorg.fenixsoft.classloading.SuperClass。它是一個由虛擬機自動生成的,創建動作由字節碼指令newarray觸發。這個類代表了一個元素類型爲org.fenixsoft.classloading.SuperClass的一維數組,數組中應有的屬性 和方法(用戶可直接使用的只有被修飾爲public的length屬性和clone()方法)都實現在這個類裏。

Java語 言中對數組的訪問要比C/C++相對安全,很大程度上就是因爲這個類包裝了數組元素的訪問,而C/C++中則是直接對數組指針的移動。在Java語言裏,當檢查到發生數組越界時會拋出 java.lang.ArrayIndexOutOfBoundsException異常,避免了直接造成非法內存訪問。

(3)常量在編譯階段會存入調用類的常量池中,本質上沒有直接引用到定義常量的類,因此不會觸發定義常量的類初始化。

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);
    }
}

上面的代碼沒有輸出。雖然代碼中確實引用了ConstClass類的常量HELLOWORLD,但其實在編譯階段通過常量傳播優化,已經將此常量的值“hello world”直接存儲在NotInitialization類的常量池中,以後NotInitialization對常量 ConstClass.HELLOWORLD的引用,實際都被轉化爲NotInitialization類對自身常量池的引用了。

 

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

接口也有初始化過程,上面的代碼都是用靜態語句塊“static{}”來輸出初始化信息的,而接口中不能使 用“static{}”語句塊,但編譯器仍然會爲接口生成““<clinit>” ”類構造器,用於初始化接口中所定義的 成員變量。

 


類加載的過程

加載

在該階段,Java虛擬機需要做:

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

第一步中的二進制字節流並不是必須從某個Class文件中獲取,它可以從以下方式獲取:

  • 從 ZIP 包讀取,成爲 JAR、EAR、WAR 格式的基礎。
  • 從網絡中獲取,最典型的應用是 Applet。
  • 運行時計算生成,例如動態代理技術,在 java.lang.reflect.Proxy 使用 ProxyGenerator.generateProxyClass 的代理類的二進制字節流。
  • 由其他文件生成,例如由 JSP 文件生成對應的 Class 類。

對於數組類而言,它本身並不由類加載器創建,而是由Java虛擬機直接在內存中動態構造出來的。不過數組類的元素類型(是數組去掉所有維度的類型)最終還是要靠類加載器來完成加載。

 

驗證

Class文件並不一定只能由Java源碼編譯而來,它可以使用包括靠鍵盤0和1直接在二進制編輯器中敲出 Class文件在內的任何途徑產生。Java虛擬機如果不檢查輸入的字節流,對其完全信任的話,很可能會因爲 載入了有錯誤或有惡意企圖的字節碼流而導致整個系統受攻擊甚至崩潰,所以驗證字節碼是Java虛擬 機保護自身的一項必要措施。

 

準備

此階段正式爲類中定義的變量(即靜態變量,被static修飾的變量)分配內存並設置類變量初始值,這些變量所有的內存都應在方法區中分配。

此階段不會對實例變量進行內存分配,實例變量是在對象實例化時隨着對象一起分配在Java堆中。

此處說的初始值“通常情況下”是數據類型的零值,假設一個類的變量的定義如下,則value在準備階段後的初始值是0而不是123,因爲此時尚未執行任何Java方法。把 value賦值爲123的putstatic指令是程序被編譯後,存放於類構造器()方法之中,所以把value賦值 爲123的動作要到類的初始化階段纔會被執行。

public static int value = 123;

 

解析

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

符號引用:一組描述引用目標的符號

直接引用:直接指向目標的指針、相對偏移量或者是一個能 間接定位到目標的句柄。若有了直接引用,那引用的目標必定已經在虛擬機 的內存中存在。在程序運行時,只有符號引用是不夠的

類或接口的解析

假設當前代碼所處的類爲D,若要把一個從未解析過的符號引用N解析爲一個類或接口C的直接引用,則需要:

(1)若C不是一個數組類型,則虛擬機把代表N的全限定名傳遞給D的類加載器去加載類C。在加載過程中,由於類數據驗證,字節碼驗證的需要,可能觸發其他相關了類的加載動作,例如加載這個類的父類或其實現的接口。如果加載過程出現任何異常,解析過程失敗。

(2)若C是一個數組類型,且數組的元素爲對象,則按上一點的規則加載數組元素類型。如果N的描述符如前面假設的形式,需要加載的元素類型類似爲java.lang.Intege,則會由虛擬機生成一個代表該數組維度和元素的數組對象。

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

字段解析

要解析一個未被解析過的字段符號引用,首先會對字段表內的class_index項中索引的 CONSTANT_Class_info符號引用進行解析,也就是字段所屬的類或接口的符號引用。如果解析成功,此處我們把該字段所屬的類或接口用C表示。接下來的步驟:

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

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

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

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

方法解析

先解析出方法表的class_index項中索引的方法所屬的類或接口的符號引用,如果解析成功,此處用C表示這個類,虛擬機進行如下步驟:

(1)如果在類的 方法表中發現class_index中索引的C是個接口的話,那就直接拋出java.lang.IncompatibleClassChangeError 異常。

(2)如通過第一步,在類C中查找是否有簡單名稱和描述符都與目標相匹配的方法,如果有則 返回這個方法的直接引用,查找結束。

(3)否則,在類C的父類中遞歸查找是否有簡單名稱和描述符都與目標相匹配的方法,如果有則返 回這個方法的直接引用,查找結束。

(4)否則,在類C實現的接口列表及它們的父接口之中遞歸查找是否有簡單名稱和描述符都與目標 相匹配的方法,如果存在匹配的方法,說明類C是一個抽象類,此時查找結束,拋出 java.lang.AbstractMethodError異常。

(5)否則,宣告方法查找失敗,拋出java.lang.NoSuchMethodError。

接口方法解析

首先解析出接口方法表的class_index 項中索引的方法所屬的類或接口的符號引 用,如果解析成功,此處用C表示這個接口,接下來虛擬機執行如下步驟:

(1)如果在接口方法表中發現class_index中的索引C是個類而不是接口,則直接拋出java.lang.IncompatibleClassChangeError異常。

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

(3)否則,在接口C的父接口中遞歸查找,直到java.lang.Object類爲止,查看是否有簡單名稱和描述符都與目標相匹配的方法,若有則返回這個方法的直接引用,查找結束。對於此步驟的查找,如果C的不同父接口中存有多個簡單名稱和描述符 都與目標相匹配的方法,那將會從這多個方法中返回其中一個並結束查找。

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

 

初始化

在該階段,Java虛擬機開始真正執行類中編寫的Java代碼。進行準備階段時,變量已經賦過一次系統要求的初始零值,而在初始化階段,則會根據類中的代碼去初始化類變量和其他資源。初始化階段就是執行類構造器<clint>()方法的過程。

<clint>()方法是編譯器自動收集類中的所有變量的賦值動作和靜態語句塊(static{}塊)中的 語句合併產生的,收集的順序由語句在源文件中出現的順序決定。

靜態語句塊中只能訪問 到定義在靜態語句塊之前的變量。對於定義在它之後的變量,在前面的靜態語句塊可以賦值,但是不能訪 問

<clint>()方法與類的構造方法(虛擬機視角中的實例構造器()方法 )不同,他需要顯示的調用父類構造器,Java虛擬機會保證在子類的()方法執行前,父類的()方法已經執行完畢。

父類的<clint>()方法先執行,意味着父類中定義的靜態語句塊要優先於子類的變量賦值。下面的代碼中,字段B的值是2而非1

static class Parent {
    public static int A = 1;
    static {
        A = 2;
    }
}
static class Sub extends Parent {
    public static int B = A;
}
public static void main(String[] args) {
     System.out.println(Sub.B);
}

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

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

(3)Java虛擬機保證一個類的()方法在多線程環境中被正確地加鎖同步,如果多個線程同時去初始化一個類,那麼只會有其中一個線程去執行這個類的()方法,其他線程都需要阻塞等待,直到活動線程執行完。

 

 

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