分享

tomcat插件类加载一个“坑”问题排查

 Levy_X 2018-10-13

昨天遇到一个诡异但是很有趣的类加载问题,虽然很快解决了,但是我还是打算剖根问底,分析内部问题出现的原因,毕竟类加载机制虽然说都知道怎么回事,但是还没在实战中实践过,也考虑到有个项目可能需要用到自定义类加载器,趁此机会先初步了解一下。

问题描述

  1. 我采用了Servlet3.0,新增加了SPI加载机制,会自动扫描classpath:META-INF/services/javax.servlet.ServletContainerInitializer中的所有这个文件,并加载其中的所有javax.servlet.ServletContainerInitializer的实现类,实现替换web.xml的功能,让你的项目war可以不需要web.xml也能正常在tomcat运行。
  2. 然后呢,日志我采用了logback,很可爱的是这个jar中ch.qos.logback.classic.servlet.LogbackServletContainerInitializer就实现了javax.servlet.ServletContainerInitializer,因此呢,tomcat在启动时就会自动加载这个类初始化一些配置。
  3. LogbackServletContainerInitializer是在logback-classic包中的,javax.servlet.ServletContainerInitializer是在javax.servlet-api包中的。
  • 有了这些前提信息,我们来说下我遇到的问题,在这样的背景下,我采用tomcat7-maven-plugin进行启动测试

以下tomcat:run...命令为tomcat7-maven-plugin的命令,scope为javax.servlet-api包在maven中的scope。

  1. tomcat:run scope=provided:正常启动
  2. tomcat:run scope=compile:启动失败
  3. tomcat:run-war scope=provided:正常启动
  4. 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类加载器架构

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的话应该优先加载的。

  1. 启动信息分析(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,确保程序正常,这也是我们平时在项目中不太在意这个细节,但是程序仍然能正确执行的原因

  • 我们先区分下这两种启动方式的差别:
  1. tomcat:run scope=compile
    是以你的项目源文件目录作为执行目录的,不会在target目录下生成war文件,如下图:


    tomcat-run目录结构.png

他的好处是什么呢?这是一个开发时工具,你修改代码会自动进行热部署,避免每次改代码都需要重新启动!那么我们可以了解下热部署的原理:深入理解Java类加载器(2):线程上下文类加载器,这是为了开发方便而把类加载过程复杂化了,这个过程暂时不做了解,但是可以大致定位是这个复杂的类加载过程中有bug,导致了加载javax.servlet-api时没像正式部署时WebAppClassLoader正确过滤。

  1. 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。。

    本站是提供个人知识管理的网络存储空间,所有内容均由用户发布,不代表本站观点。请注意甄别内容中的联系方式、诱导购买等信息,谨防诈骗。如发现有害或侵权内容,请点击一键举报。
    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多