Java類載入機制-雙親委派

weixin_33850890發表於2018-07-08

Java類載入

       在Java中,一個類如果要想正確執行,就必須通過JVM編譯,然後將其載入記憶體中才能使用,這裡的載入記憶體中,實際上就是:類在JVM中以java.lang.Class型別的物件存在。說到Class型別,瞭解反射的都比較清楚,這是反射中常用的一個型別,獲取Class物件一般使用兩種方式:通過類的具體物件的getClass方法(obj.getClass())、通過類的class屬性獲取(Object.class)。

       Java中的類載入實際上都是通過位元組流來來實現的,位元組流的來源可以有多種方式,可以從檔案中獲取,也可以通過網路獲取,雖然來源不同,但是隻要最終是位元組流形式,就可以通過JVM來解析並載入。在此先說明一下:類在載入過程中都經歷了那些步驟。

類的生命週期

       類從被虛擬機器載入,直到最後被解除安裝出記憶體,這一整個過程成為類的生命週期,大致可以分為七個階段:

12839705-8bfca64eb166e888.png
類的生命週期.png

       這裡需要注意:除去解析這個步驟,其餘步驟的順序都是確定的,解析階段規則有點不同,在有些情況下,解析可以在初始化之後再進行,這也是Java語言的執行時繫結的一個基礎保障。

       這裡先說說初始化,在虛擬機器規範中,嚴格規定了初始化所必須具備的條件,即:如果滿足必備條件,將必須執行初始化操作。共有5中情況(能夠到初始化這一步,也就說明前面都已經驗證通過了):

  • 遇到new、getstatic、putstatic或invokestatic這四條指令程式碼,如果對應類還沒有初始化,則必須進行初始化,對應與Java中常用的場景就是:例項化一個物件(new)、讀取或設定一個靜態欄位(getstatic、putstatic;這裡需要額外說明:final修飾的常量不包括在內,因為它屬於在編譯器就已經把它放到了靜態常量池中了)、呼叫一個方法的時候(invokestatic)。

  • 在使用reflect包下的方法時,對類進行反射呼叫的時候,如果沒有初始化,先觸發其初始化

  • 當初始化某一個類的時候,它的父類還沒有進行初始化,那麼就先初始化它的父類

  • 虛擬機器啟動時,我們指定的那個包含main方法的類(執行主類)會先被初始化

  • JDK1.7的動態語言支援部分,在使用java.lang.invoke.MethodHandler例項解析最後結果中的方法控制程式碼,如果方法控制程式碼所對應的類沒有被初始化,則需要先觸發其初始化。

類載入

       類的載入過程可以分為載入、驗證、準備、解析和初始化這五個步驟。也就是上圖中除去使用和解除安裝兩個步驟之外的過程。

載入

       載入過程是類載入過程的一個部分,換句話說:ClassLoading過程包括載入過程,但不僅僅只有載入過程,在載入階段,虛擬機器需要完成3個步驟:

  • 通過一個類的全限定名來獲取此類的二進位制位元組流。

  • 將此位元組流所代表的靜態儲存結構轉換成方法區中的執行時資料結構

  • 在記憶體中生成一個Class物件,在方法區中作為這個類訪問入口。

       通過第一步可以知道,這裡並沒有準確定義二進位制流具體要從哪裡獲取,怎樣獲取。因此可以發揮的空間就比較大了,例如:可以通過壓縮包中讀取(zip、jar、war等等)、可以從網路中獲取、執行時動態計算(動態代理)、其他檔案生成(JSP)等等,方法多樣,可以根據具體應用場景來自由選擇。

