真正理解線程上下文類加載器(多案例分析)

前置知識: java類加載器不完整分析

前言

此前我對線程上下文類加載器(ThreadContextClassLoader,下文使用TCCL表示)的理解僅僅侷限於下面這段話:

Java 提供了很多服務提供者接口(Service Provider Interface,SPI),允許第三方爲這些接口提供實現。常見的 SPI 有 JDBC、JCE、JNDI、JAXP 和 JBI 等。

這些 SPI 的接口由 Java 核心庫來提供,而這些 SPI 的實現代碼則是作爲 Java 應用所依賴的 jar 包被包含進類路徑(CLASSPATH)裏。SPI接口中的代碼經常需要加載具體的實現類。那麼問題來了,SPI的接口是Java核心庫的一部分,是由啓動類加載器(Bootstrap Classloader)來加載的;SPI的實現類是由系統類加載器(System ClassLoader)來加載的。引導類加載器是無法找到 SPI 的實現類的,因爲依照雙親委派模型,BootstrapClassloader無法委派AppClassLoader來加載類。

而線程上下文類加載器破壞了“雙親委派模型”,可以在執行線程中拋棄雙親委派加載鏈模式,使程序可以逆向使用類加載器。

一直困惱我的問題就是,它是如何打破了雙親委派模型?又是如何逆向使用類加載器了?直到今天看了jdbc的驅動加載過程才茅塞頓開,其實並不複雜,只是一直沒去看代碼導致理解不夠到位。

JDBC案例分析

我們先來看平時是如何使用mysql獲取數據庫連接的:

// 加載Class到AppClassLoader(系統類加載器),然後註冊驅動類
// Class.forName("com.mysql.jdbc.Driver").newInstance(); 
String url = "jdbc:mysql://localhost:3306/testdb";    
// 通過java庫獲取數據庫連接
Connection conn = java.sql.DriverManager.getConnection(url, "name", "password"); 

以上就是mysql註冊驅動及獲取connection的過程,各位可以發現經常寫的Class.forName被註釋掉了,但依然可以正常運行,這是爲什麼呢?這是因爲從Java1.6開始自帶的jdbc4.0版本已支持SPI服務加載機制,只要mysql的jar包在類路徑中,就可以註冊mysql驅動。

那到底是在哪一步自動註冊了mysql driver的呢?重點就在DriverManager.getConnection()中。我們都是知道調用類的靜態方法會初始化該類,進而執行其靜態代碼塊,DriverManager的靜態代碼塊就是:

static {
    loadInitialDrivers();
    println("JDBC DriverManager initialized");
}

初始化方法loadInitialDrivers()的代碼如下:

private static void loadInitialDrivers() {
    String drivers;
    try {
        // 先讀取系統屬性
        drivers = AccessController.doPrivileged(new PrivilegedAction<String>() {
            public String run() {
                return System.getProperty("jdbc.drivers");
            }
        });
    } catch (Exception ex) {
        drivers = null;
    }
    // 通過SPI加載驅動類
    AccessController.doPrivileged(new PrivilegedAction<Void>() {
        public Void run() {
            ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
            Iterator<Driver> driversIterator = loadedDrivers.iterator();
            try{
                while(driversIterator.hasNext()) {
                    driversIterator.next();
                }
            } catch(Throwable t) {
                // Do nothing
            }
            return null;
        }
    });
    // 繼續加載系統屬性中的驅動類
    if (drivers == null || drivers.equals("")) {
        return;
    }

    String[] driversList = drivers.split(":");
    println("number of Drivers:" + driversList.length);
    for (String aDriver : driversList) {
        try {
            println("DriverManager.Initialize: loading " + aDriver);
            // 使用AppClassloader加載
            Class.forName(aDriver, true,
                    ClassLoader.getSystemClassLoader());
        } catch (Exception ex) {
            println("DriverManager.Initialize: load failed: " + ex);
        }
    }
}

從上面可以看出JDBC中的DriverManager的加載Driver的步驟順序依次是:
1. 通過SPI方式,讀取 META-INF/services 下文件中的類名,使用TCCL加載;
2. 通過System.getProperty("jdbc.drivers")獲取設置,然後通過系統類加載器加載。
下面詳細分析SPI加載的那段代碼。

JDBC中的SPI

先來看看什麼是SP機制,引用一段博文中的介紹:

