昨天遇到一个诡异但是很有趣的类加载问题,虽然很快解决了,但是我还是打算剖根问底,分析内部问题出现的原因,毕竟类加载机制虽然说都知道怎么回事,但是还没在实战中实践过,也考虑到有个项目可能需要用到自定义类加载器,趁此机会先初步了解一下。
问题描述
- 我采用了Servlet3.0,新增加了SPI加载机制,会自动扫描
classpath:META-INF/services/javax.servlet.ServletContainerInitializer
中的所有这个文件,并加载其中的所有javax.servlet.ServletContainerInitializer
的实现类,实现替换web.xml的功能,让你的项目war可以不需要web.xml也能正常在tomcat运行。 - 然后呢,日志我采用了logback,很可爱的是这个jar中
ch.qos.logback.classic.servlet.LogbackServletContainerInitializer
就实现了javax.servlet.ServletContainerInitializer
,因此呢,tomcat在启动时就会自动加载这个类初始化一些配置。 -
LogbackServletContainerInitializer
是在logback-classic包中的,javax.servlet.ServletContainerInitializer
是在javax.servlet-api包中的。
- 有了这些前提信息,我们来说下我遇到的问题,在这样的背景下,我采用tomcat7-maven-plugin进行启动测试
以下tomcat:run...命令为tomcat7-maven-plugin的命令,scope为javax.servlet-api包在maven中的scope。
- tomcat:run + scope=provided:正常启动
- tomcat:run + scope=compile:启动失败
- tomcat:run-war + scope=provided:正常启动
- tomcat:run-war + scope=compile:正常启动
- 诡异了吧,如果是2和4一起启动失败,那我也没什么探索的欲望了,合乎情理,虽然其中还有很多细节模棱两可。
- 另外提前贴下2报错的核心信息:
java.lang.ClassCastException: ch.qos.logback.classic.servlet.LogbackServletContainerInitializer cannot be cast to javax.servlet.ServletContainerInitializer
- 可以明确LogbackServletContainerInitializer是实现了
javax.servlet.ServletContainerInitializer
接口的,这边类型转换失败只有一个原因:类加载器不对!!!
问题排查
先看看这两个类的类加载器
写个Servlet监听器,在启动时打出加载器和jar包信息
public class Callback implements ServletContextListener {
public void doCallback() {
System.out.println("查看看类加载器 ... ");
System.out.println("LogbackServletContainerInitializer = " + LogbackServletContainerInitializer.class.getClassLoader());
System.out.println("ServletContainerInitializer = " + ServletContainerInitializer.class.getClassLoader());
System.out.println("查看加载类所在jar包路径 ... ");
System.out.println("LogbackServletContainerInitializer = " + LogbackServletContainerInitializer.class.getProtectionDomain().getCodeSource().getLocation());
System.out.println("ServletContainerInitializer = " + ServletContainerInitializer.class.getProtectionDomain().getCodeSource().getLocation());
}
@Override
public void contextInitialized(ServletContextEvent servletContextEvent) {
doCallback();
}
@Override
public void contextDestroyed(ServletContextEvent servletContextEvent) {
doCallback();
}
}
在web.xml中配置好,启动,发现:
tomcat:run + scope=compile启动失败,无法打印出类加载信息。。。
tomcat:run + scope=provided
tomcat:run-war + scope=provided
tomcat:run-war + scope=compile
这三个的类加载信息是一致的,如下:
查看看类加载器 ...
LogbackServletContainerInitializer = WebappClassLoader
context:
delegate: false
repositories:
----------> Parent Classloader:
ClassRealm[plugin>org.apache.tomcat.maven:tomcat7-maven-plugin:2.2, parent: sun.misc.Launcher$AppClassLoader@18b4aac2]
ServletContainerInitializer = ClassRealm[plugin>org.apache.tomcat.maven:tomcat7-maven-plugin:2.2, parent: sun.misc.Launcher$AppClassLoader@18b4aac2]
查看加载类所在jar包路径 ...
LogbackServletContainerInitializer = file:/Users/coselding/.m2/repository-weidian/ch/qos/logback/logback-classic/1.2.3/logback-classic-1.2.3.jar
ServletContainerInitializer = file:/Users/coselding/.m2/repository-weidian/org/apache/tomcat/embed/tomcat-embed-core/7.0.47/tomcat-embed-core-7.0.47.jar
tomcat类加载器架构.jpg
结合Tomcat的类加载器架构,ServletContainerInitializer的类加载器ClassRealm应该就是对应的Common ClassLoader,而LogbackServletContainerInitializer就是WebappClassLoader,是Common ClassLoader的子加载器。和上面的场景结合起来就是,如果javax.servlet-api scope=compile,那么javax.servlet-api这个包就会在tomcat/lib下和应用WEB-INF/lib下各有一份,加载器分别是Common ClassLoader和WebappClassLoader。
我们知道JavaEE的规范中在应用间依赖隔离作了规定:***tomcat/lib下和应用WEB-INF/lib如果有相同的依赖,WEB-INF/lib是优先于tomcat/lib的,这个逻辑是为了支持tomcat部署多应用时应用间依赖隔离,打破了双亲委派原则 ***,如下图:
WebAppClassLoader加载逻辑.jpg
因此你的WEB-INF/lib目录下的javax.servlet-api会被会在LogbackServletContainerInitializer加载时加载WebappClassLoader,而Tomcat启动自己加载自己lib目录下的那份WebappClassLoader,导致了ClassCastException。这个过程用图示如下:
ServletContainerInitializer类加载.jpg
因此LogbackServletContainerInitializer实现的ServletContainerInitializer接口和tomcat识别的ServletContainerInitializer不是同一个类加载器加载的,故报错。
- 到这里解决了scope=compile和scope=provided所造成的区别。
- 但是很遗憾,场景2由于类加载失败,程序直接无法启动,我无法查看其类加载器的情况。
tomcat:run和tomcat:run-war的区别
我们用ServletContainerInitializer.class.getProtectionDomain().getCodeSource().getLocation()
打出类加载所在jar包的路径,来确认下,tomcat:run-war加载的到底是哪个类,这段代码由于是放在webapp中的,如果WEB-INF/lib目录下存在javax.servlet-api的话应该优先加载的。
- 启动信息分析(tomcat:run-war + scope=compile)
查看加载类所在jar包路径 ...
LogbackServletContainerInitializer = file:/Users/coselding/Projects/vweex/test/target/test-1.0.0-SNAPSHOT/WEB-INF/lib/logback-classic-1.2.3.jar
ServletContainerInitializer = file:/Users/coselding/.m2/repository-weidian/org/apache/tomcat/embed/tomcat-embed-core/7.0.47/tomcat-embed-core-7.0.47.jar
加载的确实是tomcat内部自带的javax.servlet-api,那我们放在WEB-INF/lib下的javax.servlet-api被忽略了?答案是的!!!我们看更完整的日志:
七月 24, 2018 3:36:57 下午 org.apache.catalina.loader.WebappClassLoader validateJarFile
信息: validateJarFile(/Users/coselding/Projects/vweex/test/target/test-1.0.0-SNAPSHOT/WEB-INF/lib/javax.servlet-api-3.0.1.jar) - jar not loaded. See Servlet Spec 2.3, section 9.7.2. Offending class: javax/servlet/Servlet.class
查看加载类所在jar包路径 ...
LogbackServletContainerInitializer = file:/Users/coselding/Projects/vweex/test/target/test-1.0.0-SNAPSHOT/WEB-INF/lib/logback-classic-1.2.3.jar
ServletContainerInitializer = file:/Users/coselding/.m2/repository-weidian/org/apache/tomcat/embed/tomcat-embed-core/7.0.47/tomcat-embed-core-7.0.47.jar
看见了吗?我们WEB-INF/lib目录下的jar被忽略了,WebappClassLoader在加载时做了校验,给出了警告,但是tomcat自己仍然会加载自身的javax.servlet-api,确保程序正常,这也是我们平时在项目中不太在意这个细节,但是程序仍然能正确执行的原因
- 我们先区分下这两种启动方式的差别:
-
tomcat:run + scope=compile
是以你的项目源文件目录作为执行目录的,不会在target目录下生成war文件,如下图:
tomcat-run目录结构.png
他的好处是什么呢?这是一个开发时工具,你修改代码会自动进行热部署,避免每次改代码都需要重新启动!那么我们可以了解下热部署的原理:深入理解Java类加载器(2):线程上下文类加载器,这是为了开发方便而把类加载过程复杂化了,这个过程暂时不做了解,但是可以大致定位是这个复杂的类加载过程中有bug,导致了加载javax.servlet-api时没像正式部署时WebAppClassLoader正确过滤。
-
tomcat:run-war + scope=compile
会先把你的项目打包成war,再启动tomcat容器加载这个war,所以tomcat:run-war方式和我们在发布系统打包发布的流程是类似的,缺点是这种启动方式你更改代码是不会运行时生效的,需要重新启动,因为代码改动不会影响target/{projectName}目录下的文件,目录结构如下图:
tomcat-run-war目录结构.png
解决方式
主要你保证你的项目依赖中mvn dependency:tree
查到的所有servlet-api依赖都是provided,就能从根源上避免这个问题,这里有个坑:
Servlet2.0依赖坐标
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>servlet-api</artifactId>
<version>2.5</version>
<scope>provided</scope>
</dependency>
Servlet3.0依赖坐标
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>javax.servlet-api</artifactId>
<version>3.0.1</version>
<scope>provided</scope>
</dependency>
呵呵。。。我们的dubbo中这两个包都依赖了,需要全部exclude。。。
参考
总结
在符合双亲委派原则的基础上,我们通常不会遇到上述问题,那为什么要打破双亲委派原则呢?目前来看主要两种情形:
- Tomcat遵循JavaEE标准,需要支持多应用部署时的依赖隔离问题,这就需要子加载器加载类优先于父加载器,否则两个不同的webapp如果依赖了两个不同版本的Spring,可能就出问题了,也如上文所说,Tomcat特做了一些兼容,针对servlet-api等一些特殊的包进行了过滤。
- SPI、JNDI等情形,接口定义在框架层(父加载器),但是实现类却在应用层jar(子加载器),框架启动时却需要去扫描加载子加载器管理范畴内的类,这种情况下采用线程上下文加载器来打破双亲委派原则,帮助实现框架层功能。
因此如果你开发的是应用层程序,这部分内容通常不需要考虑,如果开发的是框架层程序,那用到类加载器时就要心存敬畏之心了!
网友评论