1. 程式人生 > >聊聊 Java 虛擬機器:類的載入過程

聊聊 Java 虛擬機器:類的載入過程

我們都知道 Java 原始檔通過編譯器 javac 命令能夠編譯生成相應的 class 檔案,即二進位制位元組碼檔案。Java 虛擬機器將描述類或介面的 class 檔案(準確地說,應該是類的二進位制位元組流)載入到記憶體,對資料進行校驗、轉換解析和初始化,最終形成能夠被虛擬機器直接使用的 Java 型別,真正能夠執行位元組碼的操作才剛剛開始。這個過程就是虛擬機器的類載入機制。

類的載入過程概述

按照 Java 虛擬機器規範,一個 Java 檔案(類或介面)從被載入到記憶體到被卸載出記憶體的整個生命過程,總共經歷 5 個大的階段:載入、連線(驗證+準備+解析)、初始化、使用、解除安裝。其中第二個階段“連線”可細分為 3 個階段:驗證、準備和解析。因此很多書籍上也將 Java 類的生命週期劃分為 7 個階段。

類的生命週期 類的生命週期

Java 虛擬機器動態地載入類和介面,整個載入過程包括載入、連線和初始化 3 個階段。

  • 載入階段:根據特定名稱查詢類或介面型別的二進位制資料檔案,就是 class 檔案,並由此資料檔案來建立類或介面的過程。
  • 連線階段:為了讓類和介面可以被 Java 虛擬機器執行,而將類或介面併入虛擬機器執行時狀態的過程。這個階段做的工作比較多,還可以細分為下面三個階段:
    • 驗證階段:主要是校驗類檔案結構上的正確性,確保 class 中的資料資訊符合當前虛擬機器的約束要求。
    • 準備階段:為類或介面的靜態變數分配記憶體,並且以初始化這些變數的預設值,這些變數需要使用的記憶體都在方法區中進行分配。
    • 解析階段:虛擬機器將類在常量池內的符號引用轉換為直接引用的過程。
  • 初始化階段:初始化對於類或介面來說,就是執行它的初始化方法<clinit>(),真正開始執行類中定義的 Java 程式程式碼,為類的靜態變數賦予正確的初始值。

載入、驗證、準備和初始化這 4 個階段發生的順序是確定的,而解析階段則不一定,它在某些情況下可以在初始化階段之後開始,這是為了支援 Java 語言的執行時繫結(也稱為動態繫結或後期繫結)。另外需要注意的是這裡的幾個階段是按順序開始,而不是按順序進行或完成,因為這些階段通常都是互相交叉地混合進行的,通常在一個階段執行的過程中呼叫或啟用另一個階段。

這裡簡要說明下 Java 中的繫結:繫結指的是把一個方法的呼叫與方法所在的類(方法主體)關聯起來,繫結分為靜態繫結和動態繫結:

靜態繫結:即前期繫結。在程式執行前方法已經被繫結,此時由編譯器或其它連線程式實現。針對 Java 來說,簡單的可以理解為程式編譯期的繫結。Java 當中的方法只有 final,static,private 和構造方法是前期繫結的。

動態繫結:即後期繫結,也叫執行時繫結。在執行時根據具體物件的型別進行繫結。在 Java 中,幾乎所有的方法都是後期繫結的。

類的主動引用與被動引用

在整個類載入的過程中,第一個階段“載入”在虛擬機器規範中並沒強行約束,這點可以交給虛擬機器的具體實現自由設計,但是對於初始化階段虛擬機器規範是嚴格規定了如下幾種情況,如果類未初始化會對類進行初始化(當然載入、驗證和準備階段自然需要在此之前開始)。

1.在執行下列需要引用類或介面的 Java 虛擬機器指令時:new、getstatic、putstatic或invokestatic,如果類沒有進行過初始化,則需要先觸發其初始化。生成這四條指令的常見 Java 程式碼時機分別是:使用 new 關鍵字例項化物件的時候,讀取或者設定一個類的靜態變數(被 final 修飾的靜態變數即常量、已經在編譯器把結果放入常量池的靜態變數除外)的時候,或是呼叫類的靜態方法的時候。

