Java類加載機制(初始化順序)

之前寫過一篇關於Java中普通代碼塊和static代碼塊的區別,大致講解了普通代碼塊和Static代碼的區別,但是並沒有講它們的加載執行順序,本章就細細的將一下類的加載機制(初始化順序)。

類生命週期

類的字節碼從被加載到虛擬機內存中開始,到卸載出內存爲止,它的整個生命週期包括:加載(Loading)、驗證(Verification)、準備(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸載(Unloading)7個階段。其中準備、驗證、解析3個部分統稱爲連接(Linking)。類的生命週期如下圖所示:
這裏寫圖片描述
其中,加載、驗證、準備、初始化、卸載在類的生命週期的順序是不變的,那解析呢,它在某些情況下可能在初始化之後開始。這裏要強調“開始”這兩個字,因爲類的生命週期裏都是交叉完成的,通常會在執行一個階段的過程中開始另外一個階段。

加載

在加載階段(可以參考java.lang.ClassLoader的loadClass()方法),虛擬機需要完成以下3件事情:
1. 通過一個類的全限定名來獲取定義此類的二進制字節流(這裏並沒有指明要從一個Class文件中獲取,可以從其他渠道,譬如:jar包、zip文件、網絡、動態生成、數據庫等);
2. 將這個字節流所代表的靜態存儲結構轉化爲方法區的運行時數據結構;
3. 在內存中生成一個代表這個類的java.lang.Class對象,作爲方法區這個類的各種數據的訪問入口;
加載階段和連接階段(Linking)的部分內容(如一部分字節碼文件格式驗證動作)是交叉進行的,加載階段尚未完成,連接階段可能已經開始,但這些夾在加載階段之中進行的動作,仍然屬於連接階段的內容,這兩個階段的開始時間仍然保持着固定的先後順序。

非數組類型加載

這裏的類加載要區分數組類型和非數組類型,其中非數組類型是開發人員最容易控制的,比如第一點獲取二進制字節流的方式,都是開發人員可以控制的,除了這點開發人員可以也可以指定類加載器進行加載。

數組類型的加載

數組類型的類加載實際上是虛擬機完成的,開發人員控制不了,能控制的是數組的元數據的類型,一個數組類的創建要遵循以下原則:
1. 如果數組的元素類型引用類型,加載器會先按照類的加載過程加載,並且數組也將按照該類的加載器進行標識。
2. 如果數組的元素類型是普通類型,比如(int[] a),虛擬機則將數組標註爲和引導類加載器相關。
3. 數組類的可見性和它的元素類型一致,如果元素是引用類型,數組類的可見性默認是public。
第三點很好理解,如果一個類的可見性是protected或者private,這個類不能在它的包外或者包裏訪問,那麼這個類的數組類型也不能在類的包外或者包裏訪問。

驗證

驗證是連接階段的第一步,這一階段的目的是爲了確保Class文件的字節流中包含的信息符合當前虛擬機的要求,並且不會危害虛擬機自身的安全。
驗證階段大致會完成4個階段的檢驗動作:
1. 文件格式驗證:驗證字節流是否符合Class文件格式的規範;例如:是否以魔術0xCAFEBABE開頭、主次版本號是否在當前虛擬機的處理範圍之內、常量池中的常量是否有不被支持的類型、是否有不符合UTF-8編碼的數據、是否有被刪除和附加的信息。
2. 元數據驗證:對字節碼描述的信息進行語義分析(注意:對比javac編譯階段的語義分析),以保證其描述的信息符合Java語言規範的要求;例如:這個類是否有父類,除了java.lang.Object之外。
3. 字節碼驗證:通過數據流和控制流分析,確定程序語義是合法的、符合邏輯的。
4. 符號引用驗證:確保解析動作能正確執行。
5. 驗證階段是非常重要的,但不是必須的,它對程序運行期沒有影響,如果所引用的類經過反覆驗證,那麼可以考慮採用-Xverifynone參數來關閉大部分的類驗證措施,以縮短虛擬機類加載的時間,但是如果是服務器的代碼建議開啓。

準備

準備階段是正式爲類變量分配內存並設置類變量初始值的階段,這些變量所使用的內存都將在方法區(常量區)中進行分配。這時候進行內存分配的僅包括類變量(被static修飾的變量),而不包括實例變量,實例變量將會在對象實例化時隨着對象一起分配在堆中。其次,這裏所說的初始值“通常情況”下是數據類型的零值,假設一個類變量的定義爲:
public static int value=123;
那變量value在準備階段過後的初始值爲0而不是123。因爲這時候尚未開始執行任何java方法,而把value賦值爲123的putstatic指令是程序被編譯後,存放於類構造器()方法之中,所以把value賦值爲123的動作將在初始化階段纔會執行。
至於“特殊情況”是指:public static final int value=123,即當類字段的字段屬性是ConstantValue時,會在準備階段初始化爲指定的值,所以標註爲final之後,value的值在準備階段初始化爲123而非0,如果不賦值的話,默認值就爲0

解析

解析階段是虛擬機將常量池內的符號引用替換爲直接引用的過程。解析動作主要針對類或接口、字段、類方法、接口方法、方法類型、方法句柄和調用點限定符7類符號引用進行。
這裏解釋一下什麼是符號引用,什麼是直接引用:
1. 符號引用是用一組字符引用一個類、常量等,符號因爲是以一定的規範規定在class的文件中的。
2. 直接引用,也就是我們常說的引用,就是內存引用。

初始化

對我們開發來說,我們真正關心的就是初始化階段的過程,瞭解過類在字節碼的結構的開發人員清楚,類本身也有構造器,而不是代碼裏的那個構造方法,這個在字節碼裏就是<clinit>()方法。

類初始化階段是類加載過程的最後一步,到了初始化階段,才真正開始執行類中定義的java程序代碼。在準備階段,變量已經賦過一次系統要求的初始值,而在初始化階段,則根據程序猿通過程序制定的主管計劃去初始化類變量和其他資源,或者說:初始化階段是執行類構造器<clinit>()方法的過程.
<clinit>()方法是由編譯器自動收集類中的所有類變量的賦值動作和靜態語句塊static{}中的語句合併產生的,編譯器收集的順序是由語句在源文件中出現的順序所決定的,靜態語句塊只能訪問到定義在靜態語句塊之前的變量,定義在它之後的變量,在前面的靜態語句塊可以賦值,但是不能訪問。如下:

public class Test
{
    static
    {
        i=0;
        System.out.println(i);//這句編譯器會報錯:Cannot reference a field before it is defined(非法向前應用)
    }
    static int i=1;
}

<clinit>()方法與實例構造器()方法不同,它不需要顯示地調用父類構造器,虛擬機會保證在子類()方法執行之前,父類的<clinit>()方法方法已經執行完畢,這個也是“先行先發生”的原則。
舉個例子:

//SuperClass
public class SuperClass
{
    static
    {
        System.out.println("SuperClass init!");
    }

    public SuperClass()
    {
        System.out.println("init SuperClass");
    }
}
//Main class
public class Main {
    public static void main(String[] args) {
        SuperClass superClass = new SuperClass();
    }
}

執行結果:
SuperClass init!
init SuperClass

由於父類的<clinit>()方法先執行,也就意味着父類中定義的靜態語句塊要優先於子類的變量賦值操作。
<clinit>()方法對於類或者接口來說並不是必需的,如果一個類中沒有靜態語句塊,也沒有對變量的賦值操作,那麼編譯器可以不爲這個類生產<clinit>()方法。
接口中不能使用靜態語句塊,但仍然有變量初始化的賦值操作,因此接口與類一樣都會生成<clinit>()方法。但接口與類不同的是,執行接口的<clinit>()方法不需要先執行父接口的<clinit>()方法。只有當父接口中定義的變量使用時,父接口才會初始化。另外,接口的實現類在初始化時也一樣不會執行接口的<clinit>()方法。
虛擬機會保證一個類的<clinit>()方法在多線程環境中被正確的加鎖、同步,如果多個線程同時去初始化一個類,那麼只會有一個線程去執行這個類的<clinit>()方法,其他線程都需要阻塞等待,直到活動線程執行<clinit>()方法完畢。如果在一個類的<clinit>()方法中有好事很長的操作,就可能造成多個線程阻塞,在實際應用中這種阻塞往往是隱藏的。

虛擬機規範嚴格規定了有且只有5中情況(jdk1.7)必須對類進行“初始化”,並且加載、驗證、準備自然需要在此之前開始:
1. 遇到new,getstatic,putstatic,invokestatic這失調字節碼指令時,如果類沒有進行過初始化,則需要先觸發其初始化。生成這4條指令的最常見的Java代碼場景是:使用new關鍵字實例化對象的時候、讀取或設置一個類的靜態字段(被final修飾、已在編譯器把結果放入常量池的靜態字段除外)的時候,以及調用一個類的靜態方法的時候。
2. 使用java.lang.reflect包的方法對類進行反射調用的時候,如果類沒有進行過初始化,則需要先觸發其初始化。
3. 當初始化一個類的時候,如果發現其父類還沒有進行過初始化,則需要先觸發其父類的初始化。
4. 當虛擬機啓動時,用戶需要指定一個要執行的主類(包含main()方法的那個類),虛擬機會先初始化這個主類。
5. 當使用jdk1.7動態語言支持時,如果一個java.lang.invoke.MethodHandle實例最後的解析結果REF_getstatic,REF_putstatic,REF_invokeStatic的方法句柄,並且這個方法句柄所對應的類沒有進行初始化,則需要先出觸發其初始化。

爲了解釋這個我們舉個例子:

//SuperClass
public class SuperClass
{
    static
    {
        System.out.println("SuperClass init!");
    }

    public static final Integer Value = 111;

    public SuperClass()
    {
        System.out.println("init SuperClass");
    }
}
//Subclass
public class SubClass extends SuperClass{
}
//Main
public class Main {
    public static void main(String[] args) {
        System.out.println(SubClass.Value);
    }
}

執行結果:

SuperClass init!
111

只執行了父類SuperClass的<clinit>()方法,子類SubClass的方法並沒有執行,因爲沒有觸發類的初始化。
那麼,調用類的常量一定會觸發類的初始化麼?
我們修改一下SuperClass的Value常量,修改爲String:

```java
//SuperClass
public class SuperClass
{
    static
    {
        System.out.println("SuperClass init!");
    }

    public static final String Value = "111";

    public SuperClass()
    {
        System.out.println("init SuperClass");
    }
}
//Subclass
public class SubClass extends SuperClass{
}
//Main
public class Main {
    public static void main(String[] args) {
        System.out.println(SubClass.Value);
    }
}

輸出結果:

111

這裏就是常量傳播,也就是編譯器做的優化,會把不可變的、佔用內存小的常量轉移並固化到使用的地方,不會在通過類去定位這個常量的值。
再舉一個例子:
我們修改一下Main方法,在main方法裏初始化一個數組:

public class Main {
    public static void main(String[] args) {
        SubClass [] subClasses = new SubClass[2];
    }
}

因爲不滿足以上五個觸發初始化的時機,所以這個例子不會輸出任何結果。
最後我們舉個終極例子:

public class StaticTest
{
    public static void main(String[] args)
    {
        staticFunction();
    }

    static StaticTest st = new StaticTest();

    static
    {
        c=3;
        System.out.println("1");
    }

    {
        System.out.println("2");
        System.out.println(c);
    }

    StaticTest()
    {
        System.out.println("3");
        System.out.println("a="+a+",b="+b);
    }

    public static void staticFunction(){
        System.out.println("4");
    }

    int a=110;
    static int b =112;
    static int c;
}

待會兒我們給出輸出結果,分析一下執行過程。
1. 這個類是入口方法類,所以在進入Main方法之前會進行準備階段,b和c初始化爲0,然後執行初始化<clinit>()方法。
2. 執行到static StaticTest st = new StaticTest(); 會觸發類的實例化。
3. 由於初始化<clinit>()方法已經在執行了,所以直接會初始化非static代碼塊,先執行System.out.println("2");打印出2。執行System.out.println(c);時,由於c只進行初始化,並沒有進行賦值,所以打印出0。
4. 接着執行成員變量的初始化,a=110。
5. 緊接着執行構造方法,執行System.out.println("3");,打印出3,執行System.out.println("a="+a+",b="+b);,a的值爲110,b是類的常量,只進行了初始化,未進行賦值爲0,所以打印出a=110,b=0。
6. 靜態成員變量st執行完之後,進行執行靜態方法,c賦值爲3,打印出1。
7. <clinit>()執行完畢。
8. 接着執行staticFunction方法,打印出4。
最終的執行結果:

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