1. 程式人生 > >九、JAVA多執行緒:類的載入過程

九、JAVA多執行緒:類的載入過程

 

         ClassLoader的主要職責就是負責載入各種class檔案到jvm中,ClassLoader是一個抽象的class,給定一個class的二進位制檔名,ClassLoader會嘗試載入並且在JVM中生成構成這個類的各個資料結構,然後使其分佈在JVM對應的記憶體區域中.

 

類的載入過程簡介

分為三個比較大的階段,分別是載入階段,連線階段和初始化階段.

 

1️⃣、載入階段:主要負責查詢並且載入類的二進位制資料檔案,其實就是class檔案

2️⃣、連線階段(三部分):

     1.驗證: 主要是確保類檔案的正確性,比如class版本,class檔案的魔術因子是否正確.

     2.準備: 為類的靜態變數分配記憶體,並且為其初始化預設值.

     2.解析: 把類中的符號引用轉換為直接引用

3️⃣、初始化階段: 為類的靜態變數賦予正確的初始值(程式碼編寫階段給定的值).

      JVM對類的初始化是一個延遲的機制,即使用的是lazy的方式,當一個類在首次使用的時候才會被初始化,在同一個執行時包下,一個Class只會被初始化一次,在同一個執行包下,一個Class只會被初始化一次.

 

類的主動使用和被動使用

JVM虛擬機器規範規定了,每個類或者介面被Java程式首次主動使用的時候,才會對其進行初始化。

隨著JIT技術越來越成熟,JVM執行期間的編譯也越來越智慧,不排除JVM在執行期間提前預判並且初始化某個類。

6種主動使用類的場景:

    1.通過new關鍵字會導致類的初始化(最常用)

 


    2.訪問類的靜態變數,包括讀取和更新會導致類的初始化

 


    3.訪問類的靜態方法,會導致初始化

 


    4.對某個類進行反射操作,也會導致類的初始化

 


    5.初始化子類會導致父類的初始化 , 通過子類使用父類的靜態變數只會導致父類的初始化,子類不會被初始化。

public class AcitveLoadTest {

    public static void main(String[] args) {
        System.out.println(Child.y);
    }

}



class Parent {
    public static int y = 10 ;
    static {

        System.out.println("The parent is initialized ....");

    }
}

class Child extends Parent {

    static {

        System.out.println("The child is initialized ....");

    }

    public static int x = 10 ;
}

 

 

 

    6.啟動類: 執行main函式所在的類會導致該類的初始化


兩種被動使用類的場景:

     1.構造某個類的陣列時並不會導致該類的初始化


     2.引用類的靜態常量不會導致類的初始化

 

類的載入過程詳解

思考 程式碼輸出結果:

ublic class Singleton {

    private static Singleton singleton = new Singleton();

    private static int x = 0 ;

    private static  int y ;



    private  Singleton(){
        x++ ;
        y++ ;
    }

    public static Singleton getInstance(){
        return singleton;
    }

    public static void main(String[] args) {
        Singleton singleton = Singleton.getInstance();
        System.out.println(singleton.x);
        System.out.println(singleton.y);
    }
}

 

 

程式碼輸出結果:

0

1

類載入階段

       類載入器將class檔案中的二進位制資料讀取到記憶體之中,將該位元組流鎖代表的靜態儲存結構轉換為方法區中執行時的資料結構

並在堆記憶體中生成一個該類的java.lang.Class物件,作為訪問方法區資料結構的入口。

 

 

類的載入同一個全限定名:通過包名+類名來獲取二進位制流

除此之外:

              執行時動態生成 (代理: java.lang.Proxy)

              通過網路獲取 

              通過讀取zip檔案獲取類的二進位制位元組流,比如jar,war

             將類的二進位制資料儲存在資料庫的BLOB欄位型別

             執行時生成class檔案,並且動態載入

        這裡所說的載入是指類載入過程還在弄的第一個階段,並不代表著整個類已經載入完成了,在某個類完成載入階段之後。

虛擬機器會將這些二進位制位元組流按照虛擬機器所需要的格式儲存在方法區中,然後按照特定的資料結構,

隨之在堆記憶體中例項化一個java.lang.Class物件,在類載入的整個生命週期中,載入過程還沒有結束

連線階段是可以交叉工作的,比如連線階段驗證位元組流資訊的合法性,

但是總體來講,載入階段肯定是出現在連線階段之前。

 

類的連線階段

1.驗證

       確保class檔案的位元組流鎖包含的內容符合當前的jvm的規範要求,並且不會出現危害jvm自身安全的程式碼,當位元組流的資訊不符合要求時,則會丟擲VerifyError這樣的一異常或者子異常

 

