[深入理解Java虛擬機器]第七章 類載入器

Coding-lover發表於2015-10-24

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

類載入器可以說是Java語言的一項創新,也是Java語言流行的重要原因之一,它最初是為了滿足Java Applet的需求而開發出來的。雖然目前Java Applet技術基本上已經“死掉”,但類載入器卻在類層次劃分、OSGi、熱部署、程式碼加密等領域大放異彩,成為了Java技術體系中一塊重要的基石,可謂是失之桑榆,收之東隅。

類與類載入器

類載入器雖然只用於實現類的載入動作,但它在Java程式中起到的作用卻遠遠不限於類載入階段。對於任意一個類,都需要由載入它的類載入器和這個類本身一同確立其在Java虛擬機器中的唯一性,每一個類載入器,都擁有一個獨立的類名稱空間。這句話可以表達得更通俗一些 :比較兩個類是否“相等”,只有在這兩個類是由同一個類載入器載入的前提下才有意義 ,否則,即使這兩個類來源於同一個Class檔案 ,被同一個虛擬機器載入,只要載入它們的類載入器不同,那這兩個類就必定不相等。

這裡所指的“相等”,包括代表類的Class物件的equals()方法、isAssignableFrom()方法、islnstance()方法的返回結果,也包括使用instanceof關鍵字做物件所屬關係判定等情況。如果沒有注意到類載入器的影響,在某些情況下可能會產生具有迷惑性的結果,程式碼清單7-8中演示了不同的類載入器對instanceof關鍵字運算的結果的影響。

程式碼清單7 - 8 不同的類載入器對instanceof關鍵字運算的結果的影響

/**
 * 類載入器與instanceof關鍵字演示
 * 
 * @author zzm
 */
public class ClassLoaderTest {

    public static void main(String[] args) throws Exception {

        ClassLoader myLoader = new ClassLoader() {
            @Override
            public Class<?> loadClass(String name) throws ClassNotFoundException {
                try {
                    String fileName = name.substring(name.lastIndexOf(".") + 1) + ".class";
                    InputStream is = getClass().getResourceAsStream(fileName);
                    if (is == null) {
                        return super.loadClass(name);
                    }
                    byte[] b = new byte[is.available()];
                    is.read(b);
                    return defineClass(name, b, 0, b.length);
                } catch (IOException e) {
                    throw new ClassNotFoundException(name);
                }
            }
        };

        Object obj = myLoader.loadClass("org.fenixsoft.classloading.ClassLoaderTest").newInstance();

        System.out.println(obj.getClass());
        System.out.println(obj instanceof org.fenixsoft.classloading.ClassLoaderTest);
    }
}

執行結果:

class org.fenixsoft.classloading.ClassLoaderTest 
false

程式碼清單7-8中構造了一個簡單的類載入器,儘管很簡單,但是對於這個演示來說還是夠用了,它可以載入與自己在同一路徑下的Class檔案。我們使用這個類載入器去載入了一個 名為“org.fenixsoft.classloading.ClassLoaderTest”的類 ,並例項化了這個類的物件。兩行輸出結果中,從第一句可以看出,這個物件確實是類org.fenixsoft.classloading.ClassLoaderTest例項化出來的物件,但從第二句可以發現,這個物件與類org.fenixsoft.classloading.ClassLoaderTest做所屬型別檢查的時候卻返回了false ,這是因為虛擬機器中存在了兩個ClassLoaderTest類,一個是由系統應用程式類載入器載入的,另外一個是由我們自定義的類載入器載入的,雖然都來自同一個Class檔案 ,但依然是兩個獨立的類,做物件所屬型別檢查時結果自然為false。

雙親委派模型

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

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

啟動類載入器(Bootstrap ClassLoader) : 前面已經介紹過,這個類將器負責將存放在<JAVA_HOME>\lib目錄中的,或者被-Xbootclasspath引數所指定的路徑中的,並且是虛擬機器識別的(僅按照檔名識別,如rtjar,名字不符合的類庫即使放在lib目錄中也不會被載入)類庫載入到虛擬機器記憶體中。啟動類載入器無法被Java程式直接引用,使用者在編寫自定義類載入器時,如果需要把載入請求委派給引導類載入器,那直接使用null代替即可,如程式碼清單 7-9所示為java.lang.ClassLoader.getClassLoader()方法的程式碼片段。

程式碼清單7-9 ClassLoader.getClassLoader() 方法的程式碼片段

擴充套件類載入器(Extension ClassLoader):這個載入器由sun.misc.Launcher$ExtClassLoader實現,它負責載入<JAVE_HOME>\lib\ext對目錄中的,或者被java.ext.dirs系統變數所指定的路徑中的所有類庫,開發者可以直接使用擴充套件類載入器。

應用程式類載入器( Application ClassLoader ) : 這個類載入器由sun.misc.Launcher$AppClassLoader實現。由於這個類載入器是ClassLoader中的getSystemClassLoader()方法的返回值 ,所以一般也稱它為系統類載入器。它負責載入使用者類路徑(ClassPath)上所指定的類庫,開發者可以直接使用這個類載入器,如果應用程式中沒有自定義過自己的類載入器,一 般情況下這個就是程式中預設的類載入器。

