1. 程式人生 > 其它 >Java-ThreadContextLoader(執行緒上線文類載入器)總結

Java-ThreadContextLoader(執行緒上線文類載入器)總結

Java 提供了很多服務提供者介面(Service Provider Interface,SPI),允許第三方為這些介面提供實現。常見的 SPI 有 JDBC、JCE、JNDI、JAXP 和 JBI 等。
這些 SPI 的介面由 Java 核心庫來提供,而這些 SPI 的實現程式碼則是作為 Java 應用所依賴的 jar 包被包含進類路徑(CLASSPATH)裡。SPI介面中的程式碼經常需要載入具體的實現類。那麼問題來了,SPI的介面是Java核心庫的一部分,是由引導類載入器來載入的;SPI的實現類是由系統類載入器來載入的。引導類載入器是無法找到 SPI 的實現類的,因為依照雙親委派模型,BootstrapClassloader無法委派AppClassLoader來載入類。

而執行緒上下文類載入器破壞了“雙親委派模型”,可以在執行執行緒中拋棄雙親委派載入鏈模式,使程式可以逆向使用類載入器。一直困惱我的問題就是,它是如何打破了雙親委派模型?又是如何逆向使用類載入器了?
JDBC案例分析
先來看下JDBC的定義,JDBC(Java Data Base Connectivity)是一種用於執行SQL語句的Java API,可以為多種關係資料庫提供統一訪問,它由一組用Java語言編寫的類和介面組成。JDBC提供了一種基準,據此可以構建更高階的工具和介面,使資料庫開發人員能夠編寫資料庫應用程式。也就是說JDBC就是java提供的一種SPI,要接入的資料庫供應商必須按照此標準來編寫實現類。

程式碼

// 註冊驅動類
Class.forName("com.mysql.jdbc.Driver").getInstance(); 
String url = "jdbc:mysql://localhost:3306/testdb";    
// 通過java庫獲取資料庫連線
Connection conn = java.sql.DriverManager.getConnection(url, "name", "password");

解讀
Class.forName()載入了com.mysql.jdbc.Driver類,注意該類是java.sql.Driver介面的實現(class Driver extends NonRegisteringDriver implements java.sql.Driver),它們名字相同,在下面的描述中將帶上package名避免混淆。


它將執行其static靜態程式碼塊:

try {
   java.sql.DriverManager.registerDriver(new Driver());
} catch (SQLException E) {
    throw new RuntimeException("Can't register driver!");
}

registerDriver方法將本類(new com.mysql.jdbc.Driver())註冊到系統的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啟動類載入器
      * 但是在上一篇文章中講到過啟動類載入器無法被程式獲取,所以只會得到null
      * 這時問題來了,DriverManager是啟動類載入器載入的,可偏偏又要在這兒載入子類的Class
      * 子類是通過jar包的方式放入classpath中的,由AppClassLoader載入
      * 因此這兒通過雙親委派方式肯定無法載入成功,因此這兒藉助
      * ContextClassLoader來載入mysql驅動類(簡直作弊啊!)
      * 上一篇文章最後也講到了Thread.currentThread().getContextClassLoader()
      * 預設set了AppClassLoader,也就是說把類載入器放到Thread裡,那麼執行方法時任何地方都可以獲取到它。
      */
     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類,如果你沒有修改ContextClassLoader,那麼預設的AppClassLoader肯定可以載入
         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");
 }

其中執行緒上下文類載入器的作用已經在上面的註釋中詳細說明了,由於SPI提供了介面,其中用connect()方法獲取連線,資料庫廠商必須實現該方法,然而呼叫時卻是通過SPI裡的DriverManager來載入外部實現類並呼叫com.mysql.jdbc.Driver.connect()來獲取connection,所以這兒只能拜託Thread中儲存的AppClassLoader來載入了,完全破壞了雙親委派模式。
當然我們也可以不用SPI介面,直接呼叫子類的com.mysql.jdbc.Driver().connect(…)來得到資料庫連線,但不推薦這麼做(DriverManager.getConnection()最終就是呼叫該方法的)。
Tomcat與spring的類載入器案例
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自定義了多個類載入器,這些類載入器按照經典的雙親委派模型來實現,如下圖所示

灰色背景的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 如何訪問並不在其載入範圍的使用者程式呢?
解答
看過JDBC的案例後,答案呼之欲出:spring根本不會去管自己被放在哪裡,它統統使用執行緒上下文載入器來載入類,而執行緒上下文載入器預設設定為了WebAppClassLoader,也就是說哪個WebApp應用呼叫了spring,spring就去取該應用自己的WebAppClassLoader來載入bean,簡直完美~
原始碼分析
有興趣的可以接著看看具體實現。在web.xml中定義的listener為org.springframework.web.context.ContextLoaderListener,它最終呼叫了org.springframework.web.context.ContextLoader類來裝載bean,具體方法如下(刪去了部分不相關內容):

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考慮到了自己可能被放到其他位置,所以直接用執行緒上下文類載入器來解決所有可能面臨的情況。
總結
通過上面的兩個案例分析,我們可以總結出執行緒上下文類載入器的適用場景:

  • 當高層提供了統一介面讓低層去實現,同時又要是在高層載入(或例項化)低層的類時,必須通過執行緒上下文類載入器來幫助高層的ClassLoader找到並載入該類。
  • 當使用本類託管類載入,然而載入本類的ClassLoader未知時,為了隔離不同的呼叫者,可以取呼叫者各自的執行緒上下文類載入器代為託管。

簡而言之就是ContextClassLoader預設存放了AppClassLoader的引用,由於它是在執行時被放在了執行緒中,所以不管當前程式處於何處(BootstrapClassLoader或是ExtClassLoader等),在任何需要的時候都可以用Thread.currentThread().getContextClassLoader()取出應用程式類載入器來完成需要的操作。

郭慕榮部落格園