Java虛擬機(六) -類加載機制-加載器-雙親委派

一、概述

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

生命週期的五個階段(加載、驗證、準備、初始化、卸載)是按順序開始的(不保證按順序進行或完成,因爲這些階段通常是互相交叉地混合式進行的)。

解析階段可能在初始化之前也可能在初始化之後開始,因爲Java支持運行時綁定。

虛擬機規範規定了,有且只有5種情況必須立即對類進行“初始化”(加載、驗證、準備會在初始化之前完成):

  1. 遇到new、getstatic、putstatic、invokestatic這4條字節碼指令。指令分別對應,new關鍵字,獲取靜態變量的值,設置一個靜態變量(被final修飾、已在編譯器把結果放入常量池的靜態字段除外),調用一個類的靜態方法。
  2. 使用java.lang.reflect包的方法對類進行反射調用,並這個類沒有進行過初始化
  3. 初始化一個類的時候,父類沒有被初始化過,需要先初始化父類。
  4. 虛擬機啓動,用戶指定要執行的主類(main()方法的類),虛擬機會先初始化主類。
  5. 如果java.lang.invoke.MethodHandle實例最後的解析結果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,並且這個方法句柄所對應的類沒有進行過初始化,需先觸發其初始化。

注意:第三點,初始化類的時候,父類沒有被初始化,需要先初始化父類,但如果是接口,並不會要求父接口全部都完成初始化,只有在用到父接口的時候(例如,引用接口中定義的常量),纔會初始化。

  • 主動引用,以上五種行爲稱爲對一個類進行主動引用。
  • 被動引用,除此之外,所有引用類的方法不會觸發初始化,稱爲被動引用。

被動引用的例子:

public class ParentClass {
    static {
        System.out.println("parent init!");
    }
    public static int value = 123;
    public static final String HELLO_WORLD = "hello world";
}

public class ChildClass extends ParentClass {
    static {
        System.out.println("child init!")
    }
}

public class Test {
    public static void main(String[] args) {
        System.out.println(ChildClass.value);  // 第一句
        ParentClass[] temp = new ParentClass[10]; // 第二句
        System.out.println(ParentClass.HELLO_WORLD); // 第三句
    }
}
// 打印結果(三句話分別執行)
// parent init!  (第一句打印結果)
// 第二句沒有打印
// 第三句沒有打印

結論:
(1)對於靜態字段,只有直接定義這個字段的類纔會被初始化,通過子類引用父類定義的靜態字段,只會觸發父類的初始化,而不會觸發子類的初始化。
(2)定義一個類的數組,不會觸發該類的初始化。
(3)在編譯器,hello world會被存儲到Test類的常量池,Test對常量ParentClass.HELLO_WORLD的引用會轉化爲Test類對自身常量池的引用。

二、類加載過程