SPI機制簡介
SPI的全名爲Service Provider Interface,主要是應用於廠商自定義組件或插件中。在java.util.ServiceLoader的文檔裏有比較詳細的介紹。簡單的總結下java SPI機制的思想:我們系統裏抽象的各個模塊,往往有很多不同的實現方案,比如日誌模塊、xml解析模塊、jdbc模塊等方案。面向的對象的設計裏,我們一般推薦模塊之間基於接口編程,模塊之間不對實現類進行硬編碼。一旦代碼裏涉及具體的實現類,就違反了可拔插的原則,如果需要替換一種實現,就需要修改代碼。爲了實現在模塊裝配的時候能不在程序裏動態指明,這就需要一種服務發現機制。 Java SPI就是提供這樣的一個機制:爲某個接口尋找服務實現的機制。有點類似IOC的思想,就是將裝配的控制權移到程序之外,在模塊化設計中這個機制尤其重要。
SPI具體約定
Java SPI的具體約定爲:當服務的提供者提供了服務接口的一種實現之後,在jar包的META-INF/services/目錄裏同時創建一個以服務接口命名的文件。該文件裏就是實現該服務接口的具體實現類。而當外部程序裝配這個模塊的時候,就能通過該jar包META-INF/services/裏的配置文件找到具體的實現類名,並裝載實例化,完成模塊的注入。基於這樣一個約定就能很好的找到服務接口的實現類,而不需要再代碼裏制定。jdk提供服務實現查找的一個工具類:java.util.ServiceLoader

知道SPI的機制後,我們來看剛纔的代碼:

ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
Iterator<Driver> driversIterator = loadedDrivers.iterator();

try{
    while(driversIterator.hasNext()) {
        driversIterator.next();
    }
} catch(Throwable t) {
// Do nothing
}

注意driversIterator.next()最終就是調用Class.forName(DriverName, false, loader)方法,也就是最開始我們註釋掉的那一句代碼。好,那句因SPI而省略的代碼現在解釋清楚了,那我們繼續看給這個方法傳的loader是怎麼來的。

因爲這句Class.forName(DriverName, false, loader)代碼所在的類在java.util.ServiceLoader類中,而ServiceLoader.class又加載在BootrapLoader中,因此傳給 forName 的 loader 必然不能是BootrapLoader,複習雙親委派加載機制請看:java類加載器不完整分析 。這時候只能使用TCCL了,也就是說把自己加載不了的類加載到TCCL中(通過Thread.currentThread()獲取,簡直作弊啊!)。上面那篇文章末尾也講到了TCCL默認使用當前執行的是代碼所在應用的系統類加載器AppClassLoader。

再看下看ServiceLoader.load(Class)的代碼,的確如此:

public static <S> ServiceLoader<S> load(Class<S> service) {
    ClassLoader cl = Thread.currentThread().getContextClassLoader();
    return ServiceLoader.load(service, cl);
}

ContextClassLoader默認存放了AppClassLoader的引用,由於它是在運行時被放在了線程中,所以不管當前程序處於何處(BootstrapClassLoader或是ExtClassLoader等),在任何需要的時候都可以用Thread.currentThread().getContextClassLoader()取出應用程序類加載器來完成需要的操作。

到這兒差不多把SPI機制解釋清楚了。直白一點說就是,我(JDK)提供了一種幫你(第三方實現者)加載服務(如數據庫驅動、日誌庫)的便捷方式,只要你遵循約定(把類名寫在/META-INF裏),那當我啓動時我會去掃描所有jar包裏符合約定的類名,再調用forName加載,但我的ClassLoader是沒法加載的,那就把它加載到當前執行線程的TCCL裏,後續你想怎麼操作(驅動實現類的static代碼塊)就是你的事了。

好,剛纔說的驅動實現類就是com.mysql.jdbc.Driver.Class,它的靜態代碼塊裏頭又寫了什麼呢?是否又用到了TCCL呢?我們繼續看下一個例子。

使用TCCL校驗實例的歸屬

com.mysql.jdbc.Driver加載後運行的靜態代碼塊:

static {
    try {
        // Driver已經加載到TCCL中了,此時可以直接實例化
        java.sql.DriverManager.registerDriver(new com.mysql.jdbc.Driver());
    } catch (SQLException E) {
        throw new RuntimeException("Can't register driver!");
    }
}

registerDriver方法將driver實例註冊到系統的java.sql.DriverManager類中,其實就是add到它的一個名爲registeredDrivers的靜態成員CopyOnWriteArrayList中 。

