1. 程式人生 > >通過原始碼淺析Java中的資源載入

通過原始碼淺析Java中的資源載入

前提

最近在做一個基礎元件專案剛好需要用到JDK中的資源載入,這裡說到的資源包括類檔案和其他靜態資源,剛好需要重新補充一下類載入器和資源載入的相關知識,整理成一篇文章。

理解類的工作原理

這一節主要分析類載入器和雙親委派模型。

什麼是類載入器

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

類載入器雖然只用於實現類載入的功能,但是它在Java程式中起到的作用不侷限於類載入階段。對於任意一個類,都需要由載入它的類載入器和這個類本身一同確立類在Java虛擬機器中的唯一性,每一個類載入器,都擁有一個獨立的類名稱空間。上面這句話直觀來說就是:比較兩個類是否"相等",只有在這兩個類是由同一個類載入器載入的前提下才有意義,否則,即使這個兩個類是來源於同一個Class檔案,被同一個虛擬機器載入,只要載入它們的類載入器不同,那麼這兩個類必然"不相等"。這裡說到的"相等"包括代表類的Class物件的equals()

方法、isAssignableFrom()方法、isInstance()方法的返回結果,也包括使用instanceOf關鍵字做物件所屬關係判定等情況。

類和載入它的類載入器確定類在Java虛擬機器中的唯一性這個特點為後來出現的熱更新類、熱部署等技術提供了基礎。

雙親委派模型

從Java虛擬機器的角度來看,只有兩種不同的類載入器:

  • 1、第一種是啟動類載入器(Bootstrap ClassLoader),這個類載入器使用C++程式語言實現,是虛擬機器的一部分。
  • 2、另一種是其他的類載入器,這些類載入器都是由Java語言實現,獨立於虛擬機器之外,一般就是內部於JDK中,它們都繼承自抽象類載入器java.lang.ClassLoader。

JDK中提供幾個系統級別的類載入器:

  • 1、啟動類載入器(Bootstrap ClassLoader):這個類載入器負責將存放在${JAVA_HONE}\lib目錄中,或者被XbootstrapPath引數所指定的目錄中,並且是虛擬機器基於一定規則(如檔名稱規則,如rt.jar)標識的類庫載入到虛擬機器記憶體中。啟動類載入器無法被Java程式直接引用,開發者在編寫自定義類載入器如果想委派到啟動類載入器只需直接使用null替代即可。
  • 2、擴充套件類載入器(Extension ClassLoader):這個類載入器由sun.misc.Launcher的靜態內部類ExtClassLoader實現,它負責載入${JAVA_HONE}\lib\ext目錄中,或者通過java.ext.dirs系統變數指定的路徑中的所有類庫,開發者可以直接使用此類載入器。
  • 3、應用程式類載入器(Application ClassLoader):這個類載入器由sun.misc.Launcher的靜態內部類AppClassLoader實現,但是由於這個類載入器的例項是ClassLoader中靜態方法getSystemClassLoader()中的返回值,一般也稱它為系統類載入器。它負責載入使用者類路徑(ClassPath)上所指定的類庫,開發者可以直接使用這個類載入器,如果應用程式中沒有自定義過自實現的類載入器,一般情況下這個系統類載入器就是應用程式中預設使用的類載入器。
  • 4、執行緒上下文類載入器(Thread Context ClassLoader):這個在下一小節"破壞雙親委派模型"再分析。

Java開發者開發出來的Java應用程式都是由上面四種類載入器相互配合進行類載入的,如果有必要還可以加入自定義的類載入器。其中,啟動類載入器、擴充套件類載入器、應用程式類載入器和自定義類載入器之間存在著一定的關係:

r-l-1

上圖展示的類載入器之間的層次關係稱為雙親委派模型(Parents Delegation Model)。雙親委派模型要求除了頂層的類載入器(Java中頂層的類載入器一般是Bootstrap ClassLoader),其他的類載入器都應當有自己的父類載入器。這些類載入器之間的父子關係一般不會以繼承(Inheritance)的關係來實現,而是通過組合(Composition)的關係實現。類載入器層次關係這一點可以通過下面的程式碼驗證一下:

