JVM 的類初始化機制

jiacai2050發表於2017-01-15

當你在 Java 程式中new物件時,有沒有考慮過 JVM 是如何把靜態的位元組碼(byte code)轉化為執行時物件的呢,這個問題看似簡單,但清楚的同學相信也不會太多,這篇文章首先介紹 JVM 類初始化的機制,然後給出幾個易出錯的例項來分析,幫助大家更好理解這個知識點。

Loading, Linking, and Initialization

JVM 將位元組碼轉化為執行時物件分為三個階段,分別是:loading 、Linking、initialization。

JVM 的類初始化機制
The beginning of a class's lifetime

下面分別介紹這三個過程:

Loading

Loading 過程主要工作是由ClassLoader完成。該過程具體包括三件事:

  1. 根據類的全名,生成一份二進位制位元組碼來表示該類
  2. 將二進位制的位元組碼解析成方法區對應的資料結構
  3. 最後生成一 Class 物件的例項來表示該類

JVM 的類初始化機制
ClassLoader 繼承關係,不同 CL 負責載入不同類

JVM 中除了最頂層的Boostrap ClassLoader是用 C/C++ 實現外,其餘類載入器均由 Java 實現,我們可以用getClassLoader方法來獲取當前類的類載入器:

public class ClassLoaderDemo {
    public static void main(String[] args) {
        System.out.println(ClassLoaderDemo.class.getClassLoader());
    }
}

# sun.misc.Launcher$AppClassLoader@30a4effe
# AppClassLoader 也就是上圖中的 System Class Loader複製程式碼

此外,我們在啟動java傳入-verbose:class來檢視載入的類有那些。

java -verbose:class ClassLoaderDemo

[Opened /Library/Java/JavaVirtualMachines/jdk1.8.0_112.jdk/Contents/Home/jre/lib/rt.jar]
[Loaded java.lang.Object from /Library/Java/JavaVirtualMachines/jdk1.8.0_112.jdk/Contents/Home/jre/lib/rt.jar]
[Loaded java.io.Serializable from /Library/Java/JavaVirtualMachines/jdk1.8.0_112.jdk/Contents/Home/jre/lib/rt.jar]
[Loaded java.lang.Comparable from /Library/Java/JavaVirtualMachines/jdk1.8.0_112.jdk/Contents/Home/jre/lib/rt.jar]
[Loaded java.lang.CharSequence from /Library/Java/JavaVirtualMachines/jdk1.8.0_112.jdk/Contents/Home/jre/lib/rt.jar]

....
....

[Loaded java.security.BasicPermissionCollection from /Library/Java/JavaVirtualMachines/jdk1.8.0_112.jdk/Contents/Home/jre/lib/rt.jar]
[Loaded ClassLoaderDemo from file:/Users/liujiacai/codes/IdeaProjects/mysql-test/target/classes/]
[Loaded sun.launcher.LauncherHelper$FXHelper from /Library/Java/JavaVirtualMachines/jdk1.8.0_112.jdk/Contents/Home/jre/lib/rt.jar]
[Loaded java.lang.Class$MethodArray from /Library/Java/JavaVirtualMachines/jdk1.8.0_112.jdk/Contents/Home/jre/lib/rt.jar]
[Loaded java.lang.Void from /Library/Java/JavaVirtualMachines/jdk1.8.0_112.jdk/Contents/Home/jre/lib/rt.jar]
sun.misc.Launcher$AppClassLoader@2a139a55
[Loaded java.lang.Shutdown from /Library/Java/JavaVirtualMachines/jdk1.8.0_112.jdk/Contents/Home/jre/lib/rt.jar]
[Loaded java.lang.Shutdown$Lock from /Library/Java/JavaVirtualMachines/jdk1.8.0_112.jdk/Contents/Home/jre/lib/rt.jar]複製程式碼

Linking

Verification

Verification 主要是保證類符合 Java 語法規範,確保不會影響 JVM 的執行。包括但不限於以下事項:

  • bytecode 的完整性(integrity)
  • 檢查final類沒有被繼承,final方法沒有被覆蓋
  • 確保沒有不相容的方法簽名