我們的應用程式都是由這3種類載入器互相配合進行載入的,如果有必要,還可以加入自己定義的類載入器。這些類載入器之間的關係一般如圖7-2所示。

圖7-2中展示的類載入器之間的這種層次關係,稱為類載入器的雙親委派模型(Parents Delegation Model ) 。 雙親委派模型要求除了頂層的啟動類載入器外,其餘的類載入器都應當有自己的父類載入器。這裡類載入器之間的父子關係一般不會以繼承(Inheritance )的關係來實現,而是都使用組合(Composition)關係來複用父載入器的程式碼。

類載入器的雙親委派模型在JDK 1.2期間被引入並被廣泛應用於之後幾乎所有的Java程式中 ,但它並不是一個強制性的約束模型,而是Java設計者推薦給開發者的一種類載入器實現方式。

雙親委派模型的工作過程是:如果一個類載入器收到了類載入的請求,它首先不會自己去嘗試載入這個類,而是把這個請求委派給父載入器去完成,每一個層次的類載入器都是如此,因此所有的載入請求最終都應該傳送到頂層的啟動類載入器中,只有當父載入器反饋自己無法完成這個載入請求(它的搜尋範圍中沒有找到所需的類)時,子載入器才會嘗試自己去載入。

使用雙親委派模型來組織類載入器之間的關係,有一個顯而易見的好處就是Java類隨著它的類載入器一起具備了一種帶有優先順序的層次關係。例如類java.lang.Object,它存放在rt.jar之中,無論哪一個類載入器要載入這個類,最終都是委派給處於模型最頂端的啟動類載入器進行載入,因此Object類在程式的各種類載入器環境中都是同一個類。相反 ,如果沒有使用雙親委派模型,由各個類載入器自行去載入的話,如果使用者自己編寫了一個稱為 java.lang.Object的類 ,並放在程式的ClassPath中 ,那系統中將會出現多個不同的Object類 ,Java型別體系中最基礎的行為也就無法保證,應用程式也將會變得一片混亂。如果讀者有興趣的話,可以嘗試去編寫一個與rt.jar類庫中已有類重名的Java類 ,將會發現可以正常編譯 ,但永遠無法被載入執行。

雙親委派模型對於保證Java程式的穩定運作很重要,但它的實現卻非常簡單,實現雙親委派的程式碼都集中在java.lang.ClassLoader的loadClass()方法之中,如程式碼清單7-10所示, 邏輯清晰易懂:先檢查是否已經被載入過,若沒有載入則呼叫父載入器的loadClass()方法 ,若父載入器為空則預設使用啟動類載入器作為父載入器。如果父類載入失敗,丟擲 ClassNotFoundException異常後,再呼叫自己的findClass()方法進行載入。

這裡只限於HotSpot , 像MRP、Maxife等虛擬機器,整個虛擬機器本身都是由Java編寫的,自然Bootstrap ClassLoader也是由Java語言而不是C++實現的。退一步講,除了HotSpot以外的其他兩個高能虛擬機器JRockit和J9都有一個代表Bootstrap ClassLoader的Java類存在,但是關鍵方法的實現仍然是使用JNI回撥到C ( 注意不是C++ ) 的實現上,這個Bootstrap ClassLoader的例項也無法被使用者獲取到。

即使自定義了自己的類載入器,強行用defineClass()方法去載入以“java.lang”開頭的類也不會成功。如果嘗試這樣做的話,將會收到一個由虛擬機器自己丟擲的“java.lang.SecurityException : Prohibited package name :java.lang”異常。

破壞雙親委派模型

上文提到過雙親委派模型並不是一個強制性的約束模型,而是Java設計者推薦給開發者的類載入器實現方式。在Java的世界中大部分的類載入器都遵循這個模型,但也有例外,到目前為止,雙親委派模型主要出現過3較大規模的“被破壞”情況。

雙親委派模型的第一次“被破壞”其實發生在雙親委派模型出現之前——即JDK1.2釋出之前。由於雙親委派模型在JDK 1.2之後才被引入,而類載入器和抽象類java.lang.ClassLoader則在JDK 1.0時代就已經存在,面對已經存在的使用者自定義類載入器的實現程式碼,Java設計者引入雙親委派模型時不得不做出一些妥協。為了向前相容, JDK 1.2之後的java.lang.ClassLoader新增了一個新的protected方法findClass()(使用者自定義載入類邏輯), 在此之前,使用者去繼承java.lang.ClassLoader的唯一目的就是為了重寫loadClass()方法(實現雙親委派模型) ,因為虛擬機器在進行類載入的時候會呼叫載入器的私有方法loadClassInternal() 這個方法的唯一邏輯就是去呼叫自己的loadClass()。

上一節我們已經看過loadClass()方法的程式碼,雙親委派的具體邏輯就實現在這個方法之中, JDK 1.2之後已不提倡使用者再去覆蓋loadClass()方法,而應當把自己的類載入邏輯寫到findClass()方法中,在loadClass()方法的邏輯裡如果父類載入失敗,則會呼叫自己的findClass()方法來完成載入,這樣就可以保證新寫出來的類載入器是符合雙親委派規則的。