public class Main {

    public static void main(String[] args) throws Exception{
        ClassLoader classLoader = Main.class.getClassLoader();
        System.out.println(classLoader);
        System.out.println(classLoader.getParent());
        System.out.println(classLoader.getParent().getParent());
    }
}

//輸出結果,最後的null說明是Bootstrap ClassLoader
[email protected]
[email protected]
null

雙親委派模型的工作機制:如果一個類載入器收到了類載入的請求,它首先不會自己嘗試去載入這個類,而是把這個請求委派給父類載入器去完成,每一個層次的類載入器都是如此,因此所有的類載入請求最終都應該傳送到頂層的類載入器中,只有當父類載入器反饋自己無法完成當前的類載入請求的時候(也就是在它的搜尋範圍中沒有找到所需要的類),子類載入器才會嘗試自己去載入類。不過這裡有一點需要注意,每一個類載入器都會快取已經載入過的類,也就是重複載入一個已經存在的類,那麼就會從已經載入的快取中載入,如果從當前類載入的快取中判斷類已經載入過,那麼直接返回,否則會委派類載入請求到父類載入器。這個快取機制在AppClassLoader和ExtensionClassLoader中都存在,至於BootstrapClassLoader未知。

r-l-2

雙親委派模型的優勢:使用雙親委派模型來組織類載入器之間的關係,一個比較顯著的優點是Java類隨著載入它的類載入器一起具備了一種帶有優先順序的層次關係。例如java.lang包中的類庫,它存放在rt.jar中,無論使用哪一個類載入載入java.lang包中的類,最終都是委派給處於模型頂層的啟動類載入器進行載入,因此java.lang包中的類如java.lang.Object類在應用程式中的各類載入器環境中載入的都是同一個類。試想,如果可以使用使用者自定義的ClassLoader去載入java.lang.Object,那麼使用者應用程式中就會出現多個java.lang.Object類,Java型別體系中最基礎的型別也有多個,型別體系的基礎行為無法保證,應用程式也會趨於混亂。如果嘗試編寫rt.jar中已經存在的同類名的類通過自定義的類載入進行載入,將會接收到虛擬機器丟擲的異常。

雙親委派模型的實現:類載入器雙親委派模型的實現提現在ClassLoader的原始碼中,主要是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 {
                //父載入器不為null,說明父載入器不是BootstrapClassLoader
                if (parent != null) {
                    c = parent.loadClass(name, false);
                } else {
                    //父載入器為null,說明父載入器是BootstrapClassLoader
                    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;
     }
}

破壞雙親委派模型

雙親委派模型在Java發展歷史上出現了三次比較大"被破壞"的情況:

  • 1、ClassLoader在JDK1.0已經存在,JDK1.2為了引入雙親委派模型並且需要向前相容,java.lang.ClassLoader類添加了一個新的protected的findClass()方法,在這之前,使用者去繼承java.lang.ClassLoader只能重寫其loadClass()方法才能實現自己的目標。

  • 2、雙親委派模型自身存在缺陷:雙親委派很好地解決了各個類載入器的基礎類的載入的統一問題(越基礎的類由越上層的類載入器載入),這些所謂的基礎類就是大多數情況下作為使用者呼叫的基礎類庫和基礎API,但是無法解決這些基礎類需要回呼叫戶的程式碼這一個問題,典型的例子就是JNDI。JNDI的類庫程式碼是啟動類載入器載入的,但是它需要呼叫獨立廠商實現並且部署在應用的ClassPath的JNDI的服務介面提供者(SPI,即是Service Provider Interface)的程式碼,但是啟動類載入器無法載入ClassPath下的類庫。為了解決這個問題,Java設計團隊引入了不優雅的設計:執行緒上下文類載入器(Thread Context ClassLoader),這個類載入器可以通過java.lang.Thread類的setContextClassLoader()設定,這樣子,JNDI服務就可以使用執行緒上下文類載入器去載入所需的SPI類庫,但是父類載入器中請求子類載入器去載入類這一點已經打破了雙親委派模型。目前,JNDI、JDBC、JCE、JAXB和JBI等模組都是通過此方式實現。

  • 3、基於使用者對應用程式動態性的熱切追求:如程式碼熱替換(HotSwap)、熱模組部署等,說白了就是希望應用程式能像我們的計算機外設那樣可以熱插拔,因此催生出JSR-291以及它的業界實現OSGi,而OSGi定製了自己的類載入規則,不再遵循雙親委派模型,因此它可以通過自定義的類載入器機制輕易實現模組的熱部署。

