天天看點

【JVM】類的奇幻漂流——類加載機制探秘

我們寫的類,在編譯完成後,究竟是怎麼加載進虛拟機的?虛拟機又做了什麼神奇操作?本文可以帶着讀者初探類加載機制。上來先放類加載各個階段的主要任務,用于給讀者一個大概的印象體驗,現在記不住也沒有什麼關系。

現在隻需要記住三個名詞,裝載——>連接配接——>初始化,記住了嗎,我們要開始奇幻漂流了!

【JVM】類的奇幻漂流——類加載機制探秘

在文章的最後,我們使用幾個例子來加深對程式執行順序的了解。

1. 裝載

我覺得這裡使用裝載更好一點,第一,可以避免與類加載過程中的“加載”混淆,第二,裝載展現的就是一個“裝”字,僅僅是把貨物從一個地方搬到另外一個地方而已,而這裡的加載,卻包含搬運貨物、處理貨物等一系列流程。

裝載階段,将.class位元組碼檔案的二進制資料讀入記憶體中,然後将這些資料翻譯成類的中繼資料,中繼資料包括方法代碼,變量名,方法名,通路權限與傳回值,接着将中繼資料存入方法區。最後會在堆中建立一個Class對象,用來封裝類在方法區中的資料結構,是以我們可以通過通路此Class對象,來間接通路方法區中的中繼資料。

在Java7與Java8之後,方法區有不同的實作,這部分詳細内容可以參考我的另外一篇部落格​​靈性一問——為什麼用元空間替換永久代?​​

總結來講,裝載的子流程為:

.class檔案讀入記憶體——>中繼資料放進方法區——>Class對象放進堆中

最後我們通路此Class對象,即可擷取該類在方法區中的結構。

2. 連接配接

連接配接又包括驗證、準備、初始化

2.1 驗證

驗證被加載類的正确性與安全性,看class檔案是否正确,是否對會對虛拟機造成安全問題等,主要去驗證檔案格式、中繼資料、位元組碼與符合引用。

2.1.1 驗證檔案格式

2.1.1.1 驗證檔案類型

每個檔案都有特定的類型,類型辨別字段存在于檔案的開頭中,采用16進制表示,類型辨別字段稱為魔數,class檔案的魔數為0xCAFEBABY,關于此魔數的由來也很有意思,可以看這篇文章​​class檔案魔數CAFEBABE的由來​​。

2.1.1.2 驗證主次版本号

檢檢視主次版本号是否在目前jvm處理的範圍之内,主次版本号的存放位置緊随在魔數之後。

2.1.1.3 驗證常量池

常量池是class檔案中最為複雜的一部分,對常量池的驗證主要是驗證常量池中是否有不支援的類型。

例如,有以下簡答的代碼:

public class Main {
    public static void main(String[] args) {
        int a=1;
        int b=2;
        int c=a+b;
    }
}      

在該類的路徑下,使用javac Main.java編譯,然後使用javap -v Main可以輸出以下資訊:

【JVM】類的奇幻漂流——類加載機制探秘

以上标紅處,就是class檔案中存儲常量池的地方。

2.1.2 驗證中繼資料

主要是對位元組碼描述的資訊進行語義分析,以保證其描述的資訊符合java語言規範的要求,比如說驗證這個類是不是有父類,類中的字段方法是不是和父類沖突等等。

2.1.3 驗證位元組碼

這是整個驗證過程最複雜的階段,主要是通過資料流和控制流分析,确定程式語義是合法的、符合邏輯的。

2.1.4 驗證符号引用

它是驗證的最後一個階段,發生在虛拟機将符号引用轉化為直接引用的時候。主要是對類自身以外的資訊進行校驗。目的是確定解析動作能夠完成。

對整個類加載機制而言,驗證階段是一個很重要但是非必需的階段,如果我們的代碼能夠確定沒有問題,那麼就沒有必要去驗證,畢竟驗證需要花費一定的的時間,可以使用-Xverfity:none來關閉大部分的驗證。

2.2 準備

在這個階段中,主要是為類變量(靜态變量)配置設定記憶體以及初始化預設值,因為靜态變量全局隻有一份,是跟着類走的,是以配置設定記憶體其實是在方法區上配置設定。

這裡有3個注意點:

(1)在準備階段,虛拟機隻為靜态變量配置設定記憶體,執行個體變量要等到初始化階段才開始配置設定記憶體。這個時候還沒有執行個體化該類,連對象都沒有,是以這個時候還不存在執行個體變量。

(2)為靜态變量初始化預設值,注意,是初始化對應資料類型的預設值,不是自定義的值。

例如,代碼中是這樣寫的,自定義int類型的變量a的值為1

private static int a=1;      

但是,在準備階段完成之後,a的值隻會被初始化為0,而不是1。

(3)被final修飾的靜态變量,如果值比較小,則在編譯後直接内嵌到位元組碼中。如果值比較大,也是在編譯後直接放入常量池中。是以,準備階段結束後,final類型的靜态變量已經有了使用者自定義的值,而不是預設值。

2.3 解析

解析階段,主要是将class檔案中常量池中的符号引用轉化為直接引用。

符号引用的含義:

可以直接了解為是一個字元串,用這個字元串來表示一個目标。就像部落客的名字是SunAlwaysOnline,這個SunAlwaysOnline字元串就是一個符号引用,代表部落客,但是現在不能通過名字直接找到我本人。

