詳解JVM類加載的三個階段

虛擬機把描述類的數據從Class文件加載到內存,並對數據進行校驗、轉換解析和初始化,最終形成可以被虛擬機直接使用的Java類型,這一過程就是虛擬機類加載機制。

 java需要使用一個類時,並不是一蹴而就的,需要在後臺進行一些必須的步驟。如下圖所示,類在我們使用前,共經歷了“加載”、“鏈接”“初始化”三個階段,而第二個階段“鏈接”又被分爲三個小階段,分別是 “驗證”、“準備”和“解析”。下文中我們逐一分析,來一起探究java在類加載的過程中,都具體做了些什麼,類在使用前都執行了哪些操作。

  

1.類加載的過程

加載:通常情況下,我們所提到的加載是類加載機制的三個階段的總稱,而這裏的加載指的是類加載機制中的第一階段。在這個階段,虛擬機需要完成以下三件事:

  1. 通過一個類的全限定名來獲取定義此類的二進制字節流。

  2. 將這個字節流所代表的靜態存儲結構轉化爲方法區的運行時數據結構。

  3. 在內存中共生成一個代表這個類的java.lang.Class對象,作爲方法區這個類的各種數據結構的訪問入口。

這裏需要注意的是,虛擬機規範並沒有嚴格的規定從哪裏,以怎樣的形式獲取字節流。也就是說,只要最後獲取到的二進制字節流是符合JVM規範的,就都是合法的。用戶可以通過自定義類加載器,重寫ClassLoader類中的findClass()方法,來自定義獲取字節流的方式,就可以實現個性化的類加載方案。

 

驗證:驗證是鏈接階段的第一步,這一階段的目的是爲了確保Class文件的字節流中包含的信息符合當前虛擬機的要求,並且不會危害虛擬機自身的安全。
    這個階段在正常開發中貌似不怎麼會出問題,但在安裝了多個版本jdk 的機器上,就可能會遇到下面這個坑。用高版本的jdk編譯,卻用低版本的jdk運行了字節碼文件,程序就會出現Unsupported major.minor version xx.x錯誤。因爲在編譯時,jdk的版本號會被寫在字節碼文件中,當這個類被加載,並執行到驗證階段時,jvm發現了不兼容的jdk版本號,就會報以上錯誤。

 

準備:準備階段是正式爲靜態變量分配內存並設置靜態變量初始值的階段,這些變量所使用的內存都將在方法區中進行分配。

 假設在程序中有這樣的代碼

static int value = 123;
static String value2 = "Hello world";

那麼,在執行到準備階段,value的最初值將被設定爲0,value2的初始值爲null。這裏初始值指的是java爲所有的類型定義的默認值,而不是用戶自定義賦予的初值。在java中,引用數據類型的默認值都爲null,而基本數據類型的默認值參考下表。所以在這裏value的初始值爲0,而不是代碼所示的123,只有這個類在經過初始化階段之後,value變量纔會被賦值爲123。

 

解析:解析階段就是虛擬機將常量池內的符號引用替換爲直接引用的過程。

這句話可能不太好理解。簡單來說,假如我有A和B兩個類,B類在com.jk包下。那麼,A類就可以通過 com.jk.B 來引用B類。這個時候 com.jk.B 就可以被認爲是B類的符號引用。

符號引用可以是任意一個字符串,它給出了被引用的內容的名字並且可能會包含一些其他關於這個被引用項的信息——這些信息必須足以唯一的識別一個類、字段、方法。

在解析階段,符號引用會被替換爲直接引用。直接引用了可以是直接指向目標的指針,相對偏移量或者是一個可以間接定位到目標的句柄。所以jvm也必須保證被引用者在引用者加載前就已經加載完畢。

 

初始化:類的初始化是類加載的最後一步,在類的初始化階段完成的是對類的靜態變量的賦值以及靜態代碼塊的執行。

    編譯器會自動收集類中的所有靜態變量的賦值動作和靜態初始化塊中的語句合併產生一個 <clinit>() 方法,虛擬機在初始化一個類的時候,執行的正是<clinit>() 方法中的指令。

    編譯器收集的順序是由語句在源文件中出現的順序決定的,靜態初始化塊中只能訪問到定義在靜態語句塊之前的變量,定義在它之後的變量靜態語句塊中可以賦值但是不能訪問。如果類中沒有靜態語句或者靜態代碼塊,則不生成此方法。