到此驅動註冊基本完成,接下來我們回到最開始的那段樣例代碼:java.sql.DriverManager.getConnection()。它最終調用了以下方法:

private static Connection getConnection(
     String url, java.util.Properties info, Class<?> caller) throws SQLException {
     /* 傳入的caller由Reflection.getCallerClass()得到,該方法
      * 可獲取到調用本方法的Class類,這兒調用者是java.sql.DriverManager(位於/lib/rt.jar中),
      * 也就是說caller.getClassLoader()本應得到Bootstrap啓動類加載器
      * 但是在上篇文章[java類加載器不完整分析]中講到過啓動類加載器無法被程序獲取,所以只會得到null
      */
     ClassLoader callerCL = caller != null ? caller.getClassLoader() : null;
     synchronized(DriverManager.class) {
         // 此處再次獲取線程上下文類加載器,用於後續校驗
         if (callerCL == null) {
             callerCL = Thread.currentThread().getContextClassLoader();
         }
     }

     if(url == null) {
         throw new SQLException("The url cannot be null", "08001");
     }

     SQLException reason = null;
     // 遍歷註冊到registeredDrivers裏的Driver類
     for(DriverInfo aDriver : registeredDrivers) {
         // 使用線程上下文類加載器檢查Driver類有效性,重點在isDriverAllowed中,方法內容在後面
         if(isDriverAllowed(aDriver.driver, callerCL)) {
             try {
                 println("    trying " + aDriver.driver.getClass().getName());
                 // 調用com.mysql.jdbc.Driver.connect方法獲取連接
                 Connection con = aDriver.driver.connect(url, info);
                 if (con != null) {
                     // Success!
                     return (con);
                 }
             } catch (SQLException ex) {
                 if (reason == null) {
                     reason = ex;
                 }
             }

         } else {
             println("    skipping: " + aDriver.getClass().getName());
         }

     }
     throw new SQLException("No suitable driver found for "+ url, "08001");
 }
private static boolean isDriverAllowed(Driver driver, ClassLoader classLoader) {
    boolean result = false;
    if(driver != null) {
        Class<?> aClass = null;
        try {
        // 傳入的classLoader爲調用getConnetction的線程上下文類加載器,從中尋找driver的class對象
            aClass =  Class.forName(driver.getClass().getName(), true, classLoader);
        } catch (Exception ex) {
            result = false;
        }
    // 注意,只有同一個類加載器中的Class使用==比較時纔會相等,此處就是校驗用戶註冊Driver時該Driver所屬的類加載器與調用時的是否同一個
    // driver.getClass()拿到就是當初執行Class.forName("com.mysql.jdbc.Driver")時的應用AppClassLoader
        result = ( aClass == driver.getClass() ) ? true : false;
    }

    return result;
}

可以看到這兒TCCL的作用主要用於校驗存放的driver是否屬於調用線程的Classloader。例如在下文中的tomcat裏,多個webapp都有自己的Classloader,如果它們都自帶 mysql-connect.jar包,那底層Classloader的DriverManager裏將註冊多個不同類加載器的Driver實例,想要區分只能靠TCCL了。

Tomcat與spring的類加載器案例

接下來將介紹《深入理解java虛擬機》一書中的案例,並解答它所提出的問題。(部分類容來自於書中原文)

Tomcat中的類加載器