生命週期的前五個階段,是一個類的加載過程(加載(Loading)、驗證(Verification)、準備(Preparation)、解析(Resolution)、初始化(Initialization))

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

  2. 驗證
    對於虛擬機的類加載機制來說,驗證是非常重要的階段,但他不是一個必要的階段。對程序運行期沒有影響。如果所運行的全部代碼(包括自己編寫的及第三方包中的代碼)都已經被反覆的使用和驗證過,那麼實施階段,可以考慮使用-Xverify:none參數來關閉大部分的類驗證措施,以縮短虛擬機類加載的時間。
    .
    (1). 文件格式驗證
    這階段的驗證主要保證輸入的字節流能正確地解析並存儲到方法區內,格式商符合描述一個Java類型信息的要求。該階段是基於二進制字節流進行的,只有通過這個階段的驗證後,字節流纔會進入內存的方法區中進行存儲。
    **驗證點:**是否以魔數0xCAFEBABE開頭、主次版本號是否在當前虛擬機處理範圍之內、指向常量的各種索引值是否指向不存在的常量等等.
    .
    (2). 元數據驗證
    這個階段是對字節碼描述的信息進行語義分析,以保證其描述的信息符合Java語言規範的要求。
    驗證點:這個類是否有父類(除了Object,所有的類都應該有父類)、這個類的父類是否繼承了不允許被繼承的類(final修飾)等等。
    .
    (3). 字節碼驗證
    最複雜的驗證階段,主要目的是通過數據流和控制流的分析,確認程序語義是合法的,符合邏輯的。這個階段會對類的方法體進行校驗分析,保證校驗類的方法在運行時不會做出危害虛擬機安全的事件。
    .
    (4). 符號引用驗證
    這個階段的校驗發生在虛擬機將符號引用轉化爲直接引用的時候,這個轉化動作將在連接的第三個階段–解析階段中發生。符號引用驗證可以看做是對類自身以外(常量池中的各種符號引用)的信息進行匹配性校驗。
    驗證點:符號引用中通過字符串描述的全限定名是否能找到對應的類等等。

  3. 準備
    準備階段是正式爲類變量分配內存並設置類變量初始值的階段,這些變量所使用的內存都在方法區中進行分配。(這裏分配的初始值的變量是類變量,static修飾,並且,賦予的初始值是零值。例如: public static int value = 123; 這個階段,給value分配的值是0,也就是int的默認值。把value賦值123的putstatic指令是程序被編譯後,存放於類構造器()方法之中,把123賦給value,是在初始化階段執行的。)
    上面說的賦值有個例外,假設類字段的字段屬性表中存在ConstantValue屬性,準備階段的value就會被初始化爲ConstantValue屬性所指定的值。例如:public static final int value = 123;,這個value就會在準備階段被賦予123。

  4. 解析
    解析階段是虛擬機將常量池內的符號引用替換爲直接引用的過程。
    虛擬機規範要求在anewarray、checkcast、getfield、getstatic、instanceof、invokedynamic、invokeinterface、invokespecial、invokestatic、invokevirtual、ldc、ldc_w、multianewarray、new、putfield和putstatic這16個指令之前執行解析。

  5. 初始化
    初始化階段是執行類構造器<clinit>()方法的過程。
    <clinit>()方法是由編譯器自動收集類中的所有類變量的賦值動作和靜態語句塊中的語句合併產生的。
    (1). 靜態語句塊中只能訪問到定義靜態語句塊之前的變量,定義在它之後的變量,靜態語句塊可以對其賦值,但不能訪問。
    static {
    int i = 0; // 變量賦值可以正常編譯通過
    System.out.print(i); // 這句編譯器會提示“非法向前引用”
    }
    static int i = 1;
    (2). 虛擬機會保證在子類的<clinit>()執行前,父類的<clinit>()的方法已經執行完畢。父類的靜態代碼塊會優先於子類的變量賦值操作。
    (3). 如果一個類沒有賦值操作,也沒有靜態代碼塊,編譯器可以不爲這個類生成<clinit>()方法。
    (4). 接口不會有靜態代碼塊,但有賦值操作,所以也有<clinit>()方法。接口的<clinit>()方法執行不需要先執行父接口的<clinit>()方法。只有父接口定義的變量使用時,父接口才會初始化。
    (5). 虛擬機會保證一個類的<clinit>()方法在多線程環境中被正確地加鎖、同步。如果有多個線程同時初始化一個類,只會有一個線程執行<clinit>()方法,其他線程阻塞,直到活動線程執行<clinit>()完畢。

三、類加載器

類加載(Class Loading)過程中,加載這個階段中的“通過類的全限定名來獲取定義此類的二進制流文件”這個動作是放到Java虛擬機外部去實現的。實現這個動作的代碼模塊稱爲“類加載器”。

  1. 比較兩個類相等,只有在同一個類加載器下,纔有意義。如果兩個類被不同加載器加載,即使它們來源於同一個Class,也不相等。
分類

