虛擬機類加載機制

虛擬機把字節碼文件從磁盤加載進內存的這個過程,咱們能夠粗糙的稱之爲「類加載」,由於「類加載」不只僅是讀取一段字節碼文件那麼簡單,虛擬機還要進行必要的「驗證」、「初始化」等操做,下文將一一敘述。java

類加載的基本流程

一個類從被加載進內存,到卸載出內存,完整的生命週期包括:加載,驗證,準備,解析,初始化,使用,卸載。如圖:git

image

這七個階段按序開始,但不意味着一個階段結束另外一個階段才能開始。也就是說,不一樣的階段每每是穿插着進行的,加載階段中可能會激活驗證的開始,而驗證階段又有可能激活準備階段的賦值操做等,但總體的開始順序是不會變的。github

具體的內容,下文將詳細描述,這裏只須要創建一個宏觀上的認識,瞭解整個類加載過程須要通過的幾個階段便可。面試

加載

「加載」和「類加載」是兩個不一樣的概念,後者包含前者,即「加載」是「類加載」的一個階段,而這個階段須要完成如下三件事:數據庫

  • 經過一個類的全限定名獲取對應於該類的二進制字節流
  • 將這個二進制字節流轉儲爲方法區的運行時數據結構
  • 於內存中生成一個 java.lang.class 類型的對象,用於表示該類的類型信息。

首先,第一個過程,讀取字節碼文件進入內存。具體如何讀取,虛擬機規範中並無明確指明。也就是說,你能夠從 ZIP 包中讀取,也能夠從網絡中獲取,還能夠動態生成,或者從數據庫中讀取等,反正最終獲得的結果同樣:字節碼文件的二進制流bootstrap

第二步,將這個內存中的二進制流從新編碼存儲,依照方法區的存儲結構進行存儲,方便後續的驗證和解析。方法區數據結構以下:數組

image

大致上的格式和咱們虛擬機規範中的 Class 文件格式是差很少的,只是這裏增長了一些項,重排了某些項的順序。安全

第三步,生成一個 java.lang.class 類型的對象。這個類型的對象建立的具體細節,咱們不得而知,可是這個對象存在於方法區之中的惟一目的就是,惟一表示了當前類的基本信息,外部全部該類的對象的建立都是要基於這個 class 對象的,由於它描述了當前類的全部信息。bash

可見,整個加載階段,後兩個步驟咱們不可控,惟一可控的是第一步,加載字節碼。具體如何加載,這部份內容,這裏不打算詳細說明,具體內容將於下文描述「類加載器」時進行說明。微信

驗證

驗證階段的目的是爲了確保加載的 Class 文件中的字節流是符合虛擬機運行要求的,不能威脅到虛擬機自身安全。

這個階段「把控」的如何,將直接決定了咱們虛擬機可否承受住惡意代碼的攻擊。整個驗證又分爲四個階段:文件格式驗證、元數據驗證、字節碼驗證,符號引用驗證

一、文件格式驗證

這個階段將於「加載」階段的第一個子階段結束後被激活,主要對已經進入內存的二進制流進行判斷,是否知足虛擬機規範中要求的 Class 文件格式。例如:

  • 魔數的值是否爲:0xCAFEBABE
  • 主次版本號是否在當前虛擬機處理範圍以內
  • 檢查常量池中的各項常量是否爲常量池所支持的類型(tag 字段是否異常取值)
  • 常量項 CONSTATNT_Utf8_info 中存儲的字面量值是否不符合 utf8 編碼標準
  • 等等等等

當經過該階段的驗證後,字節碼文件將順利的存儲爲方法區數據結構,此後的任何操做都不在基於這個字節碼文件了,都將直接操做存儲在方法區中的類數據結構。

二、元數據驗證

該階段的驗證主要針對字節碼文件所描述的語義進行驗證,驗證它是否符合 Java 語言規範的要求。例如:

  • 這個類是否有父類,Object 類除外
  • 這個類是否繼承了某個不容許被繼承的類
  • 這個類中定義的方法,字段是否存在衝突
  • 等等等等

雖然某些校驗在編譯器中已經驗證過了,這裏卻依然須要驗證的緣由是,並非全部的 Class 文件都是由編譯器產生的,也能夠根據 Class 文件格式規範,直接編寫二進制獲得。雖然這種狀況少之又少,可是不表明不存在,因此這一步的驗證的存在是頗有必要的。

三、字節碼驗證

通過「元數據驗證」以後,整個字節碼文件中定義的語義必然會符合 Java 語言規範。可是並不能保證方法內部的字節碼指令可以很好的協做,好比出現:跳轉指令跳轉到方法體以外的字節碼指令上,字節碼指令取錯操做數棧中的數據等問題

