深入理解虛擬機執行子系統——你真的瞭解類加載過程嗎?

一提到類加載機制,現在的人大部分都能回答雙親委派模型、加載的大致過程。是的,大部分人知道的東西一定不是錯的,但知識和財富一樣,唯有少部分才能真正掌握。

開始閱讀之前,先統一以下約定:
1.類型:包含了類和接口;
2.Class文件:不是以文件形式保存在磁盤某一處,而是一串二進制字節流,不論其以何種形式存在。有可能是磁盤文件、網絡、數據庫、內存或者動態產生等。
3.本篇文中使用了較多的反編譯來輔助剖析類的加載過程。如果對於反編譯或者Class文件不熟悉,則最好先閱讀這篇:《深入理解虛擬機執行子系統——扒開Class文件的結構 一探究竟》

類加載的時機

一個類型從被加載到虛擬機內存中,到卸載出虛擬機內存它的整個生命週期會經過:加載、校驗、準備、解析、初始化、使用、卸載這七個階段,其中校驗、準備、解析三個部分統稱爲連接,整個過程如下:
類加載過程
加載過程按照加載、驗證、準備初始化和卸載的順序進行,但解析階段則不一定。在某些特定的場景下,解析階段可以在初始化階段之後開始。這是爲了支持Java語言的運行時綁定特性。
整個加載過程按部就班的“開始”,但不意味着將按部就班的結束,這是因爲這些階段通常都是互相交叉混合的進行着,會在一個階段的執行過程中激活另一個階段,一個階段先開始並不意味它要先結束。

加載

加載階段是整個類加載過程的第一個階段。這個階段主要完成3方面的事情:
1.通過一個類的全限定名獲取此類的二進制字節流;
2.將這個二進制字節流所代表的的靜態存儲結構轉換爲方法區的運行時數據結構;
3.在內存中生成一個代表這個類的java.lang.Class對象,作爲方法區這個類的各種數據的訪問入口。

《Java虛擬機規範》對這3點並沒有具體的實現要求。因此留給虛擬機可發揮的空間很大,僅僅第1條就給虛擬機開放了廣闊的空間。許多舉足輕重的技術就是基於這一條實現的,比如:
1.從ZIP壓縮包中讀取,這一點很常見,也成爲了日後JAR、WAR包格式的基礎;
2.從網絡中獲取,典型應用場景有Web-Applet;
3.運行時動態生成,典型場景有動態代理,生成帶有“$Proxy”後綴的代理類的二進制字節流;
4.由其他文件生成,典型應用場景有JSP文件,由JSP文件生成對應的Class文件;
5.從數據庫中讀取,這種場景相對較少,例如有些中間件服務器(如SAP Netweaver)可以選擇把程序安裝到數據庫中來完成程序代碼在集羣間的分發;
6.從加密文件是讀取,這種就是典型的Class文件加密防止被反編譯的保護措施。通過加載時解密Class文件來保障程序運行邏輯不被窺探。

相對於其他階段,類型加載階段是程序員可控性最強的階段了。加載階段可以使用Java虛擬機內置的類加載器完成,也可以由開發人員自定義類加載器完成(繼承ClassLoader重寫findClass或者loadClass方法)。
對於數組類而言,情況就有所不同,數組類本身不通過類加載器創建,它是由Java虛擬機直接在內存中動態構造出來的。但數組類與類加載器仍然有很密切的關係,因爲數組類的元素類型(Element Type,指的是數組去掉所有維度的類型)最終還是要靠類加載器來完成加載。

加載階段與連接階段的部分動作(如一部分字節碼文件格式驗證動作)是交叉進行的,加載階段尚未完成,連接階段可能已經開始,但這些夾在加載階段之中進行的動作,仍然屬於連接階段的一部分,這兩個階段的開始時間仍然保持着固定的先後順序。

驗證

驗證是連接階段的第一步,主要用於校驗二進制字節流的內容是否符合《Java虛擬機規範》全部要求,以此來保證這些內容不會對虛擬機造成威脅。整個驗證階段大致分爲四個部分:

1.文件格式驗證

回想一下Class文件的結構:比如魔數、版本號、常量池、屬性表、方法表、局部變量表等,此階段主要就是對這些文件結構進行校驗,常見的有:
1)是否已魔數開頭,版本號是否在虛擬機可接受的範圍內;
2)常量池中的常量是否有不被支持的類型(檢查flag標誌);
3)CONSTANT_Utf8_info型的常量中是否有不符合UTF-8編碼的數據;
4)Class文件各部分結構及其本身是有有被刪除或者添加附加信息的內容。

2.元數據驗證