讀取或設定類的靜態變數會觸發類初始化,示例如下:

public class Initialization {

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

    public static int x = 0;
}

public class Test {

    public static void main(String[] args) {
        System.out.println(Initialization.x);
    }
}
複製程式碼

輸出結果:

init!
0
複製程式碼

呼叫類的靜態方法會觸發類初始化,示例如下:

public class Initialization {

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

    public static void test() {

    }
}

public class Test {

    public static void main(String[] args) {
        Initialization.test();
    }
}
複製程式碼

輸出結果:

init!
複製程式碼

2.呼叫類庫 java.lang.reflect 包中某些方法對類進行反射呼叫的時候,如果類沒有進行過初始化,則需要先觸發其初始化。示例如下:

public class Initialization {

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

public class Test {

    public static void main(String[] args) throws ClassNotFoundException {
        Class.forName("jvm.init.demo.Initialization");
    }
}
複製程式碼

輸出結果:

init!
複製程式碼

3.在對一個類的某個子類進行初始化的時候,如果發現該類沒有進行過初始化,則需要先觸發其初始化。

public class Parent {

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

public class Child extends Parent {

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

public class Initialization {

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

    public static void main(String[] args) {
        Child child = new Child();
    }
}
複製程式碼

輸出結果:

init!
Parent init!
Child init!
複製程式碼

4.在類被選定為 Java 虛擬機器啟動時的初始類時(包含 main() 的那個類),虛擬機器會先初始化這個主類。如上示例所示,虛擬機器會先初始化 Initialization 這個主類。

5.在使用 JDK1.7 的動態語言支援下,初次呼叫 java.lang.invoke.MethodHandle 例項後的解析結果是 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法控制代碼,並且這個方法所對應的類沒有進行過初始化,則需要先觸發其初始化。

根據虛擬機器規範,類或介面首次主動引用時才會對其初始化,且只有以上幾種對類主動引用的場景才會觸發類的初始化,除此之外,其餘的都稱為被動引用,且都不會觸發初始化。

關於類的主動引用和被動引用,下面舉幾個容易引起大家混淆的例子來說明什麼是被動引用。

  • 定義某個類的陣列時不會觸發該類的初始化,比如下面的例子:
public class Person {

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

}

public class Test {

    public static void main(String[] args) {
        Person[] p = new Person[10];
        System.out.println(p.length);
    }

}
複製程式碼

輸出結果:

10
複製程式碼

從結果中並沒有看到輸出:Person init! 因此,新建 Person 陣列並沒有觸發 Person 類的初始化,是被動引用。

  • 子類引用父類的靜態欄位,不會導致子類初始化,如下例子:
public class Parent {

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

    public static String s = "hello";

}

public class Child extends Parent {

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

}

public class Test {

    public static void main(String[] args) {
        System.out.println(Child.s);
    }

}
複製程式碼

輸出結果:

Parent init!
hello
複製程式碼

從結果中沒有看到輸出:Child init! 因此,並沒有觸發子類的初始化,屬於被動引用。

  • 引用類的靜態常量不會導致類的初始化,看下面例子:
public class Constants {

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

    public static final String HELLO = "hello";

}

public class Test {

    public static void main(String[] args) {
        System.out.println(Constants.HELLO);
    }

}
複製程式碼

輸出結果:

hello
複製程式碼

從結果中沒有看到輸出:Constants init!常量在編譯階段會存入呼叫類的常量池中,其實並沒有直接引用到定義常量的類,因此沒有觸發定義常量的類的初始化。

類的載入過程詳解

在詳細講解 Java 虛擬機器中類載入的各個階段前,我們先看下面的這段單例程式,思考下程式的輸出結果:

public class Singleton {

    // ①
    private static int a = 0;

    private static int b;

    private static Singleton instance = new Singleton(); // ②

    private Singleton() {
        a++;
        b++;
    }

    public static Singleton getInstance() {
        return instance;
    }

