关于 Tomcat 的类加载器层次,我之前也只是看过文档中的介绍,并未深入理解,直到一次问题排查,对其有了更深入的理解,本文作简要记录。
在一次处理 java.sql.DriverManager
相关的问题时,看到 DriverManager 文档中有以下描述:
Applications no longer need to explicitly load JDBC drivers using Class.forName(). Existing programs which currently load JDBC drivers using Class.forName() will continue to work without modification.
于是果断把一个数据源工具类中的 Class.forName()
方法调用进行了移除。原实现:
1 | package me.tianshuang.util; |
移除对 Class.forName()
的显式调用后:
1 | package me.tianshuang.util; |
然后在本地跑了个单元测试,验证了该工具类没有问题,随即进行发布,发现 未接入过数据源 的 web 应用使用该工具类获取连接时抛出如下异常:
1 | java.sql.SQLException: No suitable driver found for jdbc:mysql://... |
让我感到困惑的是,这明显和文档说的不一致,同样的代码,明明在本地通过单元测试的,在 Tomcat 容器中使用就不行了。于是经过查询文档及 DEBUG,发现与 Tomcat 的类加载器层次有关。
首先我们看看 Servlet 规范 Servlet Specification 2.4 中关于 web 应用类加载器的描述,位于 SRV.9.7.2 Web Application Class Loader:
The class loader that a container uses to load a servlet in a WAR must allow the developer to load any resources contained in library JARs within the WAR following normal J2SE semantics using getResource. As described in the J2EE license agreement, servlet containers that are not part of a J2EE product should not allow the application to override J2SE platform classes, such as those in the java.* and javax.* namespaces, that J2SE does not allow to be modified. Also, servlet containers that are part of a J2EE product should not allow the application to override J2SE or J2EE platform classes, such as those in java.* and javax.* namespaces, that either J2SE or J2EE do not allow to be modified. The container should not allow applications to override or access the container’s implementation classes. It is recommended also that the application class loader be implemented so that classes and resources packaged within the WAR are loaded in preference to classes and resources residing in container-wide library JARs.
规范提到不允许应用覆盖 J2SE 及 J2EE 平台的类,比如 java.*
和 javax.*
命名空间中的类,不允许应用覆盖或访问容器实现的类,推荐将 web 应用类加载器实现为加载 WAR 包中的类优先于容器范围的 JAR。
通过查阅 Apache Tomcat 8 (8.5.72) - Class Loader How-To 中关于类加载器的描述,我们知道,在 Java 环境中,类加载器以父子树形的模式被组合。当一个类加载器被要求加载一个类或者资源时,它将把这个加载请求委托给父类加载器,然后仅在父类加载器找不到请求的类或资源时才自行加载。需要注意的是,web 应用类加载器的模型与此稍有不同,但是主要原理是相同的。
当 Tomcat 启动时,它会创建一组类加载器,这些类加载器组织成以下父子关系,在下图中,父类加载器位于子类加载器上方,层次结构如图所示:
1 | Bootstrap |
上面的说法有一点偏差,准确地说,Bootstrap ClassLoader 是由 JVM 创建,System ClassLoader 是 sun.misc.Launcher
加载时由静态字段实例化创建,根据 Tomcat 文档中对 Bootstrap ClassLoader 的描述,其中提到:
Bootstrap — This class loader contains the basic runtime classes provided by the Java Virtual Machine, plus any classes from JAR files present in the System Extensions directory ($JAVA_HOME/jre/lib/ext). Note: some JVMs may implement this as more than one class loader, or it may not be visible (as a class loader) at all.
对于 Oracle JVM,Tomcat 文档中的 Bootstrap ClassLoader 其实对应的 JVM 中的 Bootstrap ClassLoader 与 Ext ClassLoader,我们以上的图补充为:
1 | Bootstrap |
对于 System ClassLoader,通常使用 CLASSPATH
环境变量的值作为搜索路径,而 Tomcat 的启动脚本 $CATALINA_HOME/bin/catalina.sh
完全忽略了原 CLASSPATH
环境变量的值,重新指定了 CLASSPATH
环境变量的值,具体实现可以参考:catalina.sh。比如在我的电脑上,使用默认的 Tomcat 配置,通过脚本启动 Tomcat,在 Tomcat 容器运行环境下,使用 sun.misc.Launcher.AppClassLoader#getAppClassLoader
初始化系统类加载器时,调用 System.getProperty("java.class.path")
获取到的值为:
1 | /opt/tomcat/bin/bootstrap.jar:/opt/tomcat/bin/tomcat-juli.jar |
扩展类加载器与系统类加载器的创建逻辑可以参见:Launcher.java。
剩下的 Common ClassLoader 与 Webapp ClassLoader 才是由 Tomcat 代码进行的创建,其中 Common ClassLoader 创建部分的源码位于 org.apache.catalina.startup.Bootstrap#initClassLoaders :
1 | private void initClassLoaders() { |
ClassLoaderFactory.createClassLoader(repositories, parent)
的源码位于 ClassLoaderFactory.java:
1 | /** |
创建 Common ClassLoader 时,传入 createClassLoader
的 parent
参数为 null
,我们跟随调用链,先看 return new URLClassLoader(array);
:
1 | public URLClassLoader(URL[] urls) { |
跟随 super()
至 java.security.SecureClassLoader#SecureClassLoader()
:
1 | protected SecureClassLoader() { |
再跟随 super()
至 java.lang.ClassLoader#ClassLoader()
:
1 | protected ClassLoader() { |
可以看出,Common ClassLoader 实际为实例化了一个 URLClassLoader
,其 parent
为系统类加载器,与我们上面描述的层次关系相符合。
根据上面的代码,完成对 commonLoader
的创建后,会创建名为 server
和 shared
的类加载器,这两个类加载器是根据 catalina.properties
配置文件中的 server.loader
与 shared.loader
的配置决定是否创建,默认配置可以参考:catalina.properties,可以看出默认配置中这两个选项的值为空,故默认配置下不会进行创建,直接引用的 commonLoader
, 所以 org.apache.catalina.startup.Bootstrap#initClassLoaders
方法执行后,commonLoader
与 catalinaLoader
与 sharedLoader
均指向的 commonLoader
。随后将这个 commonLoader
设置为了当前线程的上下文类加载器。代码位于 org.apache.catalina.startup.Bootstrap#init():
1 | /** |
自此,我们明确了 commonLoader
的创建过程,但是这个和开始提到的 java.sql.DriverManager
的问题有什么关系呢,此处不得不引出另一篇文档:Apache Tomcat 8 (8.5.72) - JNDI Datasource How-To - DriverManager, the service provider mechanism and memory leaks,根据该文档的描述,java.sql.DriverManager
是支持 Java SPI 服务提供者机制的,该特性能够自动发现、加载和注册通过 META-INF/services/java.sql.Driver
声明的 JDBC 驱动,以使你无需在获取 JDBC 连接前显式加载数据库驱动程序。但是,该实现在所有 Java 版本的 Servlet 容器环境中都被破坏了。原因是 java.sql.DriverManager
只会扫描一次驱动。
以上文档描述是什么意思呢?首先查看 JDK 源码,我们可以知道 java.sql.DriverManager
位于 <JAVA_HOME>/jre/lib/rt.jar
,该目录被 Bootstrap ClassLoader 所加载,且该类只会被加载一次,我们跟随 java.sql.DriverManager
中 SPI 部分的源码来明确相关逻辑,在 java.sql.DriverManager
被加载时,会触发其静态代码块:
1 | /** |
在以上代码中,最为关键的 SPI 部分的方法调用为 ServiceLoader.load(Driver.class);
,该调用将生成一个懒迭代器,我们简要看下相关实现:
1 | /** |
最为关键的部分即为以上代码,其中,从当前线程获取了上下文类加载器用于后续服务实现的加载。那么,我们知道触发 java.sql.DriverManager
时当前线程的上下文类加载器会被用于服务实现的加载,在 Tomcat 启动过程中会去触发 java.sql.DriverManager
的加载吗?根据文档可知,JreMemoryLeakPreventionListener
会触发 java.sql.DriverManager
的加载,其中源码位于 JreMemoryLeakPreventionListener.java:
1 | /** |
注释对该部分代码也进行了解释,触发 java.sql.DriverManager
加载的线程的上下文类加载器就是我们之前提到的 commonLoader
,在根据服务接口查询服务的实现时,是调用的 java.util.ServiceLoader.LazyIterator#hasNextService
去进行服务实现类配置文件的查询,源码如下:
1 | private boolean hasNextService() { |
所以,根据以上代码中的注释及 catalina.properties 中对 commmon.loader
的默认配置,我们知道 commonLoader
默认扫描如下路径:
${catalina.base}/lib
${catalina.base}/lib/*.jar
${catalina.home}/lib
${catalina.home}/lib/*.jar
回到之前的举例,在 web 应用中,我们的 MySQL 驱动程序的 jar 被打入了 WAR 包,不存在于 commonLoader
可以扫描的路径中,也不存在于父类加载器层次上可以扫描的路径中,所以在 web 应用启动后,通过 JreMemoryLeakPreventionListener
触发 java.sql.DriverManager
加载后注册的驱动只包含以上目录中的服务实现,并不包含 /WEB-INF/lib
中的驱动程序,所以在我们未显式调用 Class.forName("com.mysql.jdbc.Driver")
就进行连接的获取就触发了找不到驱动的异常。
那么,如果我们显式调用了 Class.forName("com.mysql.jdbc.Driver")
又是如何解决驱动的加载的呢,我们以 MySQL 驱动为例,在 web 应用中我们使用 Class.forName("com.mysql.jdbc.Driver")
加载 MySQL 驱动,触发的源码为:
1 | /** |
易知 caller
为 web 应用中调用 Class.forName()
的类,该类是由什么类加载器加载的呢?此时就引入了 Webapp ClassLoader,我们查看 Tomcat 部署 webapps
目录下应用的源码 HostConfig.java:
1 | /** |
跟随以上代码的调用链,可以看出会为每个部署的应用创建一个 Webapp ClassLoader,源码位于 WebappLoader.java:
1 | /** |
其中的 parentClassLoader
为我们之前的 commonLoader
,通过反射实例化一个 ParallelWebappClassLoader
,自此,就与 Tomcat 官方文档中 ClassLoader 的层次图完全一致了,继续跟随源码的话,可以看出会为该 Webapp ClassLoader 设置资源、是否委托加载、类路径等属性,然后在 StandardContext.java 将创建的 Webapp ClassLoader 绑定到当前线程,此处不再粘贴代码。
到此我们知道自行编写的代码的类由 Webapp ClassLoader 加载,该类加载器能够扫描对应应用下以下路径的类及资源:
/WEB-INF/classes
/WEB-INF/lib
因为我们的 MySQL 驱动程序的 jar 包位于我们 web 应用的 /WEB-INF/lib
目录下,所以,当我们在 web 应用中显式调用 Class.forName("com.mysql.jdbc.Driver")
时,当前的上下文类加载器为 Webapp ClassLoader,所以 MySQL 驱动程序能被正确加载,然后触发后续的驱动注册逻辑 Driver.java:
1 | public class Driver extends NonRegisteringDriver implements java.sql.Driver { |
由以上代码可知,在加载 com.mysql.jdbc.Driver
类时,执行了静态代码块,将该驱动实例化并进行了注册,从而保证了后续获取连接的正常运行。
根据以上的分析,我们知道,在默认配置下,Tomcat 触发 java.sql.DriverManager
类加载时使用的 commonLoader
,仅会注册该类加载器及父级层次可见范围中的驱动程序,打包在 web 应用中的驱动程序默认不会被自动注册。因此,在其 WEB-INF/lib
目录中具有数据库驱动程序的 web 应用程序不能依赖于 SPI 机制,而应显式注册驱动程序。
最后,我们再看看 Webapp ClassLoader 加载类的顺序,根据 Tomcat 的文档,从 web 应用的角度来看,在默认配置下,类或资源按照以下顺序进行搜索:
- Bootstrap classes of your JVM
/WEB-INF/classes
of your web application/WEB-INF/lib/*.jar
of your web application- System class loader classes
- Common class loader classes
排在首位的是 JRE 的一些基础类,如 java.
或 javax.
开头的类,因为这些类不应该被覆盖,随后才是 web 应用自身的类及依赖的 jar 中的类,然后才是 System ClassLoader 和 Common ClassLoader 中的类,我们结合代码看看,其中 Webapp ClassLoader 加载类的方法位于:org.apache.catalina.loader.WebappClassLoaderBase#loadClass(java.lang.String, boolean):
1 | /** |
其中注释 (0)
及 (0.1)
首先是检查类是否已经加载过,如果已经加载过则直接返回加载过的类,注释 (0.2)
在 Oracle JVM 实现中是使用 javaseLoader
即 Ext ClassLoader 去加载 JRE 的基类,因为默认配置的情况下 delegate
为 false
,所以如果之前没加载到类会执行注释 (2)
的逻辑加载 web 应用的类,若仍然未加载到则执行注释 (3)
委托给当前 ParallelWebappClassLoader 的 parent
即 Common ClassLoader 进行类的加载,而 Common ClassLoader 又会先委托给其父类加载器 System ClassLoader 进行加载,所以加载顺序如文档所示,如果以上逻辑执行完都没有加载到类,则最后抛出 ClassNotFoundException
。
类似的实现在 Flink 中也存在,在 Flink 中被称为 child-first,可以参考文档:Debugging Classloading | Apache Flink。
Reference
Difference between JVM and HotSpot? - Stack Overflow
Apache Tomcat 8 Configuration Reference (8.5.72) - The Loader Component
Optimize the comment about class loader by tianshuang · Pull Request #455 · apache/tomcat · GitHub
How Tomcat Classloader separates different Webapps object scope in same JVM? - Stack Overflow
About the comment of org.apache.tomcat.util.threads.TaskQueue
flink/ChildFirstClassLoader.java at release-1.14.4 · apache/flink · GitHub