- 真正理解线程上下文类加载器(多案例分析)
前言
此前我对线程上下文类加载器(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的定义,JDBC(Java Data Base Connectivity)是一种用于执行SQL语句的Java API,可以为多种关系数据库提供统一访问,它由一组用Java语言编写的类和接口组成。JDBC提供了一种基准,据此可以构建更高级的工具和接口,使数据库开发人员能够编写数据库应用程序。
也就是说JDBC就是java提供的一种SPI,要接入的数据库供应商必须按照此标准来编写实现类。
代码样例
以mysql为例,先看一下驱动注册及获取connection的过程:
1
2
3
4
5
|
// 注册驱动类 Class.forName( "com.mysql.jdbc.Driver" ).getInstance(); // 通过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静态代码块:
1
2
3
4
5
6
7
|
<code><code><code> static { try { java.sql.DriverManager.registerDriver( new Driver()); } catch (SQLException E) { throw new RuntimeException( "Can't register driver!" ); } }</code></code></code> |
registerDriver方法将本类(
new com.mysql.jdbc.Driver())注册到系统的DriverManager中,其实就是add到它的成员常量中,即一个名为
registeredDrivers的
CopyOnWriteArrayList 。
好,接下来的
java.sql.DriverManager.getConnection()才算是进入了正戏。它最终调用了以下方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
|
<code><code><code><code><code><code><code> 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" ); }</code></code></code></code></code></code></code> |
其中线程上下文类加载器的作用已经在上面的注释中详细说明了,由于SPI提供了接口,其中用
connect()方法获取连接,数据库厂商必须实现该方法,然而调用时却是通过SPI里的DriverManager来加载外部实现类并调用
com.mysql.jdbc.Driver.connect()来获取connection,所以这儿只能拜托Thread中保存的AppClassLoader来加载了,完全破坏了双亲委派模式。
当然我们也可以不用SPI接口,直接调用子类的
com.mysql.jdbc.Driver().connect(...)来得到数据库连接,但不推荐这么做(
DriverManager.getConnection()最终就是调用该方法的)。
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 如何访问并不在其加载范围的用户程序呢?
解答
看过JDBC的案例后,答案呼之欲出:spring根本不会去管自己被放在哪里,它统统使用线程上下文加载器来加载类,而线程上下文加载器默认设置为了WebAppClassLoader,也就是说哪个WebApp应用调用了spring,spring就去取该应用自己的WebAppClassLoader来加载bean,简直完美~
源码分析
有兴趣的可以接着看看具体实现。在web.xml中定义的listener为
org.springframework.web.context.ContextLoaderListener,它最终调用了
org.springframework.web.context.ContextLoader类来装载bean,具体方法如下(删去了部分不相关内容):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
|
<code><code><code><code><code><code><code><code><code><code><code><code><code> 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; } }</code></code></code></code></code></code></code></code></code></code></code></code></code> |
具体说明都在注释中,spring考虑到了自己可能被放到其他位置,所以直接用线程上下文类加载器来解决所有可能面临的情况。
总结
通过上面的两个案例分析,我们可以总结出线程上下文类加载器的适用场景:
1. 当高层提供了统一接口让低层去实现,同时又要是在高层加载(或实例化)低层的类时,必须通过线程上下文类加载器来帮助高层的ClassLoader找到并加载该类。
2. 当使用本类托管类加载,然而加载本类的ClassLoader未知时,为了隔离不同的调用者,可以取调用者各自的线程上下文类加载器代为托管。
简而言之就是
ContextClassLoader默认存放了
AppClassLoader的引用,由于它是在运行时被放在了线程中,所以不管当前程序处于何处(BootstrapClassLoader或是ExtClassLoader等),在任何需要的时候都可以用
Thread.currentThread().getContextClassLoader()取出应用程序类加载器来完成需要的操作。