深入理解JVM(③)虛擬機器的類載入時機

紀莫發表於2020-06-24

前言

Java虛擬機器把描述類的資料從Class檔案載入到記憶體,並對資料進行校驗、轉換解析和初始化,最終形成可以被虛擬機器直接使用的Java型別,這個過程被稱為虛擬機器的類載入機制。

類載入的時機

一個型別從被載入到虛擬機器記憶體中開始,到解除安裝除記憶體為止,它的生命週期將會經歷載入(Loading)驗證(Verification)準備(Preparation)解析(Resolution)初始化(Initialization)使用(Using)和 解除安裝(Unloading)、七個階段,其中驗證、準備、解析三個部分統稱為連線(Linking)
類的生命週期如下圖:
類的生命週期
其實載入、驗證、準備、初始化和解除安裝這五個階段的順序是確定的,型別的載入過程必須按照這種順序按部就班地開始,而解析階段則不一定:它在某些情況下可以在初始化階段之後再開始,這是為了支援Java語音的執行時繫結特性(也稱為動態繫結或晚期繫結)
在什麼情況下需要開始類載入過程的第一個階段“載入”,《Java虛擬機器規則》中並沒有進行強制約束,但是對於初始化階段《Java虛擬機器規範》則是嚴格規定了有且只有以下六種情況必須立即對類進行“初始化”。

  1. 遇到newgetstaticputstaticinvokestatic這四條位元組碼指令時,如果型別沒有進行過初始化,則需要先觸發其初始化階段
    涉及到這四條指令的典型場景有:
  • 使用new關鍵字例項化對的時候。
  • 讀取或設定一個型別的靜態欄位(被final修飾、已在編譯期把結果放入常量池的靜態欄位除外)的時候。
  • 呼叫一個型別的靜態方法的時候。
  1. 使用 java.lang.reflect 包的方法對型別進行反射呼叫的時候,如果型別沒有進行過初始化,則需要先觸發其初始化。
  2. 當初始化型別的時候,如果發現其父類還沒有進行過初始化,則需要先觸發其父類的初始化。
  3. 當虛擬機器啟動時,使用者需要指定一個要執行的主類(包含main()方法的那個類),虛擬機器會先初始化這個主類。
  4. 當使用JDK7新加入的動態語言支援時,如果一個java.lang.invoke.MethodHandle例項最後的解析結果為REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四種型別的方法控制程式碼,並且這個方法控制程式碼對應的類沒有進行過初始化,則需要先觸發其初始化。
  5. 當一個介面中定義了JDK8新加入的預設方法(被default關鍵字修飾的介面方法)時,如果這個介面的實現類發生了初始化,那該介面要在其之前被初始化。
    除了以上的這個六種場景外,所有引用型別的方式都不會觸發初始化,稱為被動引用。
    下面來看一下哪些是被動引用:

例子?1:

父類

package com.jimoer.classloading;

/**
 * @author jimoer
 * @date Create in 2020/06/24 16:08
 * @description 通過子類引用父類的靜態欄位,不會導致子類初始化。
 */
public class FatherClass {

    static {
        System.out.println("FatherClass init!!!!!");
    }

    public static int value = 666;

}

子類

package com.jimoer.classloading;

public class SonClass extends FatherClass{

    static {
        System.out.println("SonClass init!!!");
    }

}

測試類

@Test
public void testInitClass(){
    System.out.println(SonClass.value);
}

執行結果:

FatherClass init!!!!!
666

通過執行結果我們看到,只輸出了“FatherClass init!!!!!”,並沒有輸出“SubClass init!!!”,這是因為對於使用靜態欄位,只有直接定義這個欄位的類才會被初始化,因此通過子類來引用父類中定義的靜態欄位,並不會初始化子類。

例子?2:

/**
 * 通過陣列定義來引用類,不會觸發此類的初始化
 */
@Test
public void testInitClass2(){
    FatherClass[] fathers = new FatherClass[5];
}

執行結果:未列印任何資訊。
通過執行結果我們發現,並沒有列印出 FatherClass init!!!!! ,這說明並沒有觸發Father類的初始化階段。但是這段程式碼裡面觸發了另一個名為“[Lcom.jimoer.classloading.FatherClass”的類的初始化階段,它是一個由虛擬機器自動生成的、直接繼承與java.lang.Object的子類,建立動作由位元組碼newarray觸發。這個類代表了一個元素型別為FatherClass的一維陣列,陣列中應用的屬性和方法(length屬性和clone()方法)都實現在這個類裡。

例子?3:

/**
 * @author jimoer
 * 常量在編譯階段會存入呼叫類的常量池中,
 * 本質上沒有直接引用到定義常量的類,
 * 因此不會觸發定義常量的類的初始化。
 */
public class ConstantClass {
    
    static {
        System.out.println("ConstantClass init !!!");
    }
    
    public static final String CLASS_LOAD = "class load test !!!";
    
}

使用

/**
 * 使用常量
 */
@Test
public void testInitClass3(){
    System.out.println(ConstantClass.CLASS_LOAD);
}

執行結果:

class load test !!!

通過執行結果,我們看到當在使用一個類的常量時,並不會初始化定義了常量的類。這是因為雖然在Java原始碼中確實引用了ConstatClass的類的常量CLASS_LOAD,但其實在編譯階段通過常量傳播優化,已經將此常量的值“class load test !!!”直接儲存在使用常量的類中的常量池中了,所以在使用ConstantClass.CLASS_LOAD時候,實際上都被轉化為在使用類自身的常量池的引用了。

介面也是有初始化過程的,上面的程式碼都是用靜態語句塊“static {}”來輸出初始化資訊的,而介面中不能使用static{}語句塊,但編譯器仍然會為介面生成“()”類構造器,用於初始化介面中所定義的成員變數。
還有一點介面與類不同,當一個類在初始化時,要求其父類全部都已經初始化過了,但是在一個介面初始化時,並不要求其父介面全部都完成了初始化,只有在真正使用到父介面的時候(例如引用介面中的常量)才會初始化。

相關文章