JDK中提供的資源載入API

前邊花大量的篇幅去分析類載入器的預熱知識,是因為JDK中的資源載入依賴於類載入器(其實類檔案本來就是資原始檔的一種,類載入的過程也是資源載入的過程)。這裡先列舉出JDK中目前常用的資源(Resource)載入的API,先看ClassLoader中提供的方法。

ClassLoader提供的資源載入API

//1.例項方法

public URL getResource(String name)

//這個方法僅僅是呼叫getResource(String name)返回URL例項直接呼叫URL例項的openStream()方法
public InputStream getResourceAsStream(String name)

//這個方法是getResource(String name)方法的複數版本
public Enumeration<URL> getResources(String name) throws IOException

//2.靜態方法

public static URL getSystemResource(String name)

//這個方法僅僅是呼叫getSystemResource(String name)返回URL例項直接呼叫URL例項的openStream()方法
public static InputStream getSystemResourceAsStream(String name)

//這個方法是getSystemResources(String name)方法的複數版本
public static Enumeration<URL> getSystemResources(String name)

總的來看,只有兩個方法需要分析:getResource(String name)getSystemResource(String name)。檢視getResource(String name)的原始碼:

public URL getResource(String name) {
    URL url;
    if (parent != null) {
        url = parent.getResource(name);
    } else {
        url = getBootstrapResource(name);
    }
    if (url == null) {
        url = findResource(name);
    }
    return url;
}

是否似曾相識?這裡明顯就是使用了類載入過程中類似的雙親委派模型進行資源載入,這個方法在API註釋中描述通常用於載入資料資源如images、audio、text等等,資源名稱需要使用路徑分隔符'/'。getResource(String name)方法中查詢的根路徑我們可以通過下面方法驗證:

public class ResourceLoader {

    public static void main(String[] args) throws Exception {
        ClassLoader classLoader = ResourceLoader.class.getClassLoader();
        URL resource = classLoader.getResource("");
        System.out.println(resource);
    }
}

//輸出:file:/D:/Projects/rxjava-seed/target/classes/

很明顯輸出的結果就是當前應用的ClassPath,總結來說:ClassLoader#getResource(String name)是基於使用者應用程式的ClassPath搜尋資源,資源名稱必須使用路徑分隔符'/'去分隔目錄,但是不能以'/'作為資源名的起始,也就是不能這樣使用:classLoader.getResource("/img/doge.jpg")。接著我們再看一下ClassLoader#getSystemResource(String name)的原始碼:

public static URL getSystemResource(String name) {
    //實際上Application ClassLoader一般不會為null
    ClassLoader system = getSystemClassLoader();
    if (system == null) {
        return getBootstrapResource(name);
    }
    return system.getResource(name);
}

此方法優先使用應用程式類載入器進行資源載入,如果應用程式類載入器為null(其實這種情況很少見),則使用啟動類載入器進行資源載入。如果應用程式類載入器不為null的情況下,它實際上退化為ClassLoader#getResource(String name)方法。