這部分的驗證比較複雜,我查了不少資料,大部分都一帶而過。整體上來講,這階段的驗證主要是對方法中的字節碼指令在運行時可能出現的一部分問題進行一個校驗。

四、符號引用驗證

這個驗證相對而言就比較簡單了,它發生在「解析」階段之中。當「解析」階段開始完成一個符號引用類型的加載以後,符號引用驗證將會被激活,針對常量池中的符號引用進行一些校驗。好比:

  • CONSANT_Class_info 所對應的類是否已經被加載進內內存了
  • 類的相關字段,方法的符號引用是否能獲得對應
  • 對類,方法,字段的訪問性是否能獲得知足
  • 等等等等

符號引用驗證經過以後,解析階段才能繼續。

總結一下,驗證階段總共分爲四個子階段,任意一個階段出現錯誤,都將拋出 java.lang.VerifyError 異常或其子類異常。固然,若是你以爲驗證階段會拖慢你的程序,jvm 提供:-Xverify:none 啓動參數關閉驗證階段,縮短虛擬機類加載時間。

準備

準備階段其實是爲類變量賦「系統初值」的過程,這裏的「系統初值」並非指經過賦值語句初始化變量的意思,基本數據類型的零值,如圖:

image

例如:

public static int num = 999;
複製代碼

準備階段以後,num 的值將會被賦值爲 0。一句話歸納,這個階段就是爲類變量賦默認值的一個過程。

可是有一個特例須要注意一下,對於常量類型變量而言,它們的字段屬性表中有一項屬性 ConstantValue 是有值的,因此這個階段會將這個值初始化給變量。例如:

public static final int num = 999;
複製代碼

準備階段以後,num 的值不是 0,而是 999。

解析

整個解析過程其實只幹了一件事情,就是將==符號引用轉換成直接引用==。原先,在咱們 Class 文件中的常量池裏面,存在兩種類型的常量,直接字面量(直接引用)和符號引用。

直接引用指向的是具體的字面量,即數字或者字符串。而符號引用存儲的是對直接引用的描述,並非指向直接的字面量。例如咱們的 CONSTANT_Class_info 中的 name_index 存儲就是對常量池的一個偏量值,而不是直接存儲的字符串的地址,也就是說,符號引用指向直接引用,而直接引用指向具體的字面量。

爲何要這樣設計,其實就是爲了共用常量項。 若是不是爲了共享常量,我也能夠定義 name_index 後連續兩個字節用來表述類的全限定名的 utf8 編碼,只不過一旦整個類中有多個重複的常量項的話,就顯得浪費內存了。

當一個類被加載進方法區以後,該類的常量池中的全部常量將會入駐方法區的運行時常量池。這是一塊對全部線程公開的內存區域,多個類之間若是有重複的常量將會被合併。直接引用會直接入駐常量池,而符號引用則須要經過解析階段來實際指向運行時常量池中的直接引用的地址。

這就是解析階段所要完成的事情,下面咱們具體看看不一樣的符號引用是如何被翻譯成直接引用的。

一、類或接口的解析

假設當前代碼所處的類是 A,在 A 中遇到一個新類型 B,也能夠理解爲 A 中存在一個 B 類型的符號引用。那麼對於 B 類型的解析過程以下:

  • 經過常量池找到 B 這個符號引用所對應的直接引用(類的全限定名的 utf8 編碼)
  • 把這個全限定名稱傳遞給虛擬機完成類加載(包括咱們完整的七個步驟)
  • 替換 B 的符號引用的值爲內存中剛加載的類或者接口的地址

固然,對於咱們的數組類型是稍有不一樣的,由於數組類型在運行時由 jvm 動態建立,因此在解析階段的第一步,jvm 須要額外去建立一個數組類型放在常量池中,其他步驟基本相同。

二、字段的解析

字段在常量池中由常量項 Fieldref 描述,解析開始時,首先會去解析它的 class_index 項,解析過程如上。若是順利將會獲得字段所屬的類 A,接下來的解析過程以下:

  • 經過字段項 nameAndType 查找 A 中是否有匹配的項,若是有則直接返回該字段的引用。
  • 若是沒有,遞歸向上搜索 A 實現的全部接口去匹配。
  • 若是仍是未能成功,向上搜索 A 的父類
  • 若依然失敗,拋出 java.lang.NoSuchFieldError 異常

這部份內容實在很抽象,不少資料都沒有明確說明,字段的符號引用最後會指向哪裏。個人理解是,常量池中的字段項會指向類文件字段表中某個字段的首地址(純屬我的理解)。