這個階段主要是對Class文件的元數據(比如父類、屬性、繼承關係等)從語法的角度進行校驗。常見的用:
1)這個類是否有父類;是否繼承了不被允許繼承的類、是否實現了接口中的方法等;
2)這個類的屬性、方法是否與父類產生了矛盾。
……

3.字節碼驗證

這階段就要對類的方法體(Class文件中的Code屬性)進行校驗分析,保證被校驗類的方法在運行時不會做出危害虛擬機安全的行爲。常見的有:
1)保證任意時刻操作數棧的數據類型與指令代碼序列都能配合工作;
2)保證任何跳轉指令都不會跳轉到方法以爲的字節碼指令上;
3)保證方法體中的類型轉換總是有效的。

如果一個類型中有方法體沒有通過字節碼驗證,那它肯定是有問題的,如果通過校驗了,依然不能證明它是絕對安全的。這裏涉及到一個離散數學中的經典問題:“停機問題”——不能通過程序準確地檢查出另一個程序是否能在有限的時間之內結束運行。同樣地,我們無法通過程序去精準判斷另一段程序是否存在bug。
由於數據流分析和控制流分析的高度複雜性,Java虛擬機的設計團隊爲了避免過多的執行時間消耗在字節碼驗證階段中,在JDK 6之後把儘可能多的校驗輔助措施挪到Javac編譯器裏進行。具體做法是給方法體Code屬性的屬性表中新增加了一項名爲“StackMapTable”的新屬性,在字節碼驗證期間,Java虛擬機就不需要根據程序推導這些狀態的合法性,只需要檢查StackMapTable屬性中的記錄是否合法即可,從而節省了大量的校驗時間。

4.符號引用驗證

符號引用驗證可以看作是對類自身以外(常量池中的各種符號引用)的各類信息進行匹配性校驗,通俗來說就是,該類是否缺少或者被禁止訪問它依賴的某些外部類、方法、字段等資源。本階段通常需要校驗下列內容:
1)符號引用中通過字符串描述的全限定名是否能找到對應的類。
2)在指定類中是否存在符合方法的字段描述符及簡單名稱所描述的方法和字段。
3)符號引用中的類、字段、方法的可訪問性(private、protected、public、)是否可被當前類訪問。

驗證階段對於虛擬機的類加載機制來說,是一個非常重要的、但卻不是必須要執行的階段
因爲驗證階段只有通過或者不通過的差別,只要通過了驗證,其後就對程序運行期沒有任何影響了。如果程序運行的全部代碼(包括自己編寫的、第三方包中的、從外部加載的、動態生成的等所有代碼)都已經被反覆使用和驗證過,在生產環境的實施階段就可以考慮使用-Xverify:none參數來關閉大部分的類驗證措施,以縮短虛擬機類加載的時間。

準備

準備階段是正式爲類中定義的變量(被static修飾的變量)分配內存併爲它們賦初始值。
這裏需要仔細說明兩個容易混淆的點:
1.僅包括類變量的的內存分配和初始值分配,而不包括實例變量!實例變量是隨着對象的實例化一起被分配到內存中;
2.這裏的賦初始值指的是這些類型的零值。這些類型的零值如下:
基本類型的零值
3.如果類變量同時被final關鍵字修飾,那麼就會在字段屬性表中有對應的ConstantValue屬性,在準備階段這個類變量就會被初始化成ConstantValuse屬性所指定的值。這個類變量也被稱爲常量。我們對比源代碼和反編譯結果:

package com.leon.util;

public class Test {

    private static int VAL_1 = 1;
    private static final int VAL_2 = 2;

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

// 對應的反編譯結果如下:
public class com.leon.util.Test
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #7.#22         // java/lang/Object."<init>":()V
   #2 = Fieldref           #23.#24        // java/lang/System.out:Ljava/io/PrintStream;
   #3 = String             #25            // test class.
   #4 = Methodref          #26.#27        // java/io/PrintStream.println:(Ljava/lang/String;)V
   #5 = Fieldref           #6.#28         // com/leon/util/Test.VAL_1:I
   #6 = Class              #29            // com/leon/util/Test
   #7 = Class              #30            // java/lang/Object
   #8 = Utf8               VAL_1
   #9 = Utf8               I
  #10 = Utf8               VAL_2
  #11 = Utf8               ConstantValue
  #12 = Integer            2
……

通過反編譯結果我們清晰的看到常量池中存在VAL_2常量和其所對應的ConstantValue屬性,以及值爲2.而VAL_1類變量雖然指定了值爲1,但並沒有爲其對應的ConstantValue屬性。
那麼類變量所指定的值什麼時候賦值呢?我們帶着這個問題先繼續往下剖析。

解析

解析階段是Java虛擬機將Class文件中的符號引用轉換爲直接引用的過程。
符號引用
在Class文件中,符號引用以CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等類型的常量出現來描述所引用的目標,符號可以是任何形式的字面量,只要在使用時能夠準備的定位到目標即可。符號引用與虛擬機實現的內存佈局無關,引用的目標並不一定是已經加載到虛擬機內存當中的內容。
直接引用
直接引用是可以直接指向目標的指針、相對偏移量或者是一個能間接定位到目標的句柄。直接引用是和虛擬機實現的內存佈局直接相關的,同一個符號引用在不同虛擬機實例上翻譯出來的直接引用一般不會相同。如果有了直接引用,那引用的目標必定已經在虛擬機的內存中存在。
對同一個符號引用進行多次解析請求是很常見的事情。除了invokedynamic指令以外,虛擬機實現可以對第一次解析的結果進行緩存,譬如在運行時直接引用常量池中的記錄,並把常量標識爲已解析狀態,從而可以避免解析動作重複進行。
而對於invokedynamic指令而言,上面的規則就不成立了。因爲該指令本身的目的是用於支持動態語言,必須等到程序實際運行至此纔會觸發解析動作,因此每次解析的結果是不一樣的。具體原理在以後“動態語言原理”中進行詳細剖析。
解析階段中大致有以下幾部分:
1)類或者接口的解析
2)字段的解析
3)方法的解析
4)接口方法解析