總結一下:ClassLoader提供的資源載入的方法中的核心方法是ClassLoader#getResource(String name),它是基於使用者應用程式的ClassPath搜尋資源,遵循"資源載入的雙親委派模型",資源名稱必須使用路徑分隔符'/'去分隔目錄,但是不能以'/'作為資源名的起始字元,其他幾個方法都是基於此方法進行衍生,新增複數操作等其他操作。getResource(String name)方法不會顯示丟擲異常,當資源搜尋失敗的時候,會返回null。

Class提供的資源載入API

java.lang.Class中也提供了資源載入的方法,如下:

public java.net.URL getResource(String name) {
    name = resolveName(name);
    ClassLoader cl = getClassLoader0();
    if (cl==null) {
        // A system class.
        return ClassLoader.getSystemResource(name);
    }
    return cl.getResource(name);
}

public InputStream getResourceAsStream(String name) {
    name = resolveName(name);
    ClassLoader cl = getClassLoader0();
    if (cl==null) {
        // A system class.
        return ClassLoader.getSystemResourceAsStream(name);
    }
    return cl.getResourceAsStream(name);
}

從上面的原始碼來看,Class#getResource(String name)Class#getResourceAsStream(String name)分別比ClassLoader#getResource(String name)ClassLoader#getResourceAsStream(String name)只多了一步,就是搜尋之前先進行資源名稱的預處理resolveName(name),我們重點看這個方法做了什麼:

private String resolveName(String name) {
    if (name == null) {
        return name;
    }
    if (!name.startsWith("/")) {
        Class<?> c = this;
        while (c.isArray()) {
            c = c.getComponentType();
        }
        String baseName = c.getName();
        int index = baseName.lastIndexOf('.');
        if (index != -1) {
            name = baseName.substring(0, index).replace('.', '/')
                    +"/"+name;
         }
    } else {
         name = name.substring(1);
    }
    return name;
}

邏輯相對比較簡單:

  • 1、如果資源名稱以'/'開頭,那麼直接去掉'/',這個時候的資源查詢實際上退化為ClassPath中的資源查詢。
  • 2、如果資源名稱不以'/'開頭,那麼解析出當前類的實際型別(因為當前類有可能是陣列),取出型別的包路徑,替換包路徑中的'.'為'/',再拼接原來的資源名稱。舉個例子:"club.throwable.Main.class"中呼叫了Main.class.getResource("doge.jpg"),那麼這個呼叫的處理資源名稱的結果就是club/throwable/doge.jpg

小結:如果看過我之前寫過的一篇URL和URI相關的文章就清楚,實際上Class#getResource(String name)Class#getResourceAsStream(String name)的資源名稱處理類似於相對URL的處理,而"相對URL的處理"的根路徑就是應用程式的ClassPath。如果資源名稱以'/'開頭,那麼相當於從ClassPath中載入資源,如果資源名稱不以'/'開頭,那麼相當於基於當前類的實際型別的包目錄下載入資源。

實際上類似這樣的資源載入方式在File類中也存在,這裡就不再展開。

小結

理解JDK中的資源載入方式有助於編寫一些通用的基礎元件,像Spring裡面的ResourceLoader、ClassPathResource這裡比較實用的工具也是基於JDK資源載入的方式編寫出來。下一篇博文《淺析JDK中ServiceLoader的原始碼》中的主角ServiceLoader就是基於類載入器的功能實現,它也是SPI中的服務類載入的核心類。

說實話,類載入器的"雙親委派模型"和"破壞雙親委派模型"是常見的面試題相關內容,這裡可以簡單列舉兩個面試題:

  • 1、談談對類載入器的"雙親委派模型"的理解。
  • 2、為什麼要引入執行緒上下文類載入器(或者是對於問題1有打破這個模型的案例嗎)?

希望這篇文章能幫助你理解和解決這兩個問題。

參考資料:

  • 《深入理解Java虛擬機器第二版》
  • JavaSE-8原始碼

(本文完 c-1-d e-20181014)