對於jvm來說,有兩種類加載器:

  1. 啓動類加載器
    由C++實現(針對HotSpot虛擬機來說),是虛擬機的一部分。
    負責加載<JAVA_HOME>\lin目錄中的,或者被-Xbootclasspath參數所指定的路徑中的,並且是虛擬機識別的類庫加載到虛擬機內存中。
    無法被Java程序直接引用。
  2. 其他類加載器
    由Java語言實現,獨立於虛擬機。

對於Java開發人員來說,有3種系統提供的加載器

  1. 啓動類加載器
  2. 擴展類加載器
    由sun.misc.Launcher$ExtClassLoader實現
    負責加載<JAVA_HOME>\lib\ext目錄中的,或者被java.ext.dirs系統變量所指定的路徑中的所有類庫。
    開發者可以直接使用擴展類加載器
  3. 應用程序類加載器(系統類加載器),程序默認的加載器
    由sun.misc.Launcher$AppClassLoader實現。
    負責加載用戶類路徑(ClassPath)上所指定的類庫。
    開發者可以直接使用

應用程序一般都是由這3種類加載器互相配合進行加載,如果有必要,可以加入自己定義的類加載器。

雙親委派模型
  1. 概念:
    雙親委派模型要求除了頂層的啓動類加載器,其餘類加載器都應當有自己的父類加載器(以組合的形式複用父類加載器,而不是繼承)
  2. 工作過程
    類加載器收到類加載請求,它會先把這個請求委派給父類加載器去完成,每個層次的類加載器都是如此。所有加載請求都會傳送到頂層的啓動類加載器中。當父類加載器反饋自己無法完成這個加載請求,子類加載器會嘗試自己去加載。

在java.lang.ClassLoader的loadClass()方法中,可以看到雙親委派模型的實現

protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
        synchronized (getClassLoadingLock(name)) {
            // First, check if the class has already been loaded
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                long t0 = System.nanoTime();
                try {
                    if (parent != null) {
                        c = parent.loadClass(name, false); // 父類加載器加載
                    } else {
                        c = findBootstrapClassOrNull(name); // 啓動類加載器加載
                    }
                } catch (ClassNotFoundException e) {
                    // ClassNotFoundException thrown if class not found
                    // from the non-null parent class loader
                }

                if (c == null) {
                    // If still not found, then invoke findClass in order
                    // to find the class.
                    long t1 = System.nanoTime();
                    c = findClass(name); // 自己加載

                    // this is the defining class loader; record the stats
                    sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                    sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                    sun.misc.PerfCounter.getFindClasses().increment();
                }
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }

注意:雙親委派模型不是一個強制性的約束模型,而是Java設計者推薦給開發者的類加載器實現方式。

四、名詞解釋

  • 全限定名, 例如類java.lang.String的全限定名爲 java/lang/String
  • 魔數0xCAFEBABE,每個Class文件的頭4個字節稱爲魔數(Magic Number),它唯一作用是確定這個文件是否爲一個能被虛擬機接受的Class文件。0xCAFEBABE是固定值。
  • 常量池中主要存放兩大類常量:字面量和符號引用。字面量是可以理解成Java的常量,如文本字符串等。符號引用主要包括三類常量:1, 類和接口的全限定名;2,字段的名稱和描述符;3,方法的名稱和描述符;
  • 符號引用,符號引用以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可。符號引用的字面量形式明確定義在Java虛擬機規範的Class文件格式中。
  • 直接引用,直接引用是直接指向目標的指針、相對偏移量或是一個能間接定位到目標的句柄。同一個符號應用在不同虛擬機實例上翻譯出來的直接引用一般不會相同。如果有了直接引用,引用的目標必定存在內存中。
  • 字段表 class文件結構的一部分,用於描述接口或類中聲明的變量。
  • 屬性表 在Class文件、字段表、方法表都可以攜帶自己的屬性表結合,用於描述某些場景的專用信息。
  • ConstantValue ConstantValue是虛擬機規範預定義的屬性,存在於字段表。表示final關鍵字定義的常量值。

[1] 周志明 · 深入理解Java虛擬機 :機械工業出版社

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