首頁>技術>

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

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

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

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可以輸出以下資訊:

以上標紅處,就是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();    }}

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

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

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

這裡的靜態域包括靜態變數與靜態程式碼塊,靜態變數和靜態程式碼塊的執行順序由編碼順序決定。

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

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

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

這裡只要輸出子類的靜態屬性sonA,因此需要初始化子類,但父類還沒有被初始化,因此先初始化父類。一般而言,靜態程式碼塊會對靜態變數進行賦值,因此呼叫靜態屬性,在此之前虛擬機器會呼叫靜態程式碼塊。所以,輸出如下:

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

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

子類從父類繼承而來的屬性,因此這裡屬於被動使用。只會執行靜態屬性真正存在的那個類的初始化,即只會初始化父類。因此,輸出:

(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?

本來在正常的情況下,要想使用一個變數,變數首先需要宣告出來。當然,java做出了一種特許,允許在使用前不先宣告,但必須要滿足幾個條件,其中有一個條件是該變數只能出現在賦值表示式的左邊,即c=1可以,c=2可以,c+=1不可以(c+=1也就是c=c+1,違反了左值協定)。當然如果這裡使用全限定名,也就是輸出Main.c時,則可以正常執行。

有的小夥伴可能還是有大大的疑問,不要緊,沒看懂的可以參考以下講解非法前向引用的文章

java報錯非法的前向引用問題

Java編譯時提示非法向前引用

Illegal forward Reference java issue

關於載入使用到的類載入器,雙親委派機制,如何自定義類載入器,可能需要另開篇幅。

12
  • BSA-TRITC(10mg/ml) TRITC-BSA 牛血清白蛋白改性標記羅丹明
  • scikit-learn中的自動模型選擇和複合特徵空間