类加载器
在分析 tomcat 类加载之前,我们简单的回顾下 java 体系的类加载器
- 启动类加载器(Bootstrap ClassLoader):加载对象是java的核心类库,把一些的 java 类加载到 jvm 中,它并不是我们熟悉的
ClassLoader
,而是 jvm 层面由 C/C++ 实现的类加载器,负责加载 $JAVA_HOME/jre/lib 目录下 jvm 指定的类库,它是无法被 java 应用程序直接使用的 - 扩展类加载器(Extension Classloader):它是一个 ClassLoader 实例,父加载器是启动类加载器,它负责加载 $JAVA_HOME/jre/lib/ext 目录的类库
- 应用类加载器(Application ClassLoader):又叫做系统类加载器(System ClassLoader),负责加载用户类路径(-cp参数)指定的类库,可以通过
ClassLoader.getSystemClassLoader()
获取,它也是由启动类加载器加载的 - 自定义类加载器:应用程序根据自己的需求开发的类加载器,可以继承
ClassLoader
,当然也可以不继承
下图描述了类加载器的关系图,其中自定义类加载器有N多个
我们知道 java.lang.ClassLoader
有双亲委派机制(准确的说是单亲,因为只有一个parent),这只是 java 建议的规范,我们也可以不遵循这条规则,但是建议遵循该规则。此外,有一点需要注意的是,类加载器不局限于 ClassLoader
,我们也可以自己实现一个类加载器,只要你加载出来的 Class 符合 jvm 规范即可
我们在日常开发工作中,经常会遇到类冲突的情况,明明 classpath 下面的类有这个方法,但是一旦跑线上环境就出错,比如NoSuchMethodError
、NoClassDefFoundError
、NoClassDefFoundError
等。我们可以使用 jvm 参数 -verbose:class
方便地定位该问题,使用该参数可以快速地定位某个类是从哪个jar包加载的,而不是一味地埋头苦干,求百度,找Google。下面是使用 -verbose:class
jvm 参数的部分日志输出
[Loaded org.springframework.context.annotation.CommonAnnotationBeanPostProcessor from file:/D:/tomcat/webapps/touch/WEB-INF/lib/spring-context-4.3.7.RELEASE.jar]
[Loaded com.alibaba.dubbo.rpc.InvokerListener from file:/D:/tomcat/webapps/touch/WEB-INF/lib/dubbo-2.5.3.jar]
我们有必要了解下关于类加载有几个重要的知识点:
- 在 Java 中我们用完全类名来标识一个类,而在 JVM 层面,使用完全类名 + CloassLoader 对象实例 ID 作为唯一标识,因此使用不同实例的类加载器,加载的两个同名的类,他们的类实例是不同的,并且不能强制转换
- 在双亲委派机制中,类加载器查找类时,是一层层往父类加载器查找的,最后才查看自己,如果都找不到则会抛出异常,而不是一层层往下找的
- 每个运行中的线程都有一个
CloassLoader
,并且会从父线程中继承(默认是应用类加载器),在没有显式声明由哪个类加载器加载类时(比如 new 关键字),将默认由当前线程的类加载器加载该类
由于篇幅有限,关于类加载的过程这里不再展开了,可以参考厮大的博客
- Java虚拟机类加载机制:Java虚拟机类加载机制_朱小厮的博客-优快云博客
tomcat 类加载器
根据实际的应用场景,我们来分析下 tomcat 类加载器需要解决的几个问题
- 为了避免类冲突,每个 webapp 项目中各自使用的类库要有隔离机制
- 不同 webapp 项目支持共享某些类库
- 类加载器应该支持热插拔功能,比如对 jsp 的支持、webapp 的 reload 操作
为了解决以上问题,tomcat设计了一套类加载器,如下图所示。在 Tomcat 里面最重要的是 Common 类加载器,它的父加载器是应用程序类加载器,负责加载 ${catalina.base}/lib
、${catalina.home}/lib
目录下面所有的 .jar 文件和 .class 文件。下图的虚线部分,有 catalina 类加载器、share 类加载器,并且它们的 parent 是 common 类加载器,默认情况下被赋值为 Common 类加载器实例,即 Common 类加载器、catalina 类加载器、 share 类加载器都属于同一个实例。当然,如果我们通过修改 catalina.properties
文件的 server.loader
和 shared.loader
配置,从而指定其创建不同的类加载器
我们先从 Bootstrap
这个入口说起,在执行 init
的时候会实例化类加载器,在初始化类加载器之后立即设置线程上下文类加载器(Thread Context ClassLoader)为 catalina 类加载器,接下来是为 Catalina
组件指定父类加载器。为什么要设置线程上下文的类加载器呢?一方面,很多诸如 ClassUtils
之类的编码,他们在获取 ClassLoader
的时候,都是先尝试从 Thread 上下文中获取 ClassLoader
,例如:ClassLoader cl = Thread.currentThread().getContextClassLoader();
另一方面,在没有显式指定类加载器的情况下,默认使用线程的上下文类加载器加载类,由于 tomcat 的大部分 jar 包都在 ${catalina.hom}/lib
目录,因此需要将线程类加载器指定为 catalina 类加载器,否则加载不了相关的类。
双亲委派模型存在设计上的缺陷,在某些应用场景下,例如加载 SPI 实现(JNDI、JDBC等),如果我们严格遵循双亲委派的一般性原则,使用应用程序类加载器,由于这些 SPI 实现在厂商的 jar 包中,所以应用程序类加载器不可能认识这些代码啊,怎么办?为了解决这个问题,Java 设计团队引入了一个不太优雅的设计:Thread Context ClassLoader
,有了这个线程上下文类加载器,我们便可以做一些“舞弊”的事情了,JNDI 服务可以使用这个类加载器加载 SPI 需要的代码,JDBC、JAXB 也是如此。这样,双亲委派模型便被破坏了。
Bootstrap.java
public void init() throws Exception {
// 初始化commonLoader、catalinaLoader、sharedLoader,关于ClassLoader的后面再看
initClassLoaders();
// 设置上下文类加载器为 catalinaLoader
Thread.currentThread().setContextClassLoader(catalinaLoader);
SecurityClassLoad.securityClassLoad(catalinaLoader);
// 反射方法实例化Catalina,后面初始化Catalina用了很多反射,不知道意图是什么
Class<?> startupClass = catalinaLoader.loadClass("org.apache.catalina.startup.Catalina");
Object startupInstance = startupClass.getConstructor().newInstance();
//TODO 为Catalina对象设置其父加载器为shared类加载器,默认情况下就是catalina类加载器
// 引用Catalina实例
catalinaDaemon = startupInstance;
catalina.properties 文件的相关配置如下所示
common.loader=${catalina.base}/lib,${catalina.base}/lib/*.jar,${catalina.home}/lib,${catalina.home}/lib/*.jar
server.loader=
我们再来看下创建类加载器的代码,首先是创建 common 类加载器,从 catalina.properties
中读取 common.loader
配置作为 common 类加载器的路径。我们注意到 common.loader
中存在 ${catalina.base}
、${catalina.home}
这样的占位符,在读取配置之后,tomcat 会进行替换处理,同理 server.loader
、shared.loader
也可以使用这样的占位符,或者系统变量作为占位符,有兴趣的童鞋可以参考下 Bootstrap.replace(String str)
源码,如果在项目中有相同的场景的话,可以直接 copy 该代码。
Bootstrap.java
private void initClassLoaders() {
try {
// 从catalina.properties中读取common.loader配置作为common类加载的路径
commonLoader = createClassLoader("common", null);
if( commonLoader == null ) {
commonLoader=this.getClass().getClassLoader();
}
// 如果未指定server.loader和shared.loader配置,则catalina和shared类加载器都是common类加载器
catalinaLoader = createClassLoader("server", commonLoader);
sharedLoader = createClassLoader("shared", commonLoader);
} catch (Throwable t) {
handleThrowable(t);
log.error("Class loader creation threw exception", t);
System.exit(1);
}
接下来,我们再来看下 tomcat 是如何创建 common 类加载器的。关键代码如下所示,在创建类加载器时,会读取相关的路径配置,并把路径封装成 Repository
对象,然后交给 ClassLoaderFactory
创建类加载器。
Bootstrap.java
private ClassLoader createClassLoader(String name, ClassLoader parent)
throws Exception {
// 从catalina.propeties中读取配置,并替换 catalina.home、或者catalina.base,或者环境变量
String value = CatalinaProperties.getProperty(name + ".loader");
value = replace(value);
// 遍历目录,并对路径进行处理
List<Repository> repositories = new ArrayList<>();
String[] repositoryPaths = getPaths(value);
for (String repository : repositoryPaths) {
//TODO 将路径封装成 Repository 对象
}
return ClassLoaderFactory.createClassLoader(repositories, parent);
我们再进一步对 ClassLoaderFactory
进行分析,都是细节上的处理,比如利用文件路径构造带有明显协议的 URL 对象,例如本地文件的标准 URL 是 file:/D:/app.jar
。另外,在创建 URLClassLoader
的时候还需要考虑 jdk 对权限控制的影响,因此 tomcat 利用 AccessController
创建 URLClassLoader
,由此可见 tomcat 编码的严谨性。而我们在实际的开发过程中,有时候需要自定义类加载器,但往往不会考虑权限控制这块,所以在对类加载器进行编码时需要注意一下