類加載器看這篇就夠了

面試問類加載器?看這篇就夠了

思考

咱們平時寫的代碼或程序究竟是如何運行起來的呢? 好比我開發用的是 java 語言,源碼是是 .java 的文件,但他們是沒有辦法運行的。一般咱們會打成 jar 包,而後部署到服務器上,其實咱們所說的打包就是編譯,即把 java 文件編譯成 .class 字節碼文件,那如何執行這些 .class 字節碼文件呢? 經過 java -jar 命令來執行這些 .class 文件。其實 java -jar 命令啓動了一個 jvm 進程,由 jvm 進程來運行這些字節碼文件java

概述

jvm 如何加載這些 class 文件呢?

上面咱們說 jvm 會運行這些 .class 字節碼文件,但他們是怎麼加載進來的 呢?面試

固然是經過類加載器了,類加載器加載 .class 文件的流程爲安全

加載->驗證->準備->解析->初始化服務器

類加載過程

下面咱們就分析下加載的總體流程,但在分析整個流程前,先介紹下類加載的條件數據結構

類加載條件

通常咱們的一個程序中會有不少 class 文件,那 jvm 會無條件加載這些文件嗎?jvm

確定不是的,其實 jvm 只有在**「使用」該 class 文件時纔會加載,這裏的「使用」主動使用**,主動使用只有下列幾種狀況:ide

1.當建立一個類的實例時,好比使用 new 關鍵字或者反射、克隆、反序列化優化

2.當調用類的靜態方法時,即便用字節碼 invodestatic 指令spa

3.當使用類或接口的靜態字段時(final 常量除外),好比使用 getstatic 或者 putstatic 指令線程

4.當使用 java.lang.reflect 包中的方法反射類的方法時

5.當初始化子類時,要求先初始化父類

6.做爲啓動虛擬機,含有 main() 方法的那個類

除上面列出的 6 點爲主動使用外,其餘都是被動使用

主動使用的例子

public class Parent {
    static {
        System.out.println("Parent init");
    }
}

public class Child extends Parent {
    static {
        System.out.println("Child init");
    }
}

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

若是 Parent 類被初始化,會打印 「Parent init」,若是 Child 類被初始化,會打印"Child init",經過執行 Main 類中的 main 方法,來初始化 Child 類,發現打印以下:

Parent init Child init

經過打印結果,咱們能夠驗證主動使用 class 文件的兩個條件,1 和 5 是成立的

其餘主動使用的狀況就不舉例子了,下面咱們來看下被動使用的例子

被動使用的例子

public class Parent {
    public static int v = 60;
    static {
        System.out.println("Parent init");
    }
}

public class Child extends Parent {
    static {
        System.out.println("Child init");
    }
}

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

此次是在 Parent 類中增長了一個靜態變量 v,但 Child 類中沒有增長,而後在 Main 類中訪問 Child.v,這種狀況會加載 Parent 類嗎?會加載 Child 類嗎?

輸出結果以下:

Parent init 60

可見,只加載了 Parent 類,並無加載 Child 類,值得注意,這裏的「加載」指完成整個加載的過程,其實此時 Child 類也被加載了(這裏的加載指整個加載過程的第一步加載,能夠經過加上 -XX:TraceClassLoading 參數來驗證),但沒有進行初始化。

加上 -XX:TraceClassLoading 後的輸出結果

[Loaded jvm.loadclass.Parent from file:/D:/workspace/study/study_demo/target/classes/] [Loaded jvm.loadclass.Child from file:/D:/workspace/study/study_demo/target/classes/] Parent init 60

因此在使用一個字段時,只有直接定義該字段的類纔會被初始化

在主動使用的第 3 點,很明確的指出,使用類的 final 常量不屬於主動使用,也就不會加載對應的類,咱們經過代碼驗證下

public class ConstantClass {
    public static final String CONSTANT = "constant";
    static {
        System.out.println("ConstantClass init");
    }
}

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

輸出結果以下:

[Loaded jvm.loadclass.Main from file:/D:/workspace/study/study_demo/target/classes/]

constant

經過結果,確實驗證了 final 常量不會引發類的初始化,由於在編譯階段對常量作了優化(學名是「常量傳播優化」),把常量值 "constant"直接存放到了 Main 類的常量池中,因此不會加載 ConstantClass 類

