1. 程式人生 > 其它 >深入JVM類載入全過程解析

深入JVM類載入全過程解析

【深入Java虛擬機器】一 JVM類載入過程

首先Throws(丟擲)幾個自己學習過程中一直疑惑的問題:

1、什麼是類載入?什麼時候進行類載入?

2、什麼是類初始化?什麼時候進行類初始化?

3、什麼時候會為變數分配記憶體?

4、什麼時候會為變數賦預設初值?什麼時候會為變數賦程式設定的初值?

5、類載入器是什麼?

6、如何編寫一個自定義的類載入器?

首先,在程式碼編譯後,就會生成JVM(Java虛擬機器)能夠識別的二進位制位元組流檔案(*.class)。而JVM把Class檔案中的類描述資料從檔案載入到記憶體,並對資料進行校驗、轉換解析、初始化,使這些資料最終成為可以被JVM直接使用的Java型別,這個說來簡單但實際複雜的過程叫做JVM的類載入機制。

Class檔案中的“類”從載入到JVM記憶體中,到卸載出記憶體過程有七個生命週期階段。類載入機制包括了前五個階段。

如下圖所示:

其中,載入、驗證、準備、初始化、解除安裝的開始順序是確定的,注意,只是按順序開始,進行與結束的順序並不一定。解析階段可能在初始化之後開始。

另外,類載入無需等到程式中“首次使用”的時候才開始,JVM預先載入某些類也是被允許的。(類載入的時機)

一、類的載入

我們平常說的載入大多不是指的類載入機制,只是類載入機制中的第一步載入。在這個階段,JVM主要完成三件事:

1、通過一個類的全限定名(包名與類名)來獲取定義此類的二進位制位元組流(Class檔案)。而獲取的方式,可以通過jar包、war包、網路中獲取、JSP檔案生成等方式。

2、將這個位元組流所代表的靜態儲存結構轉化為方法區的執行時資料結構。這裡只是轉化了資料結構,並未合併資料。(方法區就是用來存放已被載入的類資訊,常量,靜態變數,編譯後的程式碼的執行時記憶體區域)

3、在記憶體中生成一個代表這個類的java.lang.Class物件,作為方法區這個類的各種資料的訪問入口。這個Class物件並沒有規定是在Java堆記憶體中,它比較特殊,雖為物件,但存放在方法區中。

相對於類載入的其他階段而言,載入階段(準確地說,是載入階段獲取類的二進位制位元組流的動作)是可控性最強的階段,因為開發人員既可以使用系統提供的類載入器來完成載入,也可以自定義自己的類載入器來完成載入。

載入階段完成後,虛擬機器外部的 二進位制位元組流就按照虛擬機器所需的格式儲存在方法區之中,而且在Java堆中也建立一個java.lang.Class類的物件,這樣便可以通過該物件訪問方法區中的這些資料。

二、類的連線

類的載入過程後生成了類的java.lang.Class物件,接著會進入連線階段,連線階段負責將類的二進位制資料合併入JRE(Java執行時環境)中。類的連線大致分三個階段。

1、驗證:驗證被載入後的類(Class檔案的位元組流)是否有正確的結構,類資料是否會符合虛擬機器的要求,確保不會危害虛擬機器安全。

不同的虛擬機器對類驗證的實現可能會有所不同,但大致都會完成以下四個階段的驗證:檔案格式的驗證、元資料的驗證、位元組碼驗證和符號引用驗證。

  • 檔案格式的驗證:驗證位元組流是否符合Class檔案格式的規範,並且能被當前版本的虛擬機器處理,該驗證的主要目的是保證輸入的位元組流能正確地解析並存儲於方法區之內。經過該階段的驗證後,位元組流才會進入記憶體的方法區中進行儲存,後面的三個驗證都是基於方法區的儲存結構進行的。
  • 元資料驗證:對類的元資料資訊進行語義校驗(其實就是對類中的各資料型別進行語法校驗),保證不存在不符合Java語法規範的元資料資訊。
  • 位元組碼驗證:該階段驗證的主要工作是進行資料流和控制流分析,對類的方法體進行校驗分析,以保證被校驗的類的方法在執行時不會做出危害虛擬機器安全的行為。
  • 符號引用驗證:這是最後一個階段的驗證,它發生在虛擬機器將符號引用轉化為直接引用的時候(解析階段中發生該轉化,後面會有講解),主要是對類自身以外的資訊(常量池中的各種符號引用)進行匹配性的校驗。

2、準備:準備階段是正式為類變數分配記憶體並設定類變數初始值的階段,這些記憶體都將在方法區中分配。

  • 這時候進行記憶體分配的僅包括類變數(static),而不包括例項變數,例項變數會在物件例項化時隨著物件一塊分配在Java堆中。
  • 這裡所設定的初始值通常情況下是資料型別預設的零值(如0、0L、null、false等),而不是被在Java程式碼中被顯式地賦予。