驗證

       這一步是至關重要的一部,目的就是為了確保二進位制位元組流中包含的資訊是不是與當前虛擬機器的要求相符合,同時會不會對虛擬機器有危險。驗證階段的嚴謹性直接決定了虛擬機器的強壯性,嚴謹的驗證過程可以保證虛擬機器能夠抵禦各種惡意程式碼的攻擊。這一過程如果不通過,會丟擲java.lang.VerifyError。主要分為四個步驟:

  • 檔案格式驗證:就是驗證位元組流是否符合規範,如:是否以魔數0xCAFEBABE開頭、主次版本號是否在虛擬機器的處理範圍之內...等等

  • 後設資料驗證:這段主要是對位元組流中的資訊進行語義分析,保證符合Java的語言規範,如:驗證是否有父類(除Object之外,所有類都應當有父類)、是否存在繼承了final修飾的類...等等

  • 位元組碼驗證:這塊比較複雜,目的就是通過資料流和控制流分析,確定程式語義是合法的、符合邏輯的,在後設資料校驗的基礎上,會對方法體進行校驗分析,保證方法在執行時不會對虛擬機器有危害。

  • 符號引用驗證:這個步驟發生在虛擬機器將符號引用轉化為直接引用過程中,這個轉化動作發生在上圖類的生命週期所示的【連線】步驟的第三個階段--【解析】階段發生。可以看做是類對自身以外的資訊進行匹配校驗。

準備

       這個步驟是正式為類變數分配記憶體並且設定初始值的階段,這些變數使用的記憶體都將在方法區中進行分配。注意這裡說的變數僅僅是static修飾的變數,也就是跟類相關的變數,例項變數是在類進行例項化的時候,在Java堆中進行分配的。另外這裡說的初始化並不是我們程式中定義的那些初始化的值,它僅僅只是根據資料型別設定該型別的零值。例如i程式中定義了變數:

public static int value = 123;

       那麼這裡在初始化的時候,value的值此時是0,而value賦值123的操作是在程式被編譯之後進行的,123的賦值操作將會在後面【初始化】那一步進行的。下面給出一些基本資料型別對應的零值:

資料型別 零值 資料型別 零值
int 0 boolean false
long 0L float 0.0f
short (short)0 double 0.0d
char '\u0000' reference null
byte (byte)0

       上面的零值是通常情況下的賦值,但是還有一些特殊情況,例如用final修飾的變數,它所修飾的變數在編譯階段都會生成ConstantValue屬性,並且將該屬性的值指向程式所設定的值,如上面例子中的value,如果加上final,ConstantValue就會指向123,因此在準備階段,虛擬機器就會根據該屬性指向的值設定對應欄位的值。

解析

       這個階段是虛擬機器將常量池中的符號引用轉換為直接引用的過程,那麼符號引用和直接引用到底是什麼概念?

  • 符號引用(Symbolic Reference):它只是一種用來表示某種目標的一種標記,通過它能夠讓虛擬機器定位到它所代指的目標即可,它可以是任何形式的字面量,它與虛擬機器的記憶體佈局無關,只是一種概念上的存在,它所代指的目標不要求是否已經在記憶體中存在,不同虛擬機器雖然記憶體佈局不一樣,但是所接受的符號引用是一致的,所以說它是通用的。

  • 直接引用(Direct Reference):它可以是一個能夠直接指向目標的指標、相對偏移量或者一個能間接訪問到目標的控制程式碼。它與具體的記憶體佈局相關,它所指向的目標都已經是存在與記憶體中,是真實存在的。

       解析主要包括:類或介面的解析、欄位解析、類方法解析、介面方法解析。這裡不再分析具體各個解析的概念,想要了解詳情,可以查閱《深入理解Java虛擬機器 第2版》的第七章第三小節解析部分。

初始化

       到了這一步,才開始執行類中定義的Java程式程式碼,在前面已經有過一步初始化步驟,在這一步,將會根據Java程式的主觀意願去初始化變數和其他資源。初始化階段是執行類構造器<cinit>()方法的過程。這裡有一種情況需要說明:Java在定義靜態語句塊的時候,靜態語句塊中只能訪問到定義在該靜態語句塊之前的變數,對於定義在它之後的變數,可以賦值,但是不能訪問,例如:

public class Test {
   static {
     i = 0;//可以編譯通過,正常賦值
     System.out.println(i);//編譯不通過,提示“非法向前引用(Illegal forward reference)”
   }
   static int i = 1;
}

       而且<cinit>()方法執行之前,父類的<cinit>()方法必須已經執行完畢了,所以說最先執行的<cinit>()方法一定是Object類的,並且父類的靜態語句要先於子類的靜態語句執行。

類載入器

       類載入器作用是實現類的載入動作,同時它也是區分類與類之間唯一性的重要依據。在Java中對於任意一個類,都需要由載入器和類的本身一同確定類在Java虛擬機器中的唯一性。每一個類載入器都有一個獨立的名稱空間。換句話說:如果要比較兩個類是不是“相等”,首先得基於是同一個載入器載入出來的,否則兩個類肯定是“不相等”的。即:同一份位元組碼檔案,被不同的載入器載入,那這兩個類仍然是“不相等”的。

雙親委派模型

       在虛擬機器中,存在兩大類載入器:根載入器(BootStrap ClassLoader)和其他以Java實現的載入器。除根載入器之外,其餘的載入器都繼承自抽象類ClassLoader,並且由Java程式碼實現。

  • 根載入器:是C++實現的,它主要是用於載入JAVA_Home\lib目錄中的類,或者被-Xbootclasspath引數指定的路徑。這裡虛擬機器識別的方式是按照名字識別的,換句話說:它識別的那些類都是已經定義好的,如果是不符合這些命名的,即使放進載入路徑中,也不會載入。

  • 擴充套件類載入器(Extension ClassLoader):主要是載入JAVA_HOME\lib\ext路徑下的類。

  • 應用程式載入類(Application ClassLoader):載入類路徑上指定的類庫,如果程式中沒有自定的載入器,這個就是預設的載入器,使用者編寫的程式碼,一般都是通過它來載入。

       上面的三類載入器,是分層級的,最底層是應用程式載入器,上面一層是擴充套件類載入器,最頂層的是根載入器。雙親委派模型就是依據此結構建立的,它具體工作過程是:如果一個載入器收到了類載入的請求,載入器並不會直接進行載入,而是把這個請求委派個父類載入器來完成,只有父類載入器無法完成的時候,才到子類載入器中載入。另外需要明確一點:這裡雖然說是“父子”關係,但是實際上並不是繼承關係,而是通過組合方式實現的。在獲取類載入器的時候,可以通過getParent方法來獲取對應載入器的父類載入器。Application ClassLoader的父類載入器是Extension ClassLoader;Extension ClassLoader的父類載入器是BootStrap ClassLoader。但是如果我們通過Extension ClassLoader的getParent方法獲取父類載入器的時候,得到的會是null,這是因為根載入器是C++實現的,它是本地語言實現的。


12839705-81c588442d9e5046.png
類載入器雙親委派模型.png

雙親委派模型有一個好處,就是Java類隨著載入器的不同,有了優先順序劃分,同時對於Java程式的安全性和穩定性有了保障。試想:如果沒有雙親委派,隨便一個類都可以指定類載入器進行載入,那如果使用者自定義了一個Object類,指定根載入器載入,這就破壞了Java內部的繼承結構,Java內部,所有的類都是直接或間接繼承自Object,這樣出現了多個Object類,Java體系內部,一些很基礎的行為就無法保證了(例如:hash,toString,equals等等這些行為)。這樣系統內部就會一片混亂。而有了雙親委派,就能夠保證越基礎的類,由越高階的類載入器去完成,例如:使用者嘗試編寫一個與rt.jar類庫中某個類重名的類,可以發現,雖然可以編譯,但是永遠不會被載入執行,因為在到根載入器驗證的時候就無法通過。