加載

加載是類加載過程的第一個階段,在加載階段,jvm 須要完成以下工做:

1.經過類的全限定類名獲取類的二進制數據流

2.解析類的二進制數據流爲方法區內的數據結構

3.建立 java.lang.Class 類的實例,表示該類型

獲取類的二進制數據流的方式有不少,好比直接讀入 .class 文件,或者從 jar 、zip、war等歸檔數據包中提取 .class 文件,而後 jvm 處理這些二進制數據流並生成一個 java.lang.Class 的實例,該實例是訪問類型元數據的接口,也是實現反射的關鍵數據

驗證

驗證階段是爲了保證加載的字節碼是符合jvm規範的,大致分爲格式檢查、語義檢查、字節碼檢驗證、符號引用驗證,以下所示:

驗證

準備

準備階段主要就是爲類分配相應的內存空間,並設置初始值,經常使用的初始值以下表所示:

數據類型 默認初始值
int 0
long 0L
short (short)0
char '\u0000'
boolean fasle
float 0.0f
double 0.0d
reference null

若是類中定義了常量,如:

public static final String CONSTANT = "constant";

這種常量(查看字節碼文件,含有 ConstantValue 屬性)會在準備階段直接存到常量池中

public static final java.lang.String CONSTANT;
    descriptor: Ljava/lang/String;
    flags: (0x0019) ACC_PUBLIC, ACC_STATIC, ACC_FINAL
    ConstantValue: String constant

解析

解析階段主要把類、接口、字段和方法的符號引用轉爲直接引用

符號引用:符號引用是以一組符號來描述所引用的目標,符號能夠是任何形式的字面量,只要使用時能夠無歧義地定位到目標便可

直接引用:直接引用是能夠直接指向目標的指針、相對偏移量或是一個能間接定位到目標的句柄

解析階段主要針對類或接口、字段、類方法、接口方法、方法類型、方法句柄和調用點限定符

下面咱們經過一個例子來簡單解釋下

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

查看 main 方法中 System.out.println() 方法對應的字節碼

3: invokevirtual #3                  // Method java/io/PrintStream.println:()V

常量池第 3 項被使用,那咱們去看常量池中第 3 項的內容,以下:

#3 = Methodref          #17.#18        // java/io/PrintStream.println:()V

看來還要繼續查找引用關係,第 17 項和第 18 項,以下:

#17 = Class              #24            // java/io/PrintStream
#18 = NameAndType        #25:#7         // println:()V

其中第 17 項又引用到了第 24 項,第 18 項又引用了 第 25 和 7 項,分別以下:

#24 = Utf8               java/io/PrintStream
#25 = Utf8               println
#7 = Utf8               ()V

咱們在一張圖中表示上面的引用關係關係,以下所示: 符號引用

其實上面的引用關係就是符號引用

但在程序運行時,光有符號引用是不夠的,系統須要明確知道該方法的位置,因此 jvm 爲每一個類準備了一張方法表,將其全部的方法都列入到了方法表中,當須要調用一個類的方法時,只要知道這個方法在方法表中的偏移量就能夠直接調用了。經過解析操做,符號引用能夠轉變爲目標方法在類方法表中的位置,使得方法被成功調用。

初始化

初始化是類加載的最後一個階段,只要前面的階段都沒有問題,就會進入到初始化階段。那初始化階段作什麼工做呢?

主要就是執行類的初始化方法<clinit>(該初始化方法由編譯器自動生成),它是由類靜態成員變量的賦值語句及 static 語句塊共同產生的。這個階段纔是執行真正的賦值操做。準備階段只是分配了相應的內存空間,並設置了初始值。

下面咱們經過一個小例子來驗證下

public class StaticParent {
    public static int id = 1;
    public static int num ;
    static {
        num = 4;
    }
}

對應的部分字節碼文件以下所示:

#13 = Utf8               <clinit>
static {};
    descriptor: ()V
    flags: (0x0008) ACC_STATIC
    Code:
      stack=1, locals=0, args_size=0
         0: iconst_1
         1: putstatic     #2                  // Field id:I
         4: iconst_4
         5: putstatic     #3                  // Field num:I
         8: return

能夠看到在 <clinit>方法中,對類中的 static 變量 id 和 static語句塊中的 num 進行了賦值操做

