虛擬機器類載入機制

strind發表於2024-10-03

虛擬機器把位元組碼檔案從磁碟載入進記憶體的這個過程,我們可以粗糙的稱之為「類載入」,因為「類載入」不僅僅是讀取一段位元組碼檔案那麼簡單,虛擬機器還要進行必要的「驗證」、「初始化」等操作,下文將一一敘述。

類載入的基本流程

一個類從被載入進記憶體,到解除安裝出記憶體,完整的生命週期包括:載入,驗證,準備,解析,初始化,使用,解除安裝。如圖:

image

這七個階段按序開始,但不意味著一個階段結束另一個階段才能開始。也就是說,不同的階段往往是穿插著進行的,載入階段中可能會啟用驗證的開始,而驗證階段又有可能啟用準備階段的賦值操作等,但整體的開始順序是不會變的。

具體的內容,下文將詳細描述,這裡只需要建立一個巨集觀上的認識,瞭解整個類載入過程需要經過的幾個階段即可。

載入

「載入」和「類載入」是兩個不同的概念,後者包含前者,即「載入」是「類載入」的一個階段,而這個階段需要完成以下三件事:

  • 通過一個類的全限定名獲取對應於該類的二進位制位元組流
  • 將這個二進位制位元組流轉儲為方法區的執行時資料結構
  • 於記憶體中生成一個 java.lang.class 型別的物件,用於表示該類的型別資訊。

首先,第一個過程,讀取位元組碼檔案進入記憶體。具體如何讀取,虛擬機器規範中並沒有明確指明。也就是說,你可以從 ZIP 包中讀取,也可以從網路中獲取,還可以動態生成,或者從資料庫中讀取等,反正最終得到的結果一樣:位元組碼檔案的二進位制流

第二步,將這個記憶體中的二進位制流重新編碼儲存,依照方法區的儲存結構進行儲存,方便後續的驗證和解析。方法區資料結構如下:

image

大體上的格式和我們虛擬機器規範中的 Class 檔案格式是差不多的,只是這裡增加了一些項,重排了某些項的順序。

第三步,生成一個 java.lang.class 型別的物件。這個型別的物件建立的具體細節,我們不得而知,但是這個物件存在於方法區之中的唯一目的就是,唯一表示了當前類的基本資訊,外部所有該類的物件的建立都是要基於這個 class 物件的,因為它描述了當前類的所有資訊。

可見,整個載入階段,後兩個步驟我們不可控,唯一可控的是第一步,載入位元組碼。具體如何載入,這部分內容,這裡不打算詳細說明,具體內容將於下文描述「類載入器」時進行說明。

驗證

驗證階段的目的是為了確保載入的 Class 檔案中的位元組流是符合虛擬機器執行要求的,不能威脅到虛擬機器自身安全。

這個階段「把控」的如何,將直接決定了我們虛擬機器能否承受住惡意程式碼的攻擊。整個驗證又分為四個階段:檔案格式驗證、後設資料驗證、位元組碼驗證,符號引用驗證

1、檔案格式驗證

這個階段將於「載入」階段的第一個子階段結束後被啟用,主要對已經進入記憶體的二進位制流進行判斷,是否滿足虛擬機器規範中要求的 Class 檔案格式。例如:

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

當通過該階段的驗證後,位元組碼檔案將順利的儲存為方法區資料結構,此後的任何操作都不在基於這個位元組碼檔案了,都將直接操作儲存在方法區中的類資料結構。

2、後設資料驗證

該階段的驗證主要針對位元組碼檔案所描述的語義進行驗證,驗證它是否符合 Java 語言規範的要求。例如:

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

雖然某些校驗在編譯器中已經驗證過了,這裡卻依然需要驗證的原因是,並不是所有的 Class 檔案都是由編譯器產生的,也可以根據 Class 檔案格式規範,直接編寫二進位制得到。雖然這種情況少之又少,但是不代表不存在,所以這一步的驗證的存在是很有必要的。

3、位元組碼驗證

經過「後設資料驗證」之後,整個位元組碼檔案中定義的語義必然會符合 Java 語言規範。但是並不能保證方法內部的位元組碼指令能夠很好的協作,比如出現:跳轉指令跳轉到方法體之外的位元組碼指令上,位元組碼指令取錯運算元棧中的資料等問題

這部分的驗證比較複雜,我查了很多資料,大部分都一帶而過。總體上來說,這階段的驗證主要是對方法中的位元組碼指令在執行時可能出現的一部分問題進行一個校驗。

4、符號引用驗證

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

  • 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 編碼,只不過一旦整個類中有多個重複的常量項的話,就顯得浪費記憶體了。

當一個類被載入進方法區之後,該類的常量池中的所有常量將會入駐方法區的執行時常量池。這是一塊對所有執行緒公開的記憶體區域,多個類之間如果有重複的常量將會被合併。直接引用會直接入駐常量池,而符號引用則需要通過解析階段來實際指向執行時常量池中的直接引用的地址。

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

1、類或介面的解析

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

  • 通過常量池找到 B 這個符號引用所對應的直接引用(類的全限定名的 utf8 編碼)
  • 把這個全限定名稱傳遞給虛擬機器完成類載入(包括我們完整的七個步驟)
  • 替換 B 的符號引用的值為記憶體中剛載入的類或者介面的地址

當然,對於我們的陣列型別是稍有不同的,因為陣列型別在執行時由 jvm 動態建立,所以在解析階段的第一步,jvm 需要額外去建立一個陣列型別放在常量池中,其餘步驟基本相同。

2、欄位的解析

欄位在常量池中由常量項 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

相關文章