初始化

初始化階段是類加載階段的最後一個過程。實際上初始化階段就是執行類構造方法< clinit >()方法的過程。此方法並不是Java代碼中直接編寫的方法,而是由javac編譯器自動生成的,必須要和類的構造方法< init >()方法區分開來。
那麼類構造方法是如何誕生的,包含哪些信息呢?
< clinit >()方法是由編譯器自動收集類中的所有類變量的賦值動作和靜態語句塊(static{}塊)中的語句合併產生的。編譯器收集的順序是由語句在源文件中出現的順序決定的,靜態語句塊中只能訪問到定義在靜態語句塊之前的變量,定義在它之後的變量,在前面的靜態語句塊可以賦值,但是不能訪問。
< clinit >()方法與類的構造函數(實例構造器< init >()方法)不同,它不需要顯式地調用父類構造器,Java虛擬機會保證在子類的< clinit >()方法執行前,父類的< clinit >()方法已經執行完畢。因此在Java虛擬機中第一個被執行的< clinit >()方法的類型肯定是java.lang.Object。
< clinit >()方法對於類或接口來說並不是必需的,如果一個類中沒有靜態語句塊,也沒有對變量的賦值操作,那麼編譯器可以不爲這個類生成()方法。
Java虛擬機必須保證一個類的< clinit >()方法在多線程環境中被正確地加鎖同步,如果多個線程同時去初始化一個類,那麼只會有其中一個線程去執行這個類的< clinit >()方法,其他線程都需要阻塞等待,直到活動線程執行完畢()方法。
我們通過Java源碼和反編譯結果對比來看:

package com.leon.util;

public class Test {

    private static int VAL_1 = 1;
    private static final int VAL_2 = 2;

    static {
        System.out.println("static block");
    }

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

// 省略部分……
// 類構造器反編譯如下:
 static {};
    descriptor: ()V
    flags: ACC_STATIC
    Code:
      stack=2, locals=0, args_size=0
         0: iconst_1
         1: putstatic     #5                  // Field VAL_1:I
         4: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         7: ldc           #6                  // String static block
         9: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        12: return
      LineNumberTable:
        line 5: 0
        line 9: 4
        line 10: 12
// 省略部分……

可以看到,類構造器反編譯的結果中確實是按照順序收集了類變量和靜態代碼並進行執行。

對於初始化階段,《java虛擬機規範》嚴格規定了有且只有6種情況必須立即對類進行“初始化”(加載、連接自然要在此之前開始):
1.遇到new、getstatic、putstatic或者invokestatic這4個指令時,如果類型沒有初始化過則必須要先執行初始化過程。
能夠生成這4個指令的典型場景如下:
1)使用new關鍵字實例化對象的時候;

package com.leon.util;

public class Test {
    public static void main(String[] args) {
        Object obj = new Object();
    }
}
// 部分反編譯結果:
 public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=2, args_size=1
         0: new           #2                  // class java/lang/Object
         3: dup
         4: invokespecial #1                  // Method java/lang/Object."<init>":()V
         7: astore_1
         8: return
      LineNumberTable:
        line 7: 0
        line 8: 8

2)讀取或設置一個類型的靜態字段;

package com.leon.util;

public class Test {
    public static void main(String[] args) {
    	// 讀取常量
        String val1 = TestConstant.VAL_1;
        System.out.println(val1);
    }
}
// 部分反編譯結果如下:
 public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=2, args_size=1
         0: ldc           #3                  // String TestConstant
         2: astore_1
         3: getstatic     #4                  // Field java/lang/System.out:Ljava/io/PrintStream;
         6: aload_1
         7: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        10: return

