美文网首页
tomcat插件类加载一个“坑”问题排查

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

作者: Coselding | 来源:发表于2018-08-01 17:43 被阅读0次

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

    问题描述

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

    参考

    总结

    在符合双亲委派原则的基础上,我们通常不会遇到上述问题,那为什么要打破双亲委派原则呢?目前来看主要两种情形:

    1. Tomcat遵循JavaEE标准,需要支持多应用部署时的依赖隔离问题,这就需要子加载器加载类优先于父加载器,否则两个不同的webapp如果依赖了两个不同版本的Spring,可能就出问题了,也如上文所说,Tomcat特做了一些兼容,针对servlet-api等一些特殊的包进行了过滤。
    2. SPI、JNDI等情形,接口定义在框架层(父加载器),但是实现类却在应用层jar(子加载器),框架启动时却需要去扫描加载子加载器管理范畴内的类,这种情况下采用线程上下文加载器来打破双亲委派原则,帮助实现框架层功能。
      因此如果你开发的是应用层程序,这部分内容通常不需要考虑,如果开发的是框架层程序,那用到类加载器时就要心存敬畏之心了!

    相关文章

      网友评论

          本文标题:tomcat插件类加载一个“坑”问题排查

          本文链接:https://www.haomeiwen.com/subject/rfbrvftx.html