Preparation

在一個類已經被load並且通過verification後,就進入到preparation階段。在這個階段,JVM 會為類的成員變數分配記憶體空間並且賦予預設初始值,需要注意的是這個階段不會執行任何程式碼,而只是根據變數型別決定初始值。如果不進行預設初始化,分配的空間的值是隨機的,有點型別c語言中的野指標問題。

Type    Initial Value
int    0
long    0L
short    (short) 0
char    '\u0000'
byte    (byte) 0
boolean    false
reference    null
float    0.0f
double    0.0d複製程式碼

在這個階段,JVM 也可能會為有助於提高程式效能的資料結構分配記憶體,常見的一個稱為method table的資料結構,它包含了指向所有類方法(也包括也從父類繼承的方法)的指標,這樣再呼叫父類方法時就不用再去搜尋了。

Resolution

Resolution 階段主要工作是確認類、介面、屬性和方法在類run-time constant pool的位置,並且把這些符號引用(symbolic references)替換為直接引用(direct references)。

locating classes, interfaces, fields, and methods referenced symbolically from a type's constant pool, and replacing those symbolic references with direct references.

這個過程不是必須的,也可以發生在第一次使用某個符號引用時。

JVM 的類初始化機制
JVM_Internal_Architecture

Initialization

經過了上面的loadlink後,第一次 主動呼叫某類的最後一步是Initialization,這個過程會去按照程式碼書寫順序進行初始化,這個階段會去真正執行程式碼,注意包括:程式碼塊(static與static)、建構函式、變數顯式賦值。如果一個類有父類,會先去執行父類的initialization階段,然後在執行自己的。

上面這段話有兩個關鍵詞:第一次主動呼叫第一次是說只在第一次時才會有初始化過程,以後就不需要了,可以理解為每個類有且僅有一次初始化的機會。那麼什麼是主動呼叫呢?
JVM 規定了以下六種情況為主動呼叫,其餘的皆為被動呼叫

  1. 一個類的例項被建立(new操作、反射、cloning,反序列化)
  2. 呼叫類的static方法
  3. 使用或對類/介面的static屬性進行賦值時(這不包括final的與在編譯期確定的常量表示式)
  4. 當呼叫 API 中的某些反射方法時
  5. 子類被初始化
  6. 被設定為 JVM 啟動時的啟動類(具有main方法的類)

本文後面會給出一個示例用於說明主動呼叫被動呼叫區別。

在這個階段,執行程式碼的順序遵循以下兩個原則:

  1. 有static先初始化static,然後是非static的
  2. 顯式初始化,構造塊初始化,最後呼叫建構函式進行初始化

示例

屬性在不同時期的賦值

class Singleton {

    private static Singleton mInstance = new Singleton();// 位置1
    public static int counter1;
    public static int counter2 = 0;

//    private static Singleton mInstance = new Singleton();// 位置2

    private Singleton() {
        counter1++;
        counter2++;
    }

    public static Singleton getInstantce() {
        return mInstance;
    }
}

public class InitDemo {

    public static void main(String[] args) {

        Singleton singleton = Singleton.getInstantce();
        System.out.println("counter1: " + singleton.counter1);
        System.out.println("counter2: " + singleton.counter2);
    }
}複製程式碼

mInstance在位置1時,列印出

counter1: 1
counter2: 0複製程式碼

mInstance在位置2時,列印出

counter1: 1
counter2: 1複製程式碼

Singleton中的三個屬性在Preparation階段會根據型別賦予預設值,在Initialization階段會根據顯示賦值的表示式再次進行賦值(按順序自上而下執行)。根據這兩點,就不難理解上面的結果了。

主動呼叫 vs. 被動呼叫

class NewParent {

    static int hoursOfSleep = (int) (Math.random() * 3.0);

    static {
        System.out.println("NewParent was initialized.");
    }
}

class NewbornBaby extends NewParent {

    static int hoursOfCrying = 6 + (int) (Math.random() * 2.0);

    static {
        System.out.println("NewbornBaby was initialized.");
    }
}

