JVM-類載入機制

GaoYuan206發表於2021-11-16

虛擬機器把描述類的資料從Class檔案載入到記憶體,並對資料進行校驗、轉換解析和初始化,最終形成可以被虛擬機器直接使用的Java型別,這就是虛擬機器的類載入機制。

在Java語言裡,型別的載入、連線和初始化過程都是在程式執行期間完成的,這種策略雖然會令類載入時稍微增加一些效能開銷,但是會為Java應用程式提供高度的靈活性,Java的動態擴充套件特性就是依賴執行期動態載入和動態連線這個特點實現的。

類載入的時機

類從被載入到虛擬機器記憶體中開始,到解除安裝出記憶體為止,它的整個生命週期包括:載入(Loading)、驗證(Verification)、準備(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和解除安裝(Unloading)7個階段。其中驗證、準備、解析3個部分統稱為連線(Linking)。這7個階段發生的順序如圖所示:

類的生命週期

載入、驗證、準備、初始化和解除安裝這5個階段的順序是確定的,類的載入過程必須按照這種順序按部就班地開始,而解析階段則不一定:它在某些情況下可以在初始化階段之後再開始,這是為了支援Java語言的執行時繫結。

按部就班地“開始”,強調這點是因為這些階段通常都是互相交叉地混合式進行的,通常會在這一階段執行的過程中呼叫、啟用另外一個階段。

什麼情況下需要開始類載入過程的第一個階段:載入?Java虛擬機器規範中並沒有進行強制約束,這點可以交給虛擬機器的具體實現來把握。但對於初始化階段,虛擬機器規範有嚴格的規定,有且只有5種情況必須立即對類進行“初始化”

1、 遇到new、getstatic、setstatic或invokestatic這4條位元組碼指令時,如果類沒有進行初始化,則需要先觸發其初始化。生成這4條指令的最常見的Java程式碼場景是:使用new關鍵字例項化物件的時候、讀取或設定一個類的靜態子段(被final修飾、已在編譯期把結果放入常量池的靜態子段除外)的時候,以及呼叫一個類的靜態方法的時機。

2、 使用java.lang.reflect包的方法對類進行反射呼叫的時候,如果類沒有進行過初始化,則需要先觸發其初始化。

3、當初始化一個類的時候,如果發現其父類還沒有進行初始化,則需要先觸發其父類的初始化。

4、當虛擬機器啟動時,使用者需要指定一個要執行的主類(包含main()方法的那個類),虛擬機器會先初始化這個主類。

5、當使用JDK 1.7的動態語言支援時,如果一個java.lang.invoke.MethodHandle例項最後的解析結果 REF_getStatic、REF_putStatic、REF_invokeStatic的方法控制程式碼,並且這個方法控制程式碼所對應的類沒有進行初始化,則需要先觸發其初始化。

這5種場景中的行為稱為對一個類進行主動引用,除此之外,所有引用類的方法都不會出發初始化,稱為被動引用。

介面也有初始化過程,但是一個介面在初始化時,並不要要求其父介面全部都完成了初始化,只有在真正使用到父介面的時候(如引用介面中定義的常量)才會初始化。

類載入的過程

Java虛擬機器類載入的全過程是載入、驗證、準備、解析和初始化這5個階段所執行的具體動作。

載入

在載入階段,虛擬機器需要完成以下3件事情:

1)通過一個類的全限定名來獲取定義此類的二進位制位元組流;

2)將這個位元組流所代表的靜態儲存結構轉化為方法區的執行時資料結構;

3)在記憶體中生成一個代表這個類的java.lang.Class物件,作為方法區這個類的各種資料的訪問入口;

陣列類本身不通過類載入器建立,它是由Java虛擬機器直接建立的。但陣列類與類載入器仍然有很密切的關係,因為陣列類的元素型別最終是要靠類載入器去建立。

驗證

驗證是連線階段的第一步,這一階段的目的是為了確保Class檔案的位元組流中包含的資訊符合當前虛擬機器的要求,並且不會危害虛擬機器自身的安全。