直接引用的含義:

直接引用是一個指向目标的指針,能夠通過直接引用定位到目标。比如

Student s=new Student();      

我們可以通過引用變量s直接定位到新建立出的Student對象執行個體。

将符号引用轉化為直接引用,就能将平淡無奇的字元串轉化為指向對象的指針。

3. 初始化

執行初始化,就是虛拟機執行類構造器<clinit>()方法的過程,<clinit>()方法是由編譯器自動去搜集類中的所有類變量與靜态語句塊合并産生的。可能存在多個線程同時執行某個類的<clinit>()方法,虛拟機此時會對該方法進行加鎖,保證隻有一個線程能執行。

到了這個階段,類變量與類成員變量才會被賦予使用者自定義的值。

當然,一個類并不是被初始化多次,隻有當對類的首次主動使用的時候才會導緻類的初始化。主動使用包含以下幾種方式:

  • 使用new語句建立類的對象
  • 通路類靜态變量,或者對該靜态變量指派
  • 調用類的靜态方法
  • 通過反射方式擷取對象執行個體
  • 有public static void main(String[] args)方法的類會首先被初始化
  • 初始化一個類時,如果父類還沒有被初始化,則首先會初始化父類,再初始化該類。

被動使用會發生呢?

  • 當通路一個靜态變量時時,隻有真正聲明這個靜态變量的類才會被初始化。例如:通過子類引用父類的靜态變量,不會導緻子類初始化。
  • 引用常量不會觸發此類的初始化(常量在編譯階段就内嵌進位元組碼或存入調用類的常量池中)
  • 聲明并建立數組時,不會觸發類的初始化。例如Student array=new Student[2];

4. 類的初始化順序

現在有以下的代碼:

class Father {

    public static int fatherA = 1;
    public static final int fatherB = 2;

    static {
        System.out.println("父類的靜态代碼塊");
    }

    {
        System.out.println("父類的非靜态代碼塊");
    }

    Father() {
        System.out.println("父類的構造方法");
    }
}

class Son extends Father {
    public static int sonA = 3;
    public static final int sonB = 4;

    static {
        System.out.println("子類的靜态代碼塊");
    }

    {
        System.out.println("子類的非靜态代碼塊");
    }

    Son() {
        System.out.println("子類的構造方法");
    }
}      

(1)Main方法中執行個體化子類:

public class Main {
    public static void main(String[] args) {
        Son son = new Son();
    }
}      

首先可以确定的是,這屬于主動使用,父類先于子類初始化,是以會得到以下的輸出:

【JVM】類的奇幻漂流——類加載機制探秘

這裡可以進行總結,程式執行的順序為:

父類的靜态域->子類的靜态域->父類的非靜态域->子類的非靜态域->父類的構造方法->子類的構造方法

這裡的靜态域包括靜态變量與靜态代碼塊,靜态變量和靜态代碼塊的執行順序由編碼順序決定。

規律就是,靜态先于非靜态,父類先于子類,構造方法在最後。嗯給我背三遍

(2)Mian方法中輸出子類的sonA屬性

public class Main {
    public static void main(String[] args) {
        System.out.println(Son.sonA);
    }
}      

這裡隻要輸出子類的靜态屬性sonA,是以需要初始化子類,但父類還沒有被初始化,是以先初始化父類。一般而言,靜态代碼塊會對靜态變量進行指派,是以調用靜态屬性,在此之前虛拟機會調用靜态代碼塊。是以,輸出如下:

【JVM】類的奇幻漂流——類加載機制探秘

(3)Main方法輸出子類繼承而來的fatherA屬性

public class Main {
    public static void main(String[] args) {
        System.out.println(Son.fatherA);
    }
}      

子類從父類繼承而來的屬性,是以這裡屬于被動使用。隻會執行靜态屬性真正存在的那個類的初始化,即隻會初始化父類。是以,輸出:

【JVM】類的奇幻漂流——類加載機制探秘

(4)Main方法中聲明并建立一個子類類型的數組

public class Main {
    public static void main(String[] args) {
       Son[] sons=new Son[10];
    }
}      

顯然,這屬于被動使用,不會初始化Son類。是以,沒有任何輸出。

(5)Main方法輸出子類被static final修飾的變量

public class Main {
    public static void main(String[] args) {
        System.out.println(Son.sonB);
    }
}      

顯然,被static final修改的變量,也就是一個常量,在編譯器就放入類的常量池中了,不需要初始化類。是以,這裡隻輸出sonB的值,即為4。

(6)在聲明前使用一個靜态變量

public class Main {
    static {
        c = 1;
    }

    public static int c;
}      

這樣的代碼,是可以運作的,小朋友,你是不是有大大的疑問?但容我自仔細分析來。

首先,在準備階段,為靜态變量c配置設定記憶體,然後賦予初始值0。等到初始化階段,執行類的靜态域,也就是執行此處的靜态代碼塊中c=1,c此時已經存在,也有了一個預設值0,此時可以修改c的值。

但是,如果我僅僅在c=1後使用c的話,如:

public class Main {
    static {
        c = 1;
        System.out.println(c);
    }

    public static int c;
}      

此時編譯沒法通過,編輯器提示Illegal forward reference,即非法前向引用,似乎隻能寫入c,不能讀取c。我們之前已經分析過了,此時在記憶體中是有這個c的,那為什麼不能讀取c?