方法的符號解析的過程和字段解析過程是相似的,此處再也不贅述。

初始化

初始化階段是類加載的最後一步,在這個階段,虛擬機會調用編譯器爲類生成的 「」 方法執行對類變量的初始化語句。

和準備階段所作的事情大相徑庭,準備階段只是爲全部類變量賦系統初值,而初始化階段纔會執行咱們的程序代碼(僅限於類變量的賦值語句)。編譯器會在編譯的時候收集類中全部的靜態語句塊和靜態賦值語句合併到一個方法中,而後咱們的虛擬機在初始化階段只要調用這個方法就能夠完成對類的初始化了。

這個方法就是 「」。

例如,咱們能夠看一道經典的面試題:

class SingleTon {
    private static SingleTon singleTon = new SingleTon();
    public static int count1;
    public static int count2 = 0;
 
    private SingleTon() {
        count1++;
        count2++;
    }
 
    public static SingleTon getInstance() {
        return singleTon;
    }
}
 
public class Test {
    public static void main(String[] args) {
        SingleTon singleTon = SingleTon.getInstance();
        System.out.println("count1=" + singleTon.count1);
        System.out.println("count2=" + singleTon.count2);
    }
}
複製代碼

答案是:

count1=1

count2=0

首先,Test 類會被第一個加載,而後程序開始執行 main 方法的字節碼。

遇到 SingleTon 這個類,檢索了一下方法區,發現沒有被加載,因而開始加載 SingleTon類:

第一步,將 SingleTon 這個類的字節碼文件加載進方法區,通過文件格式驗證,這個字節碼文件順利轉儲爲方法區的數據結構

第二步,繼續進行元數據驗證,確保字節碼文件中的語義合法,接着字節碼驗證,保證方法中的字節碼指令之間不存在異常

第三步,準備階段,開始爲類變量賦系統初值,本例中 singleTon = null,count1 = 0,count2 = 0

第四步,將類常量池中的直接引用入駐方法區運行時常量池,接着解析符號引用到具體的直接引用

第五步,執行類變量的初始化語句。這裏,類變量 singleTon 會被賦值爲一個對象的引用,這個對象在建立的途中會爲類變量 count1 和 count2 加一。

到此,類變量 singleton 初始化完成,count1 = 1,count2 = 1。此時繼續初始化操做,將 count 2 = 0。

結果出來了。

最後,關於初始化還有一點須要注意一下,虛擬機保證當前類的 方法執行以前,其父類的該方法已經執行完畢,因此 Object 的 方法必定在全部類以前被執行。

類加載器

類加載的第一步就是將一個二進制字節碼文件加載進方法區內存中,而這部份內容咱們前文並無詳細說明,接下來咱們就來看看如何將一個磁盤上的字節碼文件加載進虛擬機內存中。

類加載器主要分爲四個不一樣類別

  • Bootstrap 啓動類加載器
  • Extention 擴展類加載器
  • Application 系統類加載器
  • 用戶自定義類加載器

它們之間的調用關係以下:

image

這個調用關係,官方名稱:雙親委派 。不管你使用哪一個類加載器加載一個類,它必然會向上委託,在確認上級不能加載以後,本身才會嘗試加載它。固然,沒有上級的引導類加載器除外。

通常狀況,咱們不多本身寫類加載器來加載一個類,就像咱們程序中會常用到各類各樣的類,可是用你關心它們的加載問題麼?

這些類基本都是在主類加載的解析階段被間接加載了,可是這樣的前提是,程序中有這些類型的引用,也就是說,只有程序中須要使用的類纔會被加載,你一個程序中沒有出現的類,jvm 確定不會去加載它。

若是想要自定義類加載器來加載咱們的 Class 文件,那麼至少須要繼承類 ClassLoader 類,而後外部調用它的 loadClass 方法,就能夠完成一個類型的加載了。

咱們看看這個 loadClass 的實現:

protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
        synchronized (getClassLoadingLock(name)) {
            // First, check if the class has already been loaded
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                long t0 = System.nanoTime();
                try {
                    if (parent != null) {
                        c = parent.loadClass(name, false);
                    } else {
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    // ClassNotFoundException thrown if class not found
                    // from the non-null parent class loader
                }

                if (c == null) {
                    // If still not found, then invoke findClass in order
                    // to find the class.
                    long t1 = System.nanoTime();
                    c = findClass(name);

                    // this is the defining class loader; record the stats
                    sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                    sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                    sun.misc.PerfCounter.getFindClasses().increment();
                }
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }
複製代碼

在以前的 jdk 版本中,咱們經過繼承 ClassLoader 並重寫其 loadClass 便可完成自定義的類加載器的具體實現。可是如今 jdk 1.8 已經再也不推薦這麼作了,具體咱們一點一點來看。

首先明確一點,loadClass 方法的這個參數 name 指的是待加載類的全限定名稱,例如:java.lang.String 。

而後第一步,調用方法 findLoadedClass 判斷這個類是否已經被當前的類加載器加載。若是已經被當前的類加載器加載了,那麼直接返回方法區中的該類型的 class 對象便可,不然返回 null。

若是該類未被當前類加載器加載,那麼將進入 if 的判斷體中,這段代碼即完成了「雙親委託」模型的實現。咱們具體看一看:

先拿到當前類加載器的父加載器,若是不是 null,那麼傳遞當前類給父加載器加載去,接着會遞歸進入 loadClass。若是父加載器爲 null,那麼就啓動 Bootstrap 啓動類加載器進行加載。

若是上級的類加載器在本身負責的「目錄範圍」裏,找不到傳遞過來待加載的類,那麼會拋出 ClassNotFoundException 異常,而捕獲異常後什麼也沒作,即當前調用結束。也就是說,下級類加載器請求上級類加載器加載某個類,而若是上級加載器不能加載,會致使這次調用安全結束。那麼此時的 c 必然爲 null。

這樣的話,當前類加載器就會調用 findClass 方法本身去加載該類,而這個 findClass 的實現爲空,換句話說,jdk 但願咱們經過實現這個方法來完成自定義的類型加載。

總體上來看這個 loadClass,你會發現它很巧妙的實現了「雙親委託」模型,而核心就是那段『捕獲異常而什麼都不作』的操做。

下面咱們自定義一個類加載器並加載任意一個類:

public class MyClassLoader extends ClassLoader {
	
	@Override
	public Class<?> findClass(String name) {
		String fileName = "C:\\Users\\yanga\\Desktop\\" +
							name.substring(name.lastIndexOf(".") + 1) + ".class";
		InputStream in = null;
		ByteArrayOutputStream bu = null;
		try {
			in = new FileInputStream(new File(fileName));
			bu = new ByteArrayOutputStream();
			int len = 0;
			byte[] buffer = new byte[1024];
			while((len = in.read(buffer, 0, buffer.length)) > 0) {
				bu.write(buffer, 0, len);
			}
			byte[] result = bu.toByteArray();
			return defineClass(name,result,0,result.length);
		} catch (FileNotFoundException e) {
			e.printStackTrace();
		} catch (IOException e) {
			e.printStackTrace();
		}finally {
			try {
				in.close();
				bu.close();
			} catch (IOException e) {
				e.printStackTrace();
			}
		}
		return null;
    }
}
複製代碼
//主函數調用
public static void main(String[] args){
        ClassLoader loader = new MyClassLoader();
        try {
            Class<?> myClass = loader.loadClass("MyPackage.Out");
            System.out.println(myClass.getClassLoader());
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }
    }
複製代碼

輸出結果:

image

總體上來講,咱們的 MyClassLoader 其實只幹了一件事情,就是將磁盤文件讀取進內存,保存在 byte 數組中,而後調用 defineClass 方法進行後續的類加載過程,這是個本地方法,咱們看不到它的實現。

換句話說,雖然 jdk 容許咱們自定義類加載器加載字節碼文件,可是咱們能作的也只是讀文件而已,底層的東西都被封裝的好好的,後續等咱們看 Hotspot 源碼的時候再去剖析它的底層實現。

一種類加載器老是負責某個範圍或者目錄下的全部文件的加載,就像 bootstrap 加載器負責加載 <JAVA_HOME>\lib 這個目錄中存放的全部字節碼文件,extenttion 加載器負責 <JAVA_HOME>\lib\ext 目錄下的全部字節碼文件,而 application 類加載器則負責咱們項目類路徑下的字節碼文件的加載。

至於自定義的類加載器而言,加載目錄也隨之自定義了,例如咱們這裏實現的類加載器則負責桌面目錄下全部的 Class 文件的加載。

總結一下,有關虛擬機類加載機制的相關內容,網上的資料大多相同而且對於一些細節之處很粗糙的一帶而過,我也是看了不少的資料,儘量的描述這其中的細節。固然,不少地方也只是我我的理解,各位若有不一樣看法,歡迎交流~


文章中的全部代碼、圖片、文件都雲存儲在個人 GitHub 上:

(https://github.com/SingleYam/overview_java)

歡迎關注微信公衆號:撲在代碼上的高爾基,全部文章都將同步在公衆號上。

image
相關文章
相關標籤/搜索