    public static void main(String[] args) {
        Singleton instance = Singleton.getInstance();
        System.out.println("a = " + instance.a);
        System.out.println("b = " + instance.b);
    }
}
複製程式碼

執行上面的程式結果輸出為:

a = 1
b = 1
複製程式碼

將 ② 這行程式碼上移到註釋 ① 的位置,再次執行結果輸出為:

a = 0
b = 1
複製程式碼

輸出不一樣的結果,這是為什麼呢?通過下面的學習,我們在後面詳細解釋這個現象。

類的載入(Loading)階段

“載入”是整個“類載入”過程的第一個階段,簡單來說,類的載入就是將 class 檔案中的二進位制資料讀取到記憶體中,將這個位元組流所代表的靜態儲存結構轉化為方法區的執行時資料結構,並且在記憶體中生成一個代表這個類的 java.lang.Class 物件,作為訪問方法區資料結構的入口。

Java 虛擬機器規範定義類的載入是通過全限定名(包名+類名)來獲取二進位制資料流,但並沒有強行約束必須通過某種方式獲取或者從某個地方獲取,通過以下幾種常見的形式來獲取描述類的二進位制資料流:

  • 通過讀取 zip 檔案獲取,比如 jar、war。
  • 執行時動態生成,通過動態代理 java.lang.Proxy 生成代理類的二進位制位元組流,或者使用 ASM 包生成 class。
  • 通過網路獲取,如 Applet 小程式、RMI 動態呼叫釋出。
  • 將類的二進位制資料儲存在資料庫的 BLOB 欄位中,從資料庫中獲取。

在某個類經歷載入階段後,虛擬機器首先會將二進位制位元組流按照虛擬機器所需的格式儲存在方法區中,虛擬機器規範沒有規定方法區中的具體資料結構,該區域中的資料儲存格式由虛擬機器實現自行定義。隨後在記憶體中例項化一個 java.lang.Class 物件,以便後面程式可通過該物件去訪問方法區中的這些型別資料。在類載入的整個生命週期中,載入階段是可以與連線階段的部門內容交叉進行的,比如連線階段驗證位元組碼檔案格式,但是載入階段肯定還是在連線階段之前開始的。

類的連線(Linking)階段

類的連線階段可細分為 3 個小的過程:驗證、準備和解析。
1.驗證
驗證是連線階段的第一步,驗證是為了確保 Class 檔案的位元組流中包含的資訊符合當前虛擬機器的要求,並且不會危害虛擬機器自身的安全。
不同的虛擬機器,對類驗證的實現可能有所不同,但大致都會完成下面四個階段的驗證:檔案格式驗證、元資料驗證、位元組碼驗證和符號引用驗證。

1.檔案格式驗證,驗證位元組流是否符合 Class 檔案格式的規範,並且能被當前版本的虛擬機器處理。
如驗證魔數是否 0xCAFEBABE;
主、次版本號是否正在當前虛擬機器處理範圍之內;
常量池的常量中是否有不被支援的常量型別等等。

該驗證階段的主要目的是保證輸入的位元組流能正確地解析並存儲於方法區中,經過這個階段的驗證後,位元組流才會進入記憶體的方法區中儲存,所以後面的三個驗證階段都是基於方法區的儲存結構進行的。

2.元資料驗證,是對位元組碼描述的資訊進行語義分析,以保證其描述的資訊符合Java語言規範的要求。可能包括的驗證如:
這個類是否有父類;
這個類的父類是否繼承了不允許被繼承的類;
如果這個類不是抽象類,是否實現了其父類或介面中要求實現的所有方法等等。

3.位元組碼驗證,主要工作是進行資料流和控制流分析,保證被校驗類的方法在執行時不會做出危害虛擬機器安全的行為。
如果一個類方法體的位元組碼沒有通過位元組碼驗證,那肯定是有問題的;
但如果一個方法體通過了位元組碼驗證,也不能說明其一定就是安全的。

4.符號引用驗證,發生在虛擬機器將符號引用轉化為直接引用的時候,這個轉化動作將在“解析階段”中發生。
驗證符號引用中通過字串描述的許可權定名是否能找到對應的類;
在指定類中是否存在符合方法欄位的描述符及簡單名稱所描述的方法和欄位;
符號引用中的類、欄位和方法的訪問性(private、protected、public、default)是否可被當前類訪問。

驗證階段對於虛擬機器的類載入機制來說,不一定是必要的階段。如果所執行的全部程式碼確認是安全的,可以使用-Xverify:none引數來關閉大部分的類驗證措施,以縮短虛擬機器載入類的時間。

2.準備
準備階段是為類的靜態變數分配記憶體並將其初始化為預設值,這些記憶體都將在方法區中進行分配。準備階段不分配類中的例項變數的記憶體,例項變數將會在物件例項化時隨著物件一起分配在Java堆中。

//在準備階段 value 初始值為 0,在初始化階段才會賦值為 100
public static int value = 100;
複製程式碼

3.解析
解析階段是虛擬機器將常量池內的符號引用替換為直接引用的過程。

符號引用(Symbolic Reference):符號引用以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可。符號引用與虛擬機器實現的記憶體佈局無關,引用的目標並不一定已經載入到記憶體中。

直接引用(Direct Reference):直接引用可以是直接指向目標的指標、相對偏移量或是一個能間接定位到目標的控制代碼。直接引用是與虛擬機器實現的記憶體佈局相關的,如果有了直接引用,那麼引用的目標必定已經存在於記憶體中。

類的初始化(Initialization)階段

類初始化是類載入過程的最後一步,前面的類載入過程,除了在載入階段使用者應用程式可以通過自定義類載入器參與之外,其餘動作完全由虛擬機器主導和控制。到了初始化階段,才真正開始執行類中定義的 Java 程式程式碼。

初始化階段是執行類構造器<clinit>()方法的過程。<clinit>()方法是由編譯器自動收集類中的所有類變數的賦值動作和靜態語句塊(static{}塊)中的語句合併產生的。

程式結果分析

結合類載入過程的詳細說明,最後分析一下之前給出的程式片段:

public class Singleton {

