JVM 類載入機制及初始化時機分析

語言: CN / TW / HK

類載入機制簡述

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

當我們在編譯器中選擇執行下面這個Hello World程式時,從點選執行到程式關閉這個類會經過一系列複雜的過程,這些關於該類的過程就是類的生命週期。

public class HelloWorld {
    public static void main(String[] args){
        System.out.println("Hello World"); 
    }
}
複製程式碼

類的生命週期

類的生命週期分為載入、驗證、準備、解析、初始化、使用、解除安裝七個階段。其中驗證、準備、解析三個階段被統稱為連線。

載入

  • 在載入階段,虛擬機器會通過類的全限定名查詢並載入類的二進位制位元組流到方法區中。
  • 我們可以通過在啟動時新增 JVM 引數——-XX:+TraceClassLoading,開啟列印類的載入順序功能。

驗證

  • 在驗證階段,虛擬機器需要確保被載入類的正確性,符合虛擬機器規範,以及不會危害虛擬機器自身的安全。
  • 在此階段中又有四個階段的驗證:檔案格式驗證、元資料驗證、位元組碼驗證和符號引用驗證。具體可參考《深入理解Java虛擬機器(第三版)》7.3.2 驗證。

準備

  • 在準備階段,虛擬機器會為類的靜態變數分配記憶體,並將其初始化為預設值。
  • 如果類欄位的欄位屬性表中存在ConstantValue屬性的基本型別或字串(即被final修飾),那在準備階段變數值就會被初始化為初始值而非預設值。
  • 假設上文的HelloWorld類中有兩個類變數定義如下,那麼在準備階段這兩個變數的值分別是 a=0,b=2。
    public class HelloWorld {
        private static int a = 1;
        private static final int b = 2;
    }
        ````
      
    複製程式碼

解析

  • 在解析階段,虛擬機器會將常量池內的符號引用替換為直接引用。如果符號引用指向一個未被載入的類,那麼解析階段將觸發此類的載入。

初始化

  • 在初始化階段,虛擬機器會為類的靜態變數賦予正確的初始值,這些賦值操作以及靜態程式碼塊中的程式碼會被編譯器統一置於一個<Clinit>方法中,這個方法僅會被執行一次。所以我們可以根據類的靜態程式碼塊是否執行來判斷一個類是否進行了初始化。

主動引用示例

在 Java 虛擬機器規範中規定了多種觸發初始化的情況,被稱為對類的主動引用:

  1. 虛擬機器啟動時被標為啟動類的類(包含 main() 方法的類)
  2. 在類的位元組碼中遇到new、getstatic、putstatic、invokestatic這四條指令時,會觸發類的初始化。
    • 這四條位元組碼指令分別對應建立類的例項、訪問一個類的靜態欄位、設定一個類的靜態欄位、呼叫一個類的靜態方法。
    • 這裡的靜態欄位不包括在編譯期就能確定的靜態常量,因為靜態常量會存放到呼叫方的常量池中,本質上沒有直接引用到定義常量的類,因此不會觸發定義常量的類的初始化。
  3. 當初始化類的時候,如果父類還沒有進行過初始化,則需要先觸發其父類的初始化。
  4. 使用反射 API 對類進行反射呼叫時,會初始化這個類。
  5. 當初次呼叫 MethodHandle 例項時,初始化該 MethodHandle 指向的方法所在的類。
  6. 當一個介面中定義了 default 預設方法時,如果有這個介面的實現類發生了初始化,那該介面要在其之前被初始化。

被動引用示例