在Tomcat目錄結構中,有三組目錄(“/common/*”,“/server/*”和“shared/*”)可以存放公用Java類庫,此外還有第四組Web應用程序自身的目錄“/WEB-INF/*”,把java類庫放置在這些目錄中的含義分別是:

  • 放置在common目錄中:類庫可被Tomcat和所有的Web應用程序共同使用。
  • 放置在server目錄中:類庫可被Tomcat使用,但對所有的Web應用程序都不可見。
  • 放置在shared目錄中:類庫可被所有的Web應用程序共同使用,但對Tomcat自己不可見。
  • 放置在/WebApp/WEB-INF目錄中:類庫僅僅可以被此Web應用程序使用,對Tomcat和其他Web應用程序都不可見。

爲了支持這套目錄結構,並對目錄裏面的類庫進行加載和隔離,Tomcat自定義了多個類加載器,這些類加載器按照經典的雙親委派模型來實現,如下圖所示
Tomcat中的類加載器

灰色背景的3個類加載器是JDK默認提供的類加載器,這3個加載器的作用前面已經介紹過了。而 CommonClassLoader、CatalinaClassLoader、SharedClassLoader 和 WebAppClassLoader 則是 Tomcat 自己定義的類加載器,它們分別加載 /common/*、/server/*、/shared/* 和 /WebApp/WEB-INF/* 中的 Java 類庫。其中 WebApp 類加載器和 Jsp 類加載器通常會存在多個實例,每一個 Web 應用程序對應一個 WebApp 類加載器,每一個 JSP 文件對應一個 Jsp 類加載器。

從圖中的委派關係中可以看出,CommonClassLoader 能加載的類都可以被 CatalinaClassLoader 和 SharedClassLoader 使用,而 CatalinaClassLoader 和 SharedClassLoader 自己能加載的類則與對方相互隔離。WebAppClassLoader 可以使用 SharedClassLoader 加載到的類,但各個 WebAppClassLoader 實例之間相互隔離。而 JasperLoader 的加載範圍僅僅是這個 JSP 文件所編譯出來的那一個 Class,它出現的目的就是爲了被丟棄:當服務器檢測到 JSP 文件被修改時,會替換掉目前的 JasperLoader 的實例,並通過再建立一個新的 Jsp 類加載器來實現 JSP 文件的 HotSwap 功能。

Spring加載問題

Tomcat 加載器的實現清晰易懂,並且採用了官方推薦的“正統”的使用類加載器的方式。這時作者提一個問題:如果有 10 個 Web 應用程序都用到了spring的話,可以把Spring的jar包放到 common 或 shared 目錄下讓這些程序共享。Spring 的作用是管理每個web應用程序的bean,getBean時自然要能訪問到應用程序的類,而用戶的程序顯然是放在 /WebApp/WEB-INF 目錄中的(由 WebAppClassLoader 加載),那麼在 CommonClassLoader 或 SharedClassLoader 中的 Spring 容器如何去加載並不在其加載範圍的用戶程序(/WebApp/WEB-INF/)中的Class呢?

解答

答案呼之欲出:spring根本不會去管自己被放在哪裏,它統統使用TCCL來加載類,而TCCL默認設置爲了WebAppClassLoader,也就是說哪個WebApp應用調用了spring,spring就去取該應用自己的WebAppClassLoader來加載bean,簡直完美~

源碼分析

有興趣的可以接着看看具體實現。在web.xml中定義的listener爲org.springframework.web.context.ContextLoaderListener,它最終調用了org.springframework.web.context.ContextLoader類來裝載bean,具體方法如下(刪去了部分不相關內容):

public WebApplicationContext initWebApplicationContext(ServletContext servletContext) {
    try {
        // 創建WebApplicationContext
        if (this.context == null) {
            this.context = createWebApplicationContext(servletContext);
        }
        // 將其保存到該webapp的servletContext中     
        servletContext.setAttribute(WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE, this.context);
        // 獲取線程上下文類加載器,默認爲WebAppClassLoader
        ClassLoader ccl = Thread.currentThread().getContextClassLoader();
        // 如果spring的jar包放在每個webapp自己的目錄中
        // 此時線程上下文類加載器會與本類的類加載器(加載spring的)相同,都是WebAppClassLoader
        if (ccl == ContextLoader.class.getClassLoader()) {
            currentContext = this.context;
        }
        else if (ccl != null) {
            // 如果不同,也就是上面說的那個問題的情況,那麼用一個map把剛纔創建的WebApplicationContext及對應的WebAppClassLoader存下來
            // 一個webapp對應一個記錄,後續調用時直接根據WebAppClassLoader來取出
            currentContextPerThread.put(ccl, this.context);
        }

        return this.context;
    }
    catch (RuntimeException ex) {
        logger.error("Context initialization failed", ex);
        throw ex;
    }
    catch (Error err) {
        logger.error("Context initialization failed", err);
        throw err;
    }
}

具體說明都在註釋中,spring考慮到了自己可能被放到其他位置,所以直接用TCCL來解決所有可能面臨的情況。

總結

通過上面的兩個案例分析,我們可以總結出線程上下文類加載器的適用場景:
1. 當高層提供了統一接口讓低層去實現,同時又要是在高層加載(或實例化)低層的類時,必須通過線程上下文類加載器來幫助高層的ClassLoader找到並加載該類。
2. 當使用本類託管類加載,然而加載本類的ClassLoader未知時,爲了隔離不同的調用者,可以取調用者各自的線程上下文類加載器代爲託管。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章