如static int a = 100;靜態變數a就會在準備階段被賦預設值0。另外,靜態常量(static final filed)會在準備階段賦程式設定的初值,如static final int a = 666; 靜態常量a就會在準備階段被直接賦值為666,對於靜態變數,這個操作是在初始化階段進行的。

下面列出java基本型別和引用型別的預設值:

這裡還需要注意如下幾點:

  • 對基本資料型別來說,對於類變數(static)和全域性變數,如果不顯式地對其賦值而直接使用,則系統會為其賦予預設的零值,而對於區域性變數來說,在使用前必須顯式地為其賦值,否則編譯時不通過。
  • 對於同時被static和final修飾的常量,必須在宣告的時候就為其顯式地賦值,否則編譯時不通過;而只被final修飾的常量則既可以在宣告時顯式地為其賦值,也可以在類初始化時顯式地為其賦值,總之,在使用前必須為其顯式地賦值,系統不會為其賦予預設零值。
  • 對於引用資料型別reference來說,如陣列引用、物件引用等,如果沒有對其進行顯式地賦值而直接使用,系統都會為其賦予預設的零值,即null。
  • 如果在陣列初始化時沒有對陣列中的各元素賦值,那麼其中的元素將根據對應的資料型別而被賦予預設的零值。

3、解析:將類的二進位制資料中的符號引用換為直接引用。

解析階段是虛擬機器將常量池中的符號引用轉化為直接引用的過程。在Class類檔案結構一文中已經比較過了符號引用和直接引用的區別和關聯,這裡不再贅述。前面說解析階段可能開始於初始化之前,也可能在初始化之後開始,虛擬機器會根據需要來判斷,到底是在類被載入器載入時就對常量池中的符號引用進行解析(初始化之前),還是等到一個符號引用將要被使用前才去解析它(初始化之後)。 對同一個符號引用進行多次解析請求時很常見的事情,虛擬機器實現可能會對第一次解析的結果進行快取(在執行時常量池中記錄直接引用,並把常量標示為已解析狀態),從而避免解析動作重複進行。 解析動作主要針對類或介面、欄位、類方法、介面方法四類符號引用進行,分別對應於常量池中的CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info四種常量型別。 1、類或介面的解析:判斷所要轉化成的直接引用是對陣列型別,還是普通的物件型別的引用,從而進行不同的解析。 2、欄位解析:對欄位進行解析時,會先在本類中查詢是否包含有簡單名稱和欄位描述符都與目標相匹配的欄位,如果有,則查詢結束;如果沒有,則會按照繼承關係從上往下遞迴搜尋該類所實現的各個介面和它們的父介面,還沒有,則按照繼承關係從上往下遞迴搜尋其父類,直至查詢結束,查詢流程如下圖所示:

三、類的初始化

初始化是類載入過程的最後一步,到了此階段,才真正開始執行類中定義的Java程式程式碼。在準備階段,類變數已經被賦過一次系統要求的初始值,而在初始化階段,則是根據程式設計師通過程式指定的主觀計劃去初始化類變數和其他資源,或者可以從另一個角度來表達:初始化階段是執行類構造器<clinit>()方法的過程。 這裡簡單說明下<clinit>()方法的執行規則: 1、<clinit>()方法是由編譯器自動收集類中的所有類變數的賦值動作和靜態語句塊中的語句合併產生的,編譯器收集的順序是由語句在原始檔中出現的順序所決定的,靜態語句塊中只能訪問到定義在靜態語句塊之前的變數,定義在它之後的變數,在前面的靜態語句中可以賦值,但是不能訪問。 2、<clinit>()方法與例項構造器<init>()方法(類的建構函式)不同,它不需要顯式地呼叫父類構造器,虛擬機器會保證在子類的<clinit>()方法執行之前,父類的<clinit>()方法已經執行完畢。因此,在虛擬機器中第一個被執行的<clinit>()方法的類肯定是java.lang.Object。 3、<clinit>()方法對於類或介面來說並不是必須的,如果一個類中沒有靜態語句塊,也沒有對類變數的賦值操作,那麼編譯器可以不為這個類生成<clinit>()方法。 4、介面中不能使用靜態語句塊,但仍然有類變數(final static)初始化的賦值操作,因此介面與類一樣會生成<clinit>()方法。但是介面魚類不同的是:執行介面的<clinit>()方法不需要先執行父介面的<clinit>()方法,只有當父介面中定義的變數被使用時,父接口才會被初始化。另外,介面的實現類在初始化時也一樣不會執行介面的<clinit>()方法。 5、虛擬機器會保證一個類的<clinit>()方法在多執行緒環境中被正確地加鎖和同步,如果多個執行緒同時去初始化一個類,那麼只會有一個執行緒去執行這個類的<clinit>()方法,其他執行緒都需要阻塞等待,直到活動執行緒執行<clinit>()方法完畢。如果在一個類的<clinit>()方法中有耗時很長的操作,那就可能造成多個執行緒阻塞,在實際應用中這種阻塞往往是很隱蔽的。