雙親委派模型的第二次“被破壞”是由這個模型自身的缺陷所導致的,雙親委派很好地解決了各個類載入器的基礎類的統一問題(越基礎的類由越上層的載入器進行載入),基礎類之所以稱為“基礎” ,是因為它們總是作為被使用者程式碼呼叫的A PI,但世事往往沒有絕對的完美 ,如果基礎類又要呼叫回使用者的程式碼,那該怎麼辦?

這並非是不可能的事情,一個典型的例子便是JNDI服務 ,JNDI現在已經是Java的標準服務,它的程式碼由啟動類載入器去載入(在JDK 1.3時放進去的rt.jar) ,但JNDI的目的就是對資源進行集中管理和查詢,它需要呼叫由獨立廠商實現並部署在應用程式的ClassPath下的JNDI介面提供者(SH,Service Provider Interface)的程式碼,但啟動類載入器不可能“認識”這些 程式碼啊!那該怎麼辦?

為了解決這個問題,Java設計團隊只好引入了一個不太優雅的設計:執行緒上下文類載入器(Thread Context ClassLoader)。這個類載入器可以通過java.lang.Thread類的setContextClassLoaser()方法進行設定,如果建立執行緒時還未設定,它將會從父執行緒中繼承一個 ,如果在應用程式的全域性範圍內都沒有設定過的話,那這個類載入器預設就是應用程式類載入器。

有了執行緒上下文類載入器,就可以做一些“舞弊” 的事情了,JNDI服務使用這個執行緒上下文類載入器去載入所需要的SPI程式碼 ,也就是父類載入器請求子類載入器去完成類載入的動作 ,這種行為實際上就是打通了雙親委派模型的層次結構來逆向使用類載入器,實際上已經違背了雙親委派模型的一般性原則,但這也是無可奈何的事情。Java中所有涉及SPI的載入動作基本上都採用這種方式,例如JNDI、JDBC、JCE、JAXB和JBI等。

雙親委派模型的第三次“被破壞”是由於使用者對程式動態性的追求而導致的,這裡所說的“動態性”指的是當前一些非常“熱門”的名詞:程式碼熱替換(HotSwap) 、模組熱部署(Hot Deployment)等 ,說白了就是希望應用程式能像我們的計算機外設那樣,接上滑鼠、U盤 ,不用重啟就能立即使用,滑鼠有問題或要升級就換個滑鼠,不用停機也不用重啟。對於個人計算機來說,重啟一次其實沒有什麼大不了的,但對於一些生產系統來說,關機重啟一次可能就要被列為生產事故,這種情況下熱部署就對軟體開發者,尤其是企業級軟體開發者具有很大的吸引力。

Sun公司所提出的JSR-294、JSR-277規範在與JCP組織的模組化規範之爭中落敗給JSR-291(即OSGi R4.2),雖然Sun不甘失去Java模組化的主導權,獨立在發展Jigsaw專案,但目前OSGi已經成為了業界“事實上”的Java模組化標準,而OSGi實現模組化熱部署的關鍵則是它自定義的類載入器機制的實現。每一個程式模組(OSGi中稱為Bundle)都有一個自己的類載入器,當需要更換一個Bundle時,就把Bundle連同類載入器一起換掉以實現程式碼的熱替換。

在OSGi環境下,類載入器不再是雙親委派模型中的樹狀結構,而是進一步發展為更加複雜的網狀結構,當收到類載入請求時,OSGi將按照下面的順序進行類搜尋:

  • 1 ) 將以java.*開頭的類委派給父類載入器載入。
  • 2 )否則,將委派列表名單內的類委派給父類載入器載入。
  • 3 )否則,將Import列表中的類委派給Export這個類的Bundle的類載入器載入。
  • 4 ) 否則,查詢當前Bundle的ClassPath, 使用自己的類載入器載入。
  • 5 ) 否則,查詢類是否在自己的Fragment Bundle中,如果在,則委派給Fragment Bundle的類載入器載入。
  • 6 ) 否則,查詢Dynamic Import列表的Bundle, 委派給對應Bundle的類載入器載入。
  • 7 ) 否則,類查詢失敗。

上面的查詢順序中只有開頭兩點仍然符合雙親委派規則,其餘的類查詢都是在平級的類載入器中進行的。

筆者雖然使用了“被破壞”這個詞來形容上述不符合雙親委派模型原則的行為,但這裡“被破壞”並不帶有貶義的感情色彩。只要有足夠意義和理由,突破已有的原則就可認為是一種創新。正如OSGi中的類載入器並不符合傳統的雙親委派的類載入器,並且業界對其為了實現熱部署而帶來的額外的高複雜度還存在不少爭議,但在Java程式設計師中基本有一個共識 :OSGi中對類載入器的使用是很值得學習的,弄懂了OSGi的實現,就可以算是掌握了類載入器的精髓。

相關文章