從整體上來看,驗證階段大致上會完成下面4個階段的校驗動作:檔案格式驗證、後設資料驗證、位元組碼驗證、符號引用驗證。

準備

準備階段正式為類變數分配記憶體並設定類變數初始值的階段,這些變數所使用的記憶體都將在方法區中進行分配。首先,這個時候進行記憶體分配的僅包括類變數(被static修飾的變數),而不包括例項變數,例項變數將會在物件例項化時隨著物件一起分配在Java堆中;其次,這裡所說的初始值“通常情況”下是資料型別的零值。

如果類子段的欄位屬性表中存在ConstantValue屬性,那在準備階段變數value就會被初始化為ConstantValue屬性所指定的值。

解析

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

  • 符號引用

    符號引用以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時無歧義地定位到目標即可。符號引用與虛擬機器實現的記憶體佈局無關,引用的目標並不一定已經載入到記憶體中。各種虛擬機器實現的記憶體佈局可以各不相同,但是它們能接受的符號引用必須都是一致的,因為符號引用的字面量形式明確定義在Java虛擬機器規範的Class檔案格式中。

  • 直接引用

    直接引用可以是直接指向目標的指標、相對偏移量或一個能間接定位到目標的控制程式碼。直接引用是和虛擬機器實現的記憶體佈局相關的,同一個符號引用在不同虛擬機器例項上翻譯出來的直接引用一般不會相同。如果有了直接引用,那引用的目標必定已經在記憶體中存在。

初始化

在初始化階段,才真正開始執行類中定義的Java程式程式碼,根據程式設計師通過程式制定的主觀計劃去初始化類變數和其他資源。或者可以從另外一個角度來表達:初始化階段是執行類構造器<clinit>()方法的過程。

<clinit>()方法是由編譯器自動收集類中的所有類變數的賦值動作和靜態語句塊中的語句合併產生的,編輯器收集的順序是由語句在原始檔中出現的順序所決定的,靜態語句塊中只能訪問到定義在靜態語句塊之前的變數,定義在它之後的變數可以賦值,但不能訪問。

<clinit>()方法執行過程中一些可能會影響程式執行行為的特點和細節:

1、<clinit>()方法與類的建構函式不同,它不需要顯式地呼叫父類建構函式,虛擬機器會保證在子類的<clinit>()方法執行之前,父類的<clinit>()方法已經執行完畢。因此在虛擬機器中第一個被執行的<clinit>()方法的類肯定是java.lang.Object。

2、<clinit>()方法對類和介面來說並不是必需的,如果一個類中沒有靜態語句塊,也沒有對變數的賦值操作,那麼編譯器可以不為這個類生成<clinit>()方法。

3、介面中不能使用靜態語句塊,但仍然有變數初始化的賦值操作,因此介面與類一樣都會生成<clinit>()方法。但介面與類不同的是,執行介面的<clinit>()方法不需要先執行父介面的<clinit>()方法,只有當父介面中定義的使用時,父介面才會初始化。另外,介面的實現類在初始化也一樣不會執行介面的<clinit>()方法。

4、虛擬機器會保證一個類的<clinit>()方法在多執行緒環境中被正確地加鎖、同步,如果多個執行緒同時去初始化一個類,那麼只會有一個執行緒去執行這個類的<clinit>()方法,其他執行緒都需要阻塞等待,直到活動執行緒執行<clinit>()方法完畢。

類載入器

虛擬機器設計團隊把類載入階段中的“通過一個類的全限定名來獲取描述此類的二進位制位元組流”這個動作放到Java虛擬機器外部去實現,以便讓應用程式自己決定如何去獲取所需要的類。實現這個動作的程式碼模組稱為“類載入器”。

類與類載入器

類載入器雖然只用於實現類的載入動作,但它在Java程式中起到的作用遠遠不限於類載入階段。對於任意一個類,都需要由載入它的類載入器和這個類本身一同確立其在Java虛擬機器中的唯一性,每一個類載入器,都擁有一個獨立的類名稱空間。