(1)驗證檔案格式:

      驗證檔案頭部的魔術因子,該因子決定了這個檔案到底是什麼型別,class檔案的魔術因子是0XCAFEBFBE.

      主次版本號,檢視當前的class檔案版本是否符合jdk所處理的範圍.

      構成檔案的位元組流是否存在殘缺或者其他附加資訊,主要檢視class的MD5指紋.

     常量池中的常量是否存在不被支援的變數型別,比如int64

     指向常量中的引用收付知道了不存在的常量或者該常量的型別不被支援 .

 (2)元資料驗證

元資料的驗證其實是對class的位元組流進行語義分析的過程,整個語義分析就是為了確保class位元組流符合JVM規範的要求.

    1.檢查這個類是否存在父類,是否繼承了某個介面,這些父類和介面是否合法,或者是否真實存在.

    2.檢查改類收付繼承了被final修飾的類,被final修飾的類是不允許繼承並且期中的方法是不允許被override的

    3.檢查該類是否為抽象類,如果不是抽象類,是否實現了父類的抽象方法或者介面中的所有方法

    4.檢查方法過載的合法性,比如相同的方法名稱,相同的引數,但是返回型別不同,這都是不被允許的

    5.其他語義驗證

(3)位元組碼驗證

       主要驗證程式的控制流程、比如迴圈、分支

       1.保證當前執行緒在程式計數器中的指令不會跳轉到不合法的位元組碼指令當中。

       2.保證類的轉換是合法的,A宣告的引用,不能使用B進行強制型別轉換。

       3.任意時刻,虛擬機器佔中的操作棧型別與指令程式碼都能正確的被執行,比如壓棧的時候,傳入的是A型別的引用,使用的時候卻將B型別載入本地變量表。

 (4).符號引用驗證

            主要作用就是驗證符號引用轉換為直接引用時的合法性

            通過符號引用描述的字串全限定名稱是否能夠順利的找到相關的類

           符號引用中的類.欄位,方法,收付對當前類可見,比如不能訪問引用類的私有方法

           其他驗證

           符號引用的驗證目的是為了保證解析動作的順利進行,比如,如果某個類的欄位不存在,則會丟擲NosuchfieldError,若該方法不存在時,則丟擲nosuchmethoderroe等,我們在使用反射的時候也會遇到這樣的異常資訊.
 

準備

       為物件的類變數也是就是靜態變數,分配記憶體並且設定初始值了,類變數的記憶體會被分配到方法區內,不同例項變數會分配到堆記憶體中

        所謂初始值,其實就是為相應的類變數給定一個相關型別在沒有被設定值時的預設值.

 

 

解析

所謂解析就是在常量池中尋找類,介面,欄位和方法的符號引用,並且將這些符號引用替換成直接引用的過程.

          虛擬機器規範並沒有規定解析階段發生的具體時間,只要求了在執行anewarry、checkcast、getfield、instanceof、invokeinterface、invokespecial、invokestatic、invokevirtual、multianewarray、new、putfield和putstatic這13個用於操作符號引用的位元組碼指令之前,先對它們使用的符號引用進行解析,所以虛擬機器實現會根據需要來判斷,到底是在類被載入器載入時就對常量池中的符號引用進行解析,還是等到一個符號引用將要被使用前才去解析它。

解析的動作主要針對類或介面、欄位、類方法、介面方法四類符號引用進行。分別對應編譯後常量池內的CONSTANT_Class_Info、CONSTANT_Fieldref_Info、CONSTANT_Methodef_Info、CONSTANT_InterfaceMethoder_Info四種常量型別。

          1.類、介面的解析          CONSTANT_Class_Info

          2.欄位解析          CONSTANT_Fieldref_Info

 

          3.類方法解析          CONSTANT_Methodef_Info

          4.介面方法解析          CONSTANT_InterfaceMethoder_Info

 

類的初始化階段:

       class initialize:

 

類的初始化階段是類載入過程的最後一步,在準備階段,類變數已賦過一次系統要求的初始值,而在初始化階段,則是根據程式設計師通過程式制定的主觀計劃去初始化類變數和其他資源,或者可以從另外一個角度來表達:初始化階段是執行類構造器

 