public class ActiveUsageDemo {

    // Invoking main() is an active use of ActiveUsageDemo
    public static void main(String[] args) {

        // Using hoursOfSleep is an active use of NewParent,
        // but a passive use of NewbornBaby
        System.out.println(NewbornBaby.hoursOfSleep);
    }

    static {
        System.out.println("ActiveUsageDemo was initialized.");
    }
}複製程式碼

上面的程式最終輸出:

ActiveUsageDemo was initialized.
NewParent was initialized.
1複製程式碼

之所以沒有輸出NewbornBaby was initialized.是因為沒有主動去呼叫NewbornBaby,如果把列印的內容改為NewbornBaby.hoursOfCrying 那麼這時就是主動呼叫NewbornBaby了,相應的語句也會列印出來。

首次主動呼叫才會初始化

public class Alibaba {

    public static int k = 0;
    public static Alibaba t1 = new Alibaba("t1");
    public static Alibaba t2 = new Alibaba("t2");
    public static int i = print("i");
    public static int n = 99;
    private int a = 0;
    public int j = print("j");
    {
        print("構造塊");
    }
    static {
        print("靜態塊");
    }

    public Alibaba(String str) {
        System.out.println((++k) + ":" + str + "   i=" + i + "    n=" + n);
        ++i;
        ++n;
    }

    public static int print(String str) {
        System.out.println((++k) + ":" + str + "   i=" + i + "    n=" + n);
        ++n;
        return ++i;
    }

    public static void main(String args[]) {
        Alibaba t = new Alibaba("init");
    }
}複製程式碼

上面這個例子是阿里巴巴在14年的校招附加題,我當時看到這個題,就覺得與阿里無緣了。囧

1:j   i=0    n=0
2:構造塊   i=1    n=1
3:t1   i=2    n=2
4:j   i=3    n=3
5:構造塊   i=4    n=4
6:t2   i=5    n=5
7:i   i=6    n=6
8:靜態塊   i=7    n=99
9:j   i=8    n=100
10:構造塊   i=9    n=101
11:init   i=10    n=102複製程式碼

上面是程式的輸出結果,下面我來一行行分析之。

  1. 由於Alibaba是 JVM 的啟動類,屬於主動呼叫,所以會依此進行 loading、linking、initialization 三個過程。
  2. 經過 loading與 linking 階段後,所有的屬性都有了預設值,然後進入最後的 initialization 階段。
  3. 在 initialization 階段,先對 static 屬性賦值,然後在非 static 的。k 第一個顯式賦值為 0 。
  4. 接下來是t1屬性,由於這時Alibaba這個類已經處於 initialization 階段,static 變數無需再次初始化了,所以忽略 static 屬性的賦值,只對非 static 的屬性進行賦值,所有有了開始的:

     1:j   i=0    n=0
     2:構造塊   i=1    n=1
     3:t1   i=2    n=2複製程式碼
  5. 接著對t2進行賦值,過程與t1相同

     4:j   i=3    n=3
     5:構造塊   i=4    n=4
     6:t2   i=5    n=5複製程式碼
  6. 之後到了 static 的 in

     7:i   i=6    n=6複製程式碼
  7. 到現在為止,所有的static的成員變數已經賦值完成,接下來就到了 static 程式碼塊

     8:靜態塊   i=7    n=99複製程式碼
  8. 至此,所有的 static 部分賦值完畢,接下來是非 static 的 j

     9:j   i=8    n=100複製程式碼
  9. 所有屬性都賦值完畢,最後是構造塊與建構函式

     10:構造塊   i=9    n=101
     11:init   i=10    n=102複製程式碼

經過上面這9步,Alibaba這個類的初始化過程就算完成了。這裡面比較容易出錯的是第3步,認為會再次初始化 static 變數或程式碼塊。而實際上是沒必要,否則會出現多次初始化的情況。

希望大家能多思考思考這個例子的結果,加深這三個過程的理解。

總結

經過最後這三個例子,相信大家對 JVM 對類載入機制都有了更深的理解,如果大家還是有疑問,歡迎留意討論。

參考

相關文章