對於上述的觸發初始化的主動引用情況有一些例外的情況:

  1. 對於靜態欄位,只有直接定義這個欄位的類才會被初始化,因此通過其子類來引用父類中定義的靜態欄位,只會觸發父類的初始化而不會觸發子類的初始化。

  2. 通過陣列定義來引用類,不會觸發此類的初始化。因為陣列由Java虛擬機器直接生成的,通過下面的例子來說明這一情況。

    • HelloWorld類中定義一個主方法,並在主方法中定義兩個陣列變數如下:
        public class HelloWorld {
            public static void main(String[] args){
                int[] a = new int[10];
                MyObject[] b = new MyObject[10];
            }
        }
        
        class MyObject {
            static {
                System.out.println("MyObject 類初始化...");
            }
        }
    複製程式碼
    • 然後對位元組碼檔案進行反編譯,在命令列中輸入javap -c HelloWorld,得到反編譯的位元組碼指令如下:
        ➜  classes git:(master) ✗ javap -c HelloWorld 
        Compiled from "HelloWorld.java"
        public class HelloWorld {
        public HelloWorld();
            Code:
                0: aload_0
                1: invokespecial #1                  // Method java/lang/Object."<init>":()V
                4: return
        
        public static void main(java.lang.String[]);
            Code:
                0: bipush        10
                2: newarray       int
                4: astore_1
                5: bipush        10
                7: anewarray     #2                  // class MyObject
            10: astore_2
            11: return
        }
    複製程式碼
    • 可以看到,對於原始型別的陣列變數,在位元組碼中通過指令newarray完成建立;對於引用型別的陣列變數,在位元組碼中通過指令anewarray完成建立。
    • 而對於引用型別的MyObject類,anewarray這條指令與上文中敘述的幾種主動引用情況不符合,不滿足初始化的條件,這與上述測試程式碼中沒有執行MyObject類的靜態程式碼塊的情況相符,即沒有觸發MyObject類的初始化。
  3. 當一個常量欄位的值在編譯器不確定時(如UUID.random().toString()),那麼它不會被放到呼叫類的常量池中。因此即便這個靜態欄位是一個常量(被final關鍵字修飾),但由於它在編譯期是不確定的,所以在程式執行時還是會主動使用這個常量所在的類,從而觸發常量所在類的初始化。

  4. 一個介面在初始化時,並不要求其父介面全部都完成了初始化,只有在真正使用到父介面的時候(如引用介面中執行期才確定的常量)才會初始化。

    • 由於介面是無法定義靜態程式碼塊的,所以無法像類那樣通過類靜態程式碼塊的執行與否判斷是否發生初始化。但是在介面的初始化過程中,編譯器同樣會為介面生成<clinit>()構造器,用於初始化介面中所定義的成員變數。
    • 在初始化階段,虛擬機器會為類的靜態變數賦予正確的初始值,當然介面類也會遵從這條規定。所以我們可以通過初始化階段對靜態欄位的賦值來觀察介面類是否進行了初始化,下面是驗證的過程。
    • 在父類介面中定義一個int parentA = 1/0;,然後通過子類訪問父類的parentRand常量,根據上述第三條的結論,編譯期無法確定的常量parentRand不會被放入呼叫類InterfaceDemo的常量池,那麼必然會觸發子介面或父介面其中至少一個介面類的初始化(假設我們不知道結論),如果觸發父介面的初始化,那麼會將1/0的值賦值給parentA,當虛擬機器計算1/0時,會丟擲java.lang.ArithmeticException: / by zero異常;如果只觸發子介面的初始化,則不會丟擲異常。
        public class InterfaceDemo {
        
            public static void main(String[] args) {
                System.out.println(ChildInterface.parentRand);
            }
        }
        
        interface ParentInterface {
            int parentA = 1/0;
            String parentRand = UUID.randomUUID().toString();
        }
        
        interface ChildInterface extends ParentInterface {
            String childRand = UUID.randomUUID().toString();
        }   
    複製程式碼
    • 執行InterfaceDemo類,輸出如下,其中InterfaceDemo.java:15第15行正是parentA定義的位置,從而可以得出結論:在子介面使用到父介面時會觸發父介面的初始化。
    Exception in thread "main" java.lang.ExceptionInInitializerError
        at InterfaceDemo.main(InterfaceDemo.java:10)
    Caused by: java.lang.ArithmeticException: / by zero
        at ParentInterface.<clinit>(InterfaceDemo.java:15)
        ... 1 more
    複製程式碼
    • 然後將main函式中的輸出改為ChildInterface.childRand,執行的結果時輸出了一個UUID,沒有丟擲除零異常,從而得出結論:若子介面不使用父介面,不會觸發父介面的初始化。
    • 綜上所述,驗證了第4條結論——一個介面在初始化時,並不要求其父介面全部都完成了初始化,只有在真正使用到父介面的時候(如引用介面中執行期才確定的常量)才會初始化。