類初始化是類載入過程的最後一個階段,到初始化階段,才真正開始執行類中的 Java 程式程式碼。虛擬機器規範嚴格規定了有且只有四種情況必須立即對類進行初始化:

  • 遇到 new、getstatic、putstatic、invokestatic 這四條位元組碼指令時,如果類還沒有進行過初始化,則需要先觸發其初始化。生成這四條指令最常見的 Java 程式碼場景是:使用 new 關鍵字例項化物件時、讀取或設定一個類的靜態欄位(static)時(被 static 修飾又被 final 修飾的,已在編譯期把結果放入常量池的靜態欄位除外)、以及呼叫一個類的靜態方法時。
  • 使用 Java.lang.refect 包的方法對類進行反射呼叫時,如果類還沒有進行過初始化,則需要先觸發其初始化。
  • 當初始化一個類的時候,如果發現其父類還沒有進行初始化,則需要先觸發其父類的初始化。
  • 當虛擬機器啟動時,使用者需要指定一個要執行的主類,虛擬機器會先執行該主類。

虛擬機器規定只有這四種情況才會觸發類的初始化,稱為對一個類進行主動引用,除此之外所有引用類的方式都不會觸發其初始化,稱為被動引用。下面舉一些例子來說明被動引用。

通過子類引用父類中的靜態欄位,這時對子類的引用為被動引用,因此不會初始化子類,只會初始化父類:

class Father{  
    public static int m = 33;  
    static{  
        System.out.println("父類被初始化");  
    }  
}  

class Child extends Father{  
    static{  
        System.out.println("子類被初始化");  
    }  
}  

public class StaticTest{  
    public static void main(String[] args){  
        System.out.println(Child.m);  
    }  
}  

執行後輸出的結果如下:

父類被初始化
    33

對於靜態欄位,只有直接定義這個欄位的類才會被初始化,因此,通過其子類來引用父類中定義的靜態欄位,只會觸發父類的初始化而不會觸發子類的初始化。

常量在編譯階段會存入呼叫它的類的常量池中,本質上沒有直接引用到定義該常量的類,因此不會觸發定義常量的類的初始化:

class Const{  
    public static final String NAME = "我是常量";  
    static{  
        System.out.println("初始化Const類");  
    }  
}  

public class FinalTest{  
    public static void main(String[] args){  
        System.out.println(Const.NAME);  
    }  
}  

執行後輸出的結果如下:

我是常量

雖然程式中引用了 const 類的常量 NAME,但是在編譯階段將此常量的值“我是常量”儲存到了呼叫它的類 FinalTest 的常量池中,對常量 Const.NAME 的引用實際上轉化為了 FinalTest 類對自身常量池的引用。也就是說,實際上 FinalTest 的 Class 檔案之中並沒有 Const 類的符號引用入口,這兩個類在編譯成 Class 檔案後就不存在任何聯絡了。

通過陣列定義來引用類,不會觸發類的初始化:

class Const{  
    static{  
        System.out.println("初始化Const類");  
    }  
}  

public class ArrayTest{  
    public static void main(String[] args){  
        Const[] con = new Const[5];  
    }  
}  

執行後不輸出任何資訊,說明 Const 類並沒有被初始化。

但這段程式碼裡觸發了另一個名為“LLConst”的類的初始化,它是一個由虛擬機器自動生成的、直接繼承於java.lang.Object 的子類,建立動作由位元組碼指令 newarray 觸發,很明顯,這是一個對陣列引用型別的初初始化,而該陣列中的元素僅僅包含一個對 Const 類的引用,並沒有對其進行初始化。如果我們加入對 con 陣列中各個 Const 類元素的例項化程式碼,便會觸發 Const 類的初始化,如下:

class Const{  
    static{  
        System.out.println("初始化Const類");  
    }  
}  

public class ArrayTest{  
    public static void main(String[] args){  
        Const[] con = new Const[5];  
        for(Const a:con)  
            a = new Const();  
    }  
}  

這樣便會得到如下輸出結果:

初始化Const類

根據四條規則的第一條,這裡的 new 觸發了 Const 類。

最後看一下介面的初始化過程與類初始化過程的不同。

介面也有初始化過程,上面的程式碼中我們都是用靜態語句塊來輸出初始化資訊的,而在介面中不能使用“static{}”語句塊,但編譯器仍然會為介面生成類構造器,用於初始化介面中定義的成員變數(實際上是 static final 修飾的全域性常量)。

二者在初始化時最主要的區別是:當一個類在初始化時,要求其父類全部已經初始化過了,但是一個介面在初始化時,並不要求其父介面全部都完成了初始化,只有在真正使用到父介面的時候(如引用介面中定義的常量),才會初始化該父介面。這點也與類初始化的情況很不同,回過頭來看第 2 個例子就知道,呼叫類中的 static final 常量時並不會 觸發該類的初始化,但是呼叫介面中的 static final 常量時便會觸發該介面的初始化。

 

 

 

本文來源於:

《Java高併發程式設計詳解:多執行緒與架構設計》 --汪文君