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

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

问题描述

  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(子加载器),框架启动时却需要去扫描加载子加载器管理范畴内的类,这种情况下采用线程上下文加载器来打破双亲委派原则,帮助实现框架层功能。
    因此如果你开发的是应用层程序,这部分内容通常不需要考虑,如果开发的是框架层程序,那用到类加载器时就要心存敬畏之心了!
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 157,298评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,701评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 107,078评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,687评论 0 202
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,018评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,410评论 1 211
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,729评论 2 310
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,412评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,124评论 1 239
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,379评论 2 242
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,903评论 1 257
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,268评论 2 251
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,894评论 3 233
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,014评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,770评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,435评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,312评论 2 260

推荐阅读更多精彩内容

  • Spring Boot 参考指南 介绍 转载自:https://www.gitbook.com/book/qbgb...
    毛宇鹏阅读 46,301评论 6 344
  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,087评论 18 139
  • 从三月份找实习到现在,面了一些公司,挂了不少,但最终还是拿到小米、百度、阿里、京东、新浪、CVTE、乐视家的研发岗...
    时芥蓝阅读 42,004评论 11 349
  • Spring Web MVC Spring Web MVC 是包含在 Spring 框架中的 Web 框架,建立于...
    Hsinwong阅读 21,757评论 1 92
  • 嘉陵江水长又长, 顺着山脚恣意淌, 不怕前路多坎...
    朗园阅读 306评论 0 0