1. 程式人生 > >真正理解執行緒上下文類載入器(多案例分析)

真正理解執行緒上下文類載入器(多案例分析)

前言

此前我對執行緒上下文類載入器(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的全名為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未知時,為了隔離不同的呼叫者,可以取呼叫者各自的執行緒上下文類載入器代為託管。