以下幾種情況不會執行類初始化:

  • 通過子類引用父類的靜態欄位,只會觸發父類的初始化,而不會觸發子類的初始化。
  • 定義物件陣列,不會觸發該類的初始化。
  • 常量在編譯期間會存入呼叫類的常量池中,本質上並沒有直接引用定義常量的類,不會觸發定義常量所在的類。
  • 通過類名獲取Class物件,不會觸發類的初始化。
  • 通過Class.forName載入指定類時,如果指定引數initialize為false時,也不會觸發類初始化,其實這個引數是告訴虛擬機器,是否要對類進行初始化。
  • 通過ClassLoader預設的loadClass方法,也不會觸發初始化的動作。
下面給出一個簡單的例子,以便更清晰地說明如上規則:
class Father{
    public static int a = 1;
    static{
        a = 2;
    }
}

class Child extends Father{
    public static int b = a;
}

public class ClinitTest{
    public static void main(String[] args){
        System.out.println(Child.b);
    }
}
執行上面的程式碼,會打印出2,也就是說b的值被賦為了2。 我們來看得到該結果的步驟。首先在準備階段為類變數分配記憶體並設定類變數初始值,這樣A和B均被賦值為預設值0,而後再在呼叫<clinit>()方法時給他們賦予程式中指定的值。當我們呼叫Child.b時,觸發Child的<clinit>()方法,根據規則2,在此之前,要先執行完其父類Father的<clinit>()方法,又根據規則1,在執行<clinit>()方法時,需要按static語句或static變數賦值操作等在程式碼中出現的順序來執行相關的static語句,因此當觸發執行Father的<clinit>()方法時,會先將a賦值為1,再執行static語句塊中語句,將a賦值為2,而後再執行Child類的<clinit>()方法,這樣便會將b的賦值為2. 如果我們顛倒一下Father類中“public static int a = 1;”語句和“static語句塊”的順序,程式執行後,則會打印出1。很明顯是根據規則1,執行Father的<clinit>()方法時,根據順序先執行了static語句塊中的內容,後執行了“public static int a = 1;”語句。 另外,在顛倒二者的順序之後,如果在static語句塊中對a進行訪問(比如將a賦給某個變數),在編譯時將會報錯,因為根據規則1,它只能對a進行賦值,而不能訪問。

被動引用的例子一:

通過子類引用父類的靜態欄位,對於父類屬於“主動引用”的第一種情況,對於子類,沒有符合“主動引用”的情況,故子類不會進行初始化。程式碼如下:

//父類  
public class SuperClass {  
    //靜態變數value  
    public static int value = 666;  
    //靜態塊,父類初始化時會呼叫  
    static{  
        System.out.println("父類初始化!");  
    }  
}  
  
//子類  
public class SubClass extends SuperClass{  
    //靜態塊,子類初始化時會呼叫  
    static{  
        System.out.println("子類初始化!");  
    }  
}  
  
//主類、測試類  
public class NotInit {  
    public static void main(String[] args){  
        System.out.println(SubClass.value);  
    }  
}  

被動引用的例子之二:

通過陣列來引用類,不會觸發類的初始化,因為是陣列new,而類沒有被new,所以沒有觸發任何“主動引用”條款,屬於“被動引用”。程式碼如下:

//父類
public class SuperClass {
    //靜態變數value
    public static int value = 666;
    //靜態塊,父類初始化時會呼叫
    static{
        System.out.println("父類初始化!");
    }
}

//主類、測試類
public class NotInit {
    public static void main(String[] args){
        SuperClass[] test = new SuperClass[10];
    }
}

沒有任何結果輸出!

被動引用的例子之三:

剛剛講解時也提到,靜態常量在編譯階段就會被存入呼叫類的常量池中,不會引用到定義常量的類,這是一個特例,需要特別記憶,不會觸發類的初始化!

//常量類
public class ConstClass {
    static{
        System.out.println("常量類初始化!");
    }
    
    public static final String HELLOWORLD = "hello world!";
}

//主類、測試類
public class NotInit {
    public static void main(String[] args){
        System.out.println(ConstClass.HELLOWORLD);
    }
}

輸出:hello world

總結

整個類載入過程中,除了在載入階段使用者應用程式可以自定義類載入器參與之外,其餘所有的動作完全由虛擬機器主導和控制。到了初始化才開始執行類中定義的Java程式程式碼(亦及位元組碼),但這裡的執行程式碼只是個開端,它僅限於<clinit>()方法。類載入過程中主要是將Class檔案(準確地講,應該是類的二進位制位元組流)載入到虛擬機器記憶體中,真正執行位元組碼的操作,在載入完成後才真正開始。