    // ①
    private static int a = 0;

    private static int b;

    private static Singleton instance = new Singleton(); // ②

    private Singleton() {
        a++;
        b++;
    }

    public static Singleton getInstance() {
        return instance;
    }

    public static void main(String[] args) {
        Singleton instance = Singleton.getInstance();
        System.out.println("a = " + instance.a);
        System.out.println("b = " + instance.b);
    }
}
複製程式碼

分析說明如下:
1.Singleton instance = Singleton.getInstance(); Singleton 呼叫了類的靜態方法,觸發類的初始化;
2.類載入的時候在準備過程中為類的靜態變數分配記憶體並初始化預設值 instance = null, a = 0, b = 0;
3.類開始初始化,為類的靜態變數賦值和執行靜態程式碼塊,此時被賦值 a = 0,此時 b 沒有賦值操作 b = 0, instance 被賦值為 new Singleton() 呼叫類的構造方法;
4.呼叫類的構造方法後 a = 1;b = 1。

若將 ② 這行程式碼上移到註釋 ① 的位置:
1.Singleton instance = Singleton.getInstance(); Singleton 呼叫了類的靜態方法,觸發類的初始化;
2.類載入的時候在準備過程中為類的靜態變數分配記憶體並初始化預設值 instance = null, a = 0, b=0;
3.類開始初始化,為類的靜態變數賦值和執行靜態程式碼塊,instance 被賦值為 new Singleton() 呼叫類的構造方法;
4.呼叫類的構造方法後 a = 1; b = 1;
5.繼續為 a 和 b 賦值,此時 b 沒有賦值操作,所以 b 為 1,但是 a 執行賦值操作就被賦值為 0。

參考資料

《深入理解Java虛擬機器:JVM高階特性與最佳實踐(第2版)- 周志明》
《Java高併發程式設計詳解:多執行緒與架構設計 - 汪文君》