首頁 > 軟體

深入瞭解Java中的類載入機制

2022-11-17 14:01:11

一、類載入過程

程式設計師編寫的Java源程式(.java檔案)在經過編譯器編譯之後被轉換成位元組程式碼(.class 檔案),類載入器將.class檔案中的二進位制資料讀入到記憶體中,將其放在方法區內,然後在堆區建立一個java.lang.Class物件,用來封裝類在方法區內的資料結構。

類載入的最終產品是位於堆區中的Class物件,Class物件封裝了類在方法區內的資料結構,並且向Java程式設計師提供了存取方法區內的資料結構的介面。

以下是舉例說明類載入過程:

二、類生命週期

類的生命週期包括:載入、驗證、準備、解析、初始化、使用、解除安裝7個階段。其中載入、驗證、準備、初始化、解除安裝5個階段是按照這種順序按部就班的開始,而解析階段則不一定:某些情況下,可以在初始化之後再開始,這是為了支援Java語言的執行時繫結(也稱為動態繫結或晚期繫結,其實就是多型),例如子類重寫父類別方法。

注意:這裡寫的是按部就班的開始,而不是按部就班地進行或完成,因為這些階段通常都是互相交叉混合式進行的,通常會在一個階段執行過程中呼叫、啟用另外一個階段。

1、載入

載入階段會做3件事情:

  • 通過一個類的全限定名來獲取定義此類的二進位制位元組流。
  • 將這個位元組流所代表的靜態儲存結構轉化為方法區的執行時資料結構。
  • 在Java堆中生成一個代表這個類的java.lang.Class物件,作為對方法區中這些資料的存取入口。

此處第一點並沒指明要從哪裡獲取、怎樣獲取,因此這裡給開發人員預留了擴充套件空間。許多Java技術就建立在此基礎上,例如:

  • 從ZIP包讀取,如JAR、WAR。
  • 從網路中獲取,這種場景最典型應用場景應用就是Applet。
  • 執行時計算生成,使用較多場景是動態代理技術,如spring AOP。

載入階段完成後,虛擬機器器外部的二進位制位元組流就按照虛擬機器器所需的格式儲存在方法區之中,而且在Java堆中也建立一個java.lang.Class類的物件,這樣便可以通過該物件存取方法區中的這些資料。

2、驗證

確保被載入的類的正確性,分為4個驗證階段:

  • 檔案格式驗證
  • 後設資料驗證
  • 位元組碼驗證
  • 符號參照驗證

驗證階段非常重要的,但不是必須的,它對程式執行期沒有影響,如果所參照的類經過反覆驗證,那麼可以考慮採用-Xverifynone引數來關閉大部分的類驗證措施,以縮短虛擬機器器類載入的時間。

3、準備

為類的靜態變數分配記憶體,並初始化預設值,這些記憶體是在方法區中分配,需要注意以下幾點:

  • 此處記憶體分配的變數僅包含類變數(static),而不包括範例變數,範例變數會隨著物件範例化被分配在java堆中。
  • 這裡預設值是資料型別的預設值(如0、0L、null、false),而不是程式碼中被顯示的賦予的值。
  • 如果類欄位的欄位屬性表中存在ConstatntValue屬性,即同時被final和static修飾,那麼在準備階段變數value就會被初始化為ConstValue屬性所指定的值。

4、解析

解析階段是虛擬機器器將常數池內的符號參照替換為直接參照的過程,解析動作主要針對類或介面、欄位、類方法、介面方法、方法型別、方法控制程式碼和呼叫點限定符7類符號參照進行。符號參照就是一組符號來描述目標,可以是任何字面量。

直接參照就是直接指向目標的指標、相對偏移量或一個間接定位到目標的控制程式碼。

5、初始化

為類的靜態變數賦予正確的初始值,JVM負責對類進行初始化,主要對類變數進行初始化。初始化階段是執行類構造器<client>()方法的過程。

