「JVM」類加載機制及初始化時機分析

學習 JVM 的第 n-2 天,瞭解了類加載機制,以及初始化主動引用及被動引用的各類狀況,在此記錄分享。

1. 類加載機制簡述

Java虛擬機把描述類的數據從Class文件加載到內存,並對數據進行校驗、轉換解析和初始化,最終造成能夠被虛擬機直接使用的Java類型,這個過程被稱做虛擬機的類加載機制。java

上面這段話是在周志明大佬的《深刻理解Java虛擬機》中的,做爲類加載機制的概念在此摘錄。

當咱們在編譯器中選擇運行下面這個Hello World程序,從點擊運行到程序中止運行會通過一系列複雜的過程,這些關於該類的過程就是類的生命週期。git

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

2. 類的生命週期

類的生命週期分爲加載、驗證、準備、解析、初始化、使用、卸載七個階段。其中驗證、準備、解析三個階段被統稱爲鏈接。數組

c86216ea9c74f47b3cff44127fb062d8bcc.jpg.png

下面,就針對各個階段作一個簡單的介紹。安全

2.1 加載

在加載階段,虛擬機會經過類的全限定名查找並加載類的二進制字節流到方法區中。dom

咱們能夠經過在啓動時添加 JVM 參數-XX:+TraceClassLoading,打開打印類的加載順序功能。函數

2.2 驗證

在驗證階段,虛擬機須要確保被加載類的正確性,符合虛擬機規範,以及不會危害虛擬機自身的安全。學習

在此階段中又有四個階段的驗證:文件格式驗證、元數據驗證、字節碼驗證和符號引用驗證。具體可參考《深刻理解Java虛擬機(第三版)》7.3.2節。測試

2.3 準備

在準備階段,虛擬機會爲類的靜態變量分配內存,並將其初始化爲默認值。spa

若是類字段的字段屬性表中存在ConstantValue屬性的基本類型或字符串(即被final修飾),那在準備階段變量值就會被初始化爲初始值而非默認值。命令行

假設上文的HelloWorld類中有兩個類變量定義以下,那麼在準備階段這兩個變量的值分別是 a=0,b=2。

public class HelloWorld {
    private static int a = 1;
    private static final int b = 2;
}

2.4 解析

在解析階段,虛擬機會將常量池內的符號引用替換爲直接引用。若是符號引用指向一個未被加載的類,那麼解析階段將觸發此類的加載。

2.5 初始化

在初始化階段,虛擬機會爲類的靜態變量賦予正確的初始值,這些賦值操做以及靜態代碼塊中的代碼會被編譯器統一置於一個<Clinit>方法中,這個方法僅會被執行一次。因此咱們能夠根據類的靜態代碼塊是否執行來判斷一個類是否進行了初始化。

3. 主動引用

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

  1. 虛擬機啓動時被標爲啓動類的類(包含 main() 方法的類)
  2. 在類的字節碼中遇到new、getstatic、putstatic、invokestatic這四條指令時,會觸發類的初始化。

    • 這四條字節碼指令分別對應建立類的實例、訪問一個類的靜態字段、設置一個類的靜態字段、調用一個類的靜態方法。
    • 這裏的靜態字段不包括在編譯期就能肯定的靜態常量,由於靜態常量會存放到調用方的常量池中,本質上沒有直接引用到定義常量的類,所以不會觸發定義常量的類的初始化。
  3. 當初始化類的時候,若是父類尚未進行過初始化,則須要先觸發其父類的初始化。
  4. 使用反射 API 對類進行反射調用時,會初始化這個類。
  5. 當初次調用 MethodHandle 實例時,初始化該 MethodHandle 指向的方法所在的類。
  6. 當一個接口中定義了 default 默認方法時,若是有這個接口的實現類發生了初始化,那該接口要在其以前被初始化。

4. 被動引用

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

4.1 靜態字段

對於靜態字段,只有直接定義這個字段的類纔會被初始化,所以經過其子類來引用父類中定義的靜態字段,只會觸發父類的初始化而不會觸發子類的初始化。

4.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類的初始化。

4.3 常量字段編譯期不肯定

當一個常量字段的值在編譯期不肯定時(如UUID.random().toString()),那麼它不會被放到調用類的常量池中。所以即使這個靜態字段是一個常量(被final關鍵字修飾),但因爲它在編譯期是不肯定的,因此在程序運行時仍是會主動使用這個常量所在的類,從而觸發常量所在類的初始化。

4.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條結論——一個接口在初始化時,並不要求其父接口所有都完成了初始化,只有在真正使用到父接口的時候(如引用接口中運行期才肯定的常量)纔會初始化。

總結

  1. Java虛擬機把描述類的數據從Class文件加載到內存,並對數據進行校驗、轉換解析和初始化,最終造成能夠被虛擬機直接使用的Java類型,這個過程被稱做虛擬機的類加載機制。
  2. 類加載過程分爲加載、驗證、準備、解析、初始化,其中驗證、準備、解析三個階段被統稱爲鏈接。以及各個階段虛擬機作的工做。
  3. 六種主動引用的狀況。
  4. 四種主動引用中被動引用的狀況及示例。
相關文章
相關標籤/搜索