那編譯器會爲全部的類都生成<clinit>方法嗎?答案是否認的,若是一個類既沒有賦值語句,又沒有 static 語句塊,這樣即便生成了 <clinit>方法,也是無事可作,因此編譯器就不插入了。咱們經過一個例子看下對應的字節碼

public class StaticFinalParent {
    public static final int a = 1;
    public static final int b = 2;
}
public jvm.loadclass.StaticFinalParent();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return

從字節碼中沒有發現 <clinit> 方法,由於咱們前面說過,final 類型的常量是在準備階段完成的初始化,因此在初始化階段就不用再初始化了。

注意點

這裏指的注意的一點是,jvm 會保證方法 <clinit> 的安全性,由於可能存在多個線程同時去初始化類,這樣要保證只有一個線程執行 <clinit>方法,而其餘線程要等待,只要有線程初始化類成功,其餘線程就不用再次進行初始化了

小總結

經過上面的介紹,我想你們應該瞭解了咱們平時寫的代碼,最後究竟是如何運行起來的了吧,總之一句話就是咱們編寫的 java 文件,會被編譯成 class 字節碼文件,而後由 jvm 把主動使用的類加載到內存中,而後開始執行這些程序。很重要的階段就是加載類即從外部系統得到 class 文件的二進制流,而在該階段起着決定性做用的就是下面要介紹的 類加載器

類加載器

ClassLoader 表明類加載器,是 java 的核心組件,能夠說全部的 class 文件都是由類加載器從外部讀入系統,而後交由 jvm 進行後續的鏈接、初始化等操做。

分類

jvm 會建立三種類加載器,分別爲啓動類加載器、擴展類加載器和應用類加載器,下面咱們分別簡單介紹下各個類加載器

啓動類加載器

Bootstrap ClassLoader 主要負責加載系統的核心類,如 rt.jar 中的 java 類,咱們在 Linux 系統或 Windows 系統使用 java,都會安裝 jdk,lib 目錄裏其實裏面就有這些核心類

擴展類加載器

Extension ClassLoader 主要用於加載 lib\ext 中的 java 類,這些類會支持系統的運行

應用類加載器

Application ClassLoader 主要加載用戶類,即加載用戶類路徑(ClassPath)上指定的類庫,通常都是咱們本身寫的代碼

雙親委派模型

在類加載時,系統會判斷當前類是否已經加載,若是已經加載了,就直接返回可用的類,不然就會嘗試去加載這個類。在嘗試加載類時,會先委派給其父加載器加載,最終傳到頂層的加載器加載。若是父類加載器在本身的負責的範圍內沒有找到這個類,就會下推給子類加載器加載。加載狀況以下所示:

雙親委派模型

可見檢查類是否加載的委派過程是單向的,底層的類加載器詢問了半天,到最後仍是本身加載類,那不白費力氣了嗎?這樣作固然有它的好的,這樣在結構上比較清晰,最重要的是能夠避免多層級的加載器重複加載某些類

雙親委派模型的弊端

雙親委派模型檢查類加載是單向的,但這樣也有個弊端就是上層的類加載器沒法訪問由下層類加載器所加載的類。那若是啓動類加載器加載的系統類中提供了一個接口,接口須要在應用中實現,還綁定了一個工廠方法,用於建立該接口的實例。而接口和工廠方法都在啓動類加載器中。這時就會出現該工廠沒法建立由應用類加載器加載的應用實例的問題。好比 JDBC、XML Parser 等

jvm 這麼厲害,確定會有辦法解決這種問題的,沒錯,java 中經過 SPI(Service Provider Interface)機制解來解決這類問題

總結

本文主要介紹了 jvm 的類加載機制,包括類加載的全過程和每一個階段作的一些事情。而後介紹了類加載器的工做機制和雙親委派模型。更輸入的知識點,但願你本身去繼續研究,好比 OSGI 機制,熱替換和熱部署如何實現等

參考資料

1.《實戰 Java 虛擬機》

2.《深刻理解Java虛擬機》

3.《從0開始帶你成爲JVM實戰高手》,公衆號回覆「jvm」可查看資料

歡迎關注公衆號 【天天曬白牙】,獲取最新文章,咱們一塊兒交流,共同進步!

相關文章
相關標籤/搜索