<client>()方法是由編譯器自動收集類中的所有類變數賦值動作和靜態語句static{}塊中的語句合併產生的,編譯器收集的順序是由語句在原始檔出現的順序所決定的。靜態語句塊中只能存取到定義在靜態語句塊之前的變數,定義在之後的變數可以賦值,但不能存取。如下所示:

public class Test{
    static{
        i=0;
        System.out.print(i);
    }
    static int i=1;
}

<clinit>()方法與類建構函式不一樣,不需要顯示呼叫父類別建構函式,虛擬機器器會保證在子類的<clinit>()方法執行之前,父類別的<clinit>()方法已執行完畢。

由於父類別的<clinit>()方法首先執行,意味著父類別中的靜態語句塊要優先於子類的變數賦值操作,如下所示,最終得出的值是2,而不是1。

public class TestClassLoader {
    public static int A = 1;
    static {
        A = 2;
//        System.out.println(A);
    }
    
    static class Sub extends TestClassLoader {
        public static int B = A;
    }
    
    public static void main(String[] args) {
        System.out.println(Sub.B);
    }
}

<clinit>()方法對於類和介面來說,並不是必須的,若類沒有靜態語句塊,也沒有對變數賦值操作,則不會生成<clinit>()方法。

介面與類不同的是,介面不需要先執行父類別的<clinit>()方法,只有父介面定義的變數使用時,父介面才會被初始化。另外介面的實現類也不會先執行介面的<clinit>()方法。

虛擬機器器保證當多執行緒去初始化類時,只會有一個執行緒去執行<clinit>()方法,而其他執行緒則被阻塞。

<clinit>()方法和<init>()方法區別:

執行時機不同:init方法是物件構造器方法,在new一個物件並呼叫該物件的constructor方法時才會執行。clinit方法是類構造器方法,是在JVM載入期間的初始化階段才會呼叫。

執行目的不同:init是對非靜態變數解析初始化,而clinit是對靜態變數,靜態程式碼塊進行初始化。

三、雙親委派機制

在介紹雙親委派機制前,先來看下類載入器的層次關係圖,如下:

  • 啟動類載入器(Bootstrap ClassLoader),負責載入存放在$JAVA_HOMEjrelib下,或被-Xbootclasspath引數指定的路徑中的,並且能被虛擬機器器識別的類庫(如rt.jar,所有的java.*開頭的類均被Bootstrap ClassLoader載入)。啟動類載入器是無法被Java程式直接參照的。
  • 擴充套件類載入器(Extension ClassLoader),該載入器由sun.misc.Launcher$ExtClassLoader實現,它負責載入$JAVA_HOMEjrelibext目錄中,或者由java.ext.dirs系統變數指定的路徑中的所有類庫(如javax.*開頭的類),開發者可以直接使用擴充套件類載入器。
  • 應用程式類載入器(Application ClassLoader),該類載入器由sun.misc.Launcher$AppClassLoader來實現,它負責載入使用者類路徑(ClassPath)所指定的類,開發者可以直接使用該類載入器,如果應用程式中沒有自定義過自己的類載入器,一般情況下這個就是程式中預設的類載入器。
  • 自定義類載入器(User ClassLoader),如果有必要,我們還可以加入自定義的類載入器。因為JVM自帶的ClassLoader只是懂得從本地檔案系統載入標準的java class檔案。

雙親委派機制是指如果一個類載入器收到了類載入的請求,它首先不會自己去嘗試載入這個類,而是把請求委託給父載入器去完成,依次向上,因此,所有的類載入請求最終都應該被傳遞到頂層的啟動類載入器中,只有當父載入器在它的搜尋範圍中沒有找到所需的類時,即無法完成該載入,子載入器才會嘗試自己去載入該類。

為了更清楚的瞭解雙親委派機制,我們來看下jdk1.8原始碼java.lang.ClassLoader.loadClass()方法實現:

    public Class<?> loadClass(String name) throws ClassNotFoundException {
        return loadClass(name, false);
    }

    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;
        }
    }