雙親委派模型在Java的ClassLoader.java的原始碼中就可以看到,可以檢視該類中的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) {
                    // 如果父類載入器中沒有找到該類,就丟擲ClassNotFoundException
                }

                if (c == null) {
                    //此時仍然沒有找到,就呼叫本身的findClass方法
                    long t1 = System.nanoTime();
                    c = findClass(name);

                   //下面這些步驟就是定義類載入器,並且記錄狀態的,暫時無視它
                    PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                    PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                    PerfCounter.getFindClasses().increment();
                }
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }

可以看到,程式碼邏輯很清晰:

  • 先根據findLoadedClass確定是不是已經載入過

  • 如果沒有,就委派父類載入器進行查詢載入

  • 如果父類載入器沒有載入成功,就丟擲ClassNotFoundException,並且呼叫本載入器的findClass方法進行載入

破壞雙親委派模型

       需要明白的是雙親委派模型不是一個強制性的約束,它只是一種推薦模式,Java中大都遵循這種模型,但是也會有例外,目前為止,雙親委派模型經歷過三次較大規模的“被破壞”情況。其實與其說是“被破壞”,我更願意稱它為“被改造”。因為帶來這些“破壞”的根本原因,主要還是因為雙親委派在有些特殊應用場景無法滿足的問題。

       第一次被破壞是JDK1.2之前,因為雙親委派模型是在JDK1.2之後才釋出的,ClassLoader在JDK1.0就已經存在了,因此在1.2版本釋出後,需要相容以前的程式碼,1.2以後的ClassLoader新增一個protected方法findClass。這麼做的原因就是在於1.2以前,使用者繼承ClassLoader的唯一目的就是重寫loadClass方法,那麼虛擬機器在呼叫類載入器的時候會呼叫載入器的私有方法loadClassInternal方法,該方法就一個邏輯:呼叫自己寫的loadClass方法,在1.2以後,不提倡覆蓋loadClass方法,建議重寫findClass方法,這樣在父類的loadClass載入失敗之後,會直接呼叫自身實現的findClass方法來載入。以此保證新寫出來的類載入器是符合雙親委派模型的。

       第二次被破壞是模型本身缺陷造成的,因為該模型雖然可以讓越基礎的類由越高階的載入器完成載入,但是也限制了上層載入器中載入的類不能呼叫使用者的程式碼,典型的例子就是JNDI服務,它在服務啟動的時候,需要呼叫各個廠商實現的不同介面,而該服務本身是放在rt.jar中的,為了解決這個問題,引入了執行緒上下文類載入器,它可以通過Thread類的setContextClassLoader方法來設定,若執行緒建立時沒有指定,就直接從父類中繼承一個,如果程式的全域性環境都沒有設定,就採用預設的應用程式類載入器,有了個這種載入器,JNDI通過該執行緒去載入所需要的SPI程式碼,即:父類載入器請求子類載入器去完成類的載入,實際上就是雙親委派的逆向過程。常說的JNDI、JDBC等採用的都是這種方式。

       第三次“被破壞”是使用者對程式動態性追求而導致的。這裡的動態性實際就是指:程式碼熱替換、模組熱部署等這些“熱點”概念。就是希望在程式執行的時候在不需要重啟應用程式的情況下,動態替換程式中的類。這裡就不得不提OSGi,它是Sun公司提出的JSR-294、JSR-277規範與JCP組織的模組化規範鬥爭中的產物,最終Sun落敗給JSR-294規範(即:OSGi R4.2),OSGi實現熱部署的關鍵就是:它有自定義的類載入器機制實現,每個模組在OSGi中都是一個Bundle,它都有一個自己的類載入器,當需要更換模組的時候,就將該Bundle連同所帶的類載入器一起換掉,從而達到熱部署的效果。而且在OSGi環境下,類載入器不再是雙親委派模型中的樹狀結構,而是進一步發展為網狀結構。

相關文章