比較兩個類是否“相等”,只有在這兩個類是由同一個類載入器載入的前提下才有意義,否則,即使這兩個類來源於同一個Class檔案,被同一個虛擬機器載入,只要載入它們的類載入器不同,那這兩個類就必定不相等。

雙親委派模型

從Java虛擬機器的角度來講,只存在兩種不同的類載入器:一種是啟動載入器(Bootstrap ClassLoader),這個類載入器使用C++語言,是虛擬機器自身的一部分;另外一種就是所有其他的類載入器,這些類載入器都由Java語言實現,獨立於虛擬機器外部,並且全部都繼承自抽象類java.lang.ClassLoader。

從Java開發人員的角度來看,絕大部分Java程式都會使用到以下3種系統提供的類載入器。

  • 啟動類載入器(Bootstrap ClassLoader)

    負責將存在${JAVA_HOME}\lib目錄中的,或者是被-Xbootclasspath所指定的路徑中,並且是虛擬機器識別的類庫載入到虛擬機器記憶體中。

    啟動類載入器無法被Java程式直接引用。

  • 擴充套件類載入器(Extension Classloader)

    負責載入${JAVA_HOME}\lib\ext目錄中的,或者是被java.ext.dirs系統變數所指定的所有類庫。

  • 應用程式載入器(Application Classloader)

    負責載入使用者類路徑(classpath)上所指定的類庫,如果應用程式沒有子定義過自己的類載入器,一般情況下這個是程式中預設的類載入器。

類載入器的雙親委派模型並不是一個強制性的約束模型,而是Java設計者推薦給開發者的一種類載入器實現方式。其工作過程是:如果一個類載入器收到了類載入的請求,它首先不會自己去嘗試載入這個類,而是把這個請求委派給父類載入器去完成,每一個層次的類載入器都是如此,因此所有的載入請求最終都應該傳送到頂層的啟動類載入器中,只有當父載入器反饋自己無法完成這個載入請求時,子載入器才會嘗試自己去載入。

實現雙親委派的程式碼都集中在java.lang.ClassLoader的loadClass()方法中,程式碼如下:

protected Class<?> loadClass(String name, boolean resolve)
    throws ClassNotFoundException
{
    synchronized (getClassLoadingLock(name)) {
        // 首先,檢查請求的類是否已經被載入過
        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) {
                
            }
            if (c == null) {
                long t1 = System.nanoTime();
                // 在父類無法載入的時候,再呼叫本身的findClass方法來進行載入
                c = findClass(name);
            }
        }
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }
}
複製程式碼

破壞雙親委派模型

在Java的世界中大部分的類載入器都遵循這個模型,但也有例外,到目前為止,雙親委派模型主要出現過3較大規模的“被破壞”情況:

1、雙親委派模型在JDK 1.2之後才被引入,而類載入器和抽象類java.lang.ClassLoader則在JDK 1.0時代就已經存在,面對已經存在的使用者自定義類載入的實現程式碼,Java設計者引入雙親模型時不得不做一些妥協。在此之前,使用者繼承java.lang.ClassLoader的唯一目的就是為了重寫loadClass()方法,為了向前相容,JDK 1.2之後的java.lang.ClassLoader新增了一個新的protected方法findClass(),推薦把自己的類載入邏輯寫到findClass()方法中。

2、雙親委派模型很好地解決了各個類載入器的基礎類的統一問題,之所以稱為“基礎類”,是因為它們總是作為被使用者程式碼呼叫的API。如果基礎類又要呼叫使用者的程式碼呢,典型的例子就是JNDI服務。為了解決這個問題,Java設計團隊引入一個不太優雅的設計:執行緒上下文類載入器。執行緒上下文載入器可以通過java.lang.Thread類的setContextClassLoader()方法進行設定,如果建立執行緒時還未設定,它將會從父執行緒中繼承一個,如果在應用程式的全域性範圍內都沒有設定過的話,那這個類載入器預設就是應用程式類載入器。

3、在OSGi環境下,類載入器不再是雙親委派模型中的樹狀結構,而是進一步發展為更加複雜的網狀結構。

歡迎留言補充,共同交流。個人微信公眾號求關注

JVM-類載入機制

相關文章