真正理解執行緒上下文類載入器(多案例分析)
前言
此前我對執行緒上下文類載入器(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
那到底是在哪一步自動註冊了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自定義了多個類載入器,這些類載入器按照經典的雙親委派模型來實現,如下圖所示
灰色背景的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未知時,為了隔離不同的呼叫者,可以取呼叫者各自的執行緒上下文類載入器代為託管。