如有以下代碼:

public class Test {
    static int a = 1;
    static {
        a = 2;
    }
    static int b = 3;
    int c = 4;
    
    public Test(){
      a = 123;
    } 
}

編譯後,我們通過 javap -v命令來看看,編譯器爲我們生成的<clinit>()方法中,都執行了些什麼。

iconst_1,iconst_2,iconst_3指令分別代表着 把整型數字1,2,3壓入棧中。putstatic指令作用是取出棧頂的值,並設置給類中靜態字段。#2和#3指向的是常量池中存放的類中的靜態字段,查詢常量池,發現,#2,#3代表的正是變量a和b。

 

2.類加載各階段的執行時機

類的加載是按照  加載,驗證,準備,解析的順序開始的。解析階段不一定,他在某些情況下在初始化之後開始。

加載時機:

    java虛擬機並沒有強制約束,這就代表了類的加載時機交給虛擬機的具體實現來把握。 

    jvm規範允許類加載器在預料到麼某個類將要被使用時就預先加載他,如果在預加載時遇到了。class文件缺失的情況,類加載器會在程序首次主動使用該類的時候才報告錯誤。

初始化時機:

    在該類或接口首次被主動調用時,初始化他們。在類的初始化之前,就必須會先執行加載,驗證,準備的工作(解析不一定)。

 

3.主動調用和被動調用

 類在被首次主動調用時,會被初始化。主動調用被分爲以下幾種情況。

  1. new了一個類的實例

  2. 調用了類的非final的靜態變量或靜態方法

  3. 通過反射對該類進行了調用

  4. 初始化一個類的子類時,父類會在子類初始化之前進行初始化。

    注:當一個接口初始化時,不要求其父接口也初始化。
  5. 當該類是啓動類(包含main方法的類)時。

  6. 使用jdk1.7的動態語言支持時

有且僅有上訴這六種情況會被視爲主動調用。除此之外,所有引用類的方式都不會觸發其初始化,這被稱爲被動調用。

下面是被動調用的幾種情況。

1.通過子類引用父類中的靜態字段

class Father{  
    static int count = 1;  
    static{  
        System.out.println("Initialize class Father");  
    }  
}  
​
class Son extends Father{  
    static{  
        System.out.println("Initialize class Son");  
    }  
}  
  
public class Test {  
    public static void main(String[] args) {  
        int x = Son.count;  
    }  
}

 輸出結果發現,父類輸出了靜態代碼塊的內容,而子類沒有輸出。通過子類引用父類中的靜態字段,不會觸發子類的初始化。

 

2.通過數組定義引用類

class E{
    static{
        System.out.println("Initialize class E");
    }
}
​
public class Test {
    public static void main(String[] args) {
        E[] es = new E[10];
    }
}

程序運行後,沒有任何輸出。    

    在運行期,jvm會爲數組動態生成一個數組的class對象。如果是一維數組,則爲:[L+元素的類全名;二維數組,則爲[[L+元素的類全名如果是基礎類型(int/float等),則爲[I(int類型)、[F(float類型)等。這些與所引用的對象無關,故不會觸發其類的初始化。

 

3.引用了某個類的final修飾的常量

class F{  
    static final int count = 1;  
    static{  
        System.out.println("Initialize class F");  
    }  
}  
  
public class Test {  
    public static void main(String[] args) {  
        int x = F.count;  
    }  
}

程序運行後,依然沒有任何輸出。

常量在編譯時就會被存放在調用常量的方法所在類的常量池中。所以就不會觸發常量所在類的初始化。

常量又分爲運行時常量和編譯期常量,只有編譯期常量纔會被存放在調用類的class文件中的靜態常量池中。運行時常量會放在運行時常量池中,調用運行時常量,依然會觸發其所屬類的初始化。

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