上面程式碼註釋寫的很清楚,首先呼叫findLoadedClass方法檢查是否已載入過這個類,如果沒有就呼叫parent的loadClass方法,從底層一級級往上。如果所有ClassLoader都沒有載入過這個類,就呼叫findClass方法查詢這個類,然後又從頂層逐級向下呼叫findClass方法,最終都沒找到就丟擲ClassNotFoundException。這樣設計的目的是保證安全性,防止系統類被偽造。

為了便於理解,以下是載入邏輯示意圖:

四、自定義類載入器的應用

自定義類載入器通常有以下四種應用場景:

  • 原始碼加密,防止原始碼洩露
  • 隔離載入類,採用隔離載入,防止依賴衝突。
  • 修改類載入的方式。
  • 擴充套件載入源。

1、原始碼加密

原始碼加密的本質是對位元組碼檔案進行操作。我們可以在打包的時候對class進行加密操作,然後在載入class檔案之前通過自定義classloader先進行解密操作,然後再按照標準的class檔案標準進行載入,這樣就完成了class檔案正常的載入。因此這個加密的jar包只有能夠實現解密方法的classloader才能正常載入。

2、隔離載入類

我們常常遇到頭疼的事情就是jar包版本的依賴衝突,寫程式碼五分鐘,排包一整天。

舉個栗子:

工程裡面同時引入了 A、B 兩個 jar 包,以及 C 的 v0.1、v0.2 版本,v2 版本的 Log 類比 v1 版本新增了 error 方法,,打包的時候 maven 只能選擇 C 的一個版本,假設選擇了 v1 版本。到了執行的時候,預設情況下一個專案的所有類都是用同一個類載入器載入的,所以不管你依賴了多少個版本的 C,最終只會有一個版本的 C 被載入到 JVM 中。當 B 要去存取 Log.error,就會發現 Log 壓根就沒有 error 方法,然後就拋異常 java.lang.NoSuchMethodError。這就是類衝突的一個典型案例。

類隔離技術就是用來解決這個問題。讓不同模組的 jar 包用不同的類載入器載入。

JVM 提供了一種非常簡單有效的方式,我把它稱為類載入傳導規則:JVM 會選擇當前類的類載入器來載入所有該類的參照的類。例如我們定義了 TestA 和 TestB 兩個類,TestA 會參照 TestB,只要我們使用自定義的類載入器載入 TestA,那麼在執行時,當 TestA 呼叫到 TestB 的時候,TestB 也會被 JVM 使用 TestA 的類載入器載入。依此類推,只要是 TestA 及其參照類關聯的所有 jar 包的類都會被自定義類載入器載入。通過這種方式,我們只要讓模組的 main 方法類使用不同的類載入器載入,那麼每個模組的都會使用 main 方法類的類載入器載入的,這樣就能讓多個模組分別使用不同類載入器。這也是 OSGi 和 SofaArk 能夠實現類隔離的核心原理。

3、熱載入/熱部署

在應用執行的時升級軟體,無需重新啟動的方式有兩種,熱部署和熱載入。

對於Java應用程式來說,熱部署就是在伺服器執行時重新部署專案,熱載入即在執行時重新載入class,從而升級應用。

熱載入可以概括為在容器啟動的時候起一條後臺執行緒,定時的檢測類檔案的時間戳變化,如果類的時間戳變掉了,則將類重新載入。對比反射機制,反射是在執行時獲取類資訊,通過動態的呼叫來改變程式行為。而熱載入則是在執行時通過重新載入改變類資訊,直接改變程式行為。

熱部署原理類似,但它是直接重新載入整個應用,這種方式會釋放記憶體,比熱載入更加乾淨徹底,但同時也更費時間。

4、擴充套件載入源

位元組碼檔案可以從資料庫、網路、移動裝置、甚至是電視機機上盒進行載入,可以與原始碼加密方式搭配使用。比如部分關鍵程式碼可以通過移動U盤讀取再載入到JVM。

到此這篇關於深入瞭解Java中的類載入機制的文章就介紹到這了,更多相關Java類載入機制內容請搜尋it145.com以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援it145.com!


IT145.com E-mail:sddin#qq.com