3)調用一個類型的靜態方法;

package com.leon.util;

public class Test {
    public static void main(String[] args) {
        // invoke static method.
        TestConstant.staticMethod();
    }
}
// 部分編譯結果如下:
 public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=0, locals=1, args_size=1
         0: invokestatic  #2                  // Method com/leon/util/TestConstant.staticMethod:()V
         3: return

以上便是這4個指令的典型使用場景,當出現了這4個指令,並且沒有進行類的初始化時,會立即執行類的初始化過程。

2.使用java.lang.reflect包的方法對類型進行反射調用的時候,如果此時沒有進行類的初始化則會立即執行類的初始化過程。

package com.leon.util;

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;

public class Test {
    public static void main(String[] args) throws ClassNotFoundException, NoSuchMethodException, InvocationTargetException, IllegalAccessException {
    	// 使用java.lang.reflect包進行反射調用方法
        Class<?> clazz = Class.forName("com.leon.util.TestConstant");
        Method staticMethod = clazz.getMethod("staticMethod");
        staticMethod.invoke(clazz, null);
    }
}

3)當類在進行初始化時,發現其父類還沒有進行初始化,則先執行其父類的初始化。
4)當虛擬機啓動時,用戶需要指定一個主類(包含main方法的那個類),虛擬機會先初始化這個主類。
5)當使用JDK 7新加入的動態語言支持時,如果一個java.lang.invoke.MethodHandle實例最後的解析結果爲REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四種類型的方法句柄,並且這個方法句柄對應的類沒有進行過初始化,則需要先觸發其初始化。關於動態語言,後續會有一篇專門的文章進行剖析。
6)當一個接口中定義了JDK 8新加入的默認方法(被default關鍵字修飾的接口方法)時,如果有這個接口的實現類發生了初始化,那該接口要在其之前被初始化。

《java虛擬機規範》使用了及其嚴格的方式申明有且只有以上6中情況會先執行類的初始化過程。我們來看這個例子:

public class Parent {
	static {
		System.out.println("Parent static block.");
	}
	public static int PARENT_VAL = 5;
}

public class Child extends Parent {
	static {
		System.out.println("Child static block.");
	}
}

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

以上程序執行的結果是:
Parent static block.
5

我們前面說過,當調用了getstatic指令時,如果類沒有進行初始化,則先進行類的初始化。在這個例子中,調用了靜態變量,必然會生成getstatic指令,因此必然要執行類的初始化過程。只不過這裏執行的是父類的初始化過程,並不會因爲使用了子類調用父類的靜態變量的方式而執行子類的初始化過程。但是是否執行了子類的加載和驗證階段,《java虛擬機規範》並未明確說明,不同的虛擬機實現不同。

對於接口而言,其加載過程與類的加載過程有些不同。主要不同之處在於初始化階段。對於一個類的初始化而言,必須要先完成父類的初始化,但對於接口而言,沒有這一約束,只有在真正使用到了父類的時候纔會進行父類的初始化。

使用(實例化)

類實例化之前,必須先經過類的加載過程。類加載過程更多的是虛擬機內部的活動,而實例化過程開發人員有更多的控制空間。
實例化一個對象的方式有很多種,具體如下:
1.通過new關鍵字實例化一個類的對象;
2.通過反射的方式實例化一個類的對象;

Class.forName(“java.lang.Object”).newInstance();
Object.class.newInstance();
Person .class.getConstructor().newInstance();

3.通過clone方法實例化一個類的對象;
4.通過I/O流反序列化,實例化一個類的對象。

不論採用哪種方式實例化一個類的對象,都要完成對象在內存中的佈局,最終完成構造方法的調用。如果有父類,則必須完成父類的構造方法的調用。
包括整個加載過程,其具體初始化順序如下:
父類的類構造器——>子類的類構造器——>父類的成員變量初始化(開發人員賦值)——>父類的構造方法——>子類的成員變量初始化(開發人員賦值)——>子類的構造方法

如果看懂了類的加載過程,那麼上面的初始化順序能夠很輕鬆的理解到。

很多博客所描述的的初始化順序其實都不是特別嚴謹,並且沒有闡述清楚背後的原理,不信你可以在百度上搜索關鍵字“類的初始化順序”並隨便打開一篇博客,看看是否如此,很容易就被誤導了……

卸載

當一個對象使用完成之後,如果符合垃圾回收,那麼虛擬機將會在合適的時間將其回收。但是類的的卸載條件則是十分嚴苛的。具體我們可以參考這篇文章:《深入理解Java垃圾回收——對象已死?》

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章