最近在开发环境中进行项目部署测试,再次遇一个鬼故事。项目maven打包正常,Tomcat上项目正常启动,但是在调用某个类的特定方法时,出现NoSuchMethodError异常,反编确认对应的JAR包中包含该类和对应的方法,但是JVM依旧报错。
异常信息
1 |
|
异常分析
项目是在尝试调用Google Guava Splitter中的splitToList方法时,发现该方法未定义。
首先,项目编译时正常,所以在此过程中,是能找到对应的类和方法,那么为什么会出现在运行时找不到的情况呢?
其次,抛出的异常是NoSuchMethodError,说明ClassLoader成功加载到了对应的类,只是在进行方法调用时,发现方法不存在。
针对上面的分析,进行一些猜想的验证:
1.maven打包使用了低版本的Guava,而低版本中没有对应的方法
理论上来说,这种情况不太可能,如果使用了没有对应方法的低版本,那么打包编译是会失败的,实际上反编后,也是可以在对应的类中找到该方法的;
2.JAR冲突,导致使用了低版本的Guava,而低版本中没有对应的方法
然鹅,事实证明不是,maven依赖树中只出现了一个guava包的引用,所以排除这种情况;
3.Tomcat进行Class加载时,加载了低版本的Guava,而低版本中没有对应的方法
于是,我把项目打成了JAR,引入tomcat模块,放到测试环境中运行,一切正常~那么基本可以判断是和服务器Tomcat或JAVA环境有一定的关系,为了弄清楚在加载该类时,是从哪里加载的,在Tomcat启动参数中,加入-XX:+TraceClassLoading -XX:+TraceClassUnloading,用于跟踪Tomcat类加载和卸载,并在日志中进行查看,恩。。。然后发现:
恩。。。。。。。
恩。。。。。。。。
恩。。。。。。。。。
嘛玩意儿这是。。。。。
根本就是不是从我的JAR包中加载的。。。。
从上面的图可以看出,在进行Guava Splitter类加载的时候,从JRE的扩展LIB库中进行了该类的加载,那么为什么没有从项目包中加载,而是从扩展LIB库中进行加载的呢?
可以看出,只有在项目部署在服务器Tomcat上的时候,才出现了相应的问题,那么很可能是和服务器中的Tomcat或JAVA环境有关。
首先,我们来看一下Tomcat是如何做类加载和管理的。
(https://tomcat.apache.org/tomcat-8.0-doc/class-loader-howto.html)
Tomcat使用一系列不同的ClassLoader来加载和管理一些基础的常规类,它们是能被所有WEB应用同时使用的,包括JVM的基础运行时类、Tomcat内部类等。而单个应用部署在Tomcat的独立容器中,每个容器中的WEB应用对应的ClassLoader相互隔离,以此来实现隔离。Tomcat中ClassLoader之间是呈现层级关系的,具体结构如下图所示:
其中,
Bootstrap class loader 用于加载JVM运行时基础类,同时也包含了扩展JAR包目录下的所有类($JAVA_HOME/jre/lib/ext);
System class loader 一般从CLASSPATH环境变量中进行类初始化,所有的这些对于Tomcat内部类和WEB应用都是可见的。然后,标准的Tomcat启动脚本是会忽略CLASSPATH环境变量,而使用下列仓库进行代替:
1.$CATALINA_HOME/bin/bootstrap.jar 包含用于初始化Tomcat的所需类;
2.$CATALINA_BASE/bin/tomcat-juli.jar or $CATALINA_HOME/bin/tomcat-juli.jar 日志相关的实现类;
3.$CATALINA_HOME/bin/commons-daemon.jar Apache Commons Daemon 中的类,Linux下可用于实现后台服务,Windows下可用于实现注册为系统服务。
Common class loader 包含附加的类,对于Tomcat内部类和所有的WEB应用都是可见的,默认查找CATALINA_HOME和CATALINA_BASE目录下的lib目录;
WebappX class loader 为每一个部署在Tomcat中WEB应用实例创建的ClassLoader,包括/WEB-INF/classes下所有解包的类和资源,同时也包含/WEB-INF/lib目录下JAR包中的类和资源。
由于前三个ClassLoader是各个WEB应用通用的,当需要加载一个类时,会优先按顺序上最上层ClassLoader进行加载,当一个ClassLoader在对应的目录中没有找到对应的类时,就依次交给下一层级的ClassLoader进行加载。如果最终都没有找到对应的类,则抛出NoClassDefFoundError。
综上所述,当尝试进行Google Guava Splitter类的加载时,首先交由Bootstrap class loader进行尝试性的加载,然后在对应目录中的JAR包中进行搜索时,居然从jre/lib/ext目录下的JAR包中找到了对应的类,所以优先进行了加载,而在对该JAR包进行反编后,发现确实包含了Google Guava,并且对应的Guava版本是14.0.1,而在对应的版本下,Guava Splitter确实还没有splitToList方法,所以最终导致类加载成功,但是找不到对应的方法,抛出NoSuchMethodError。
异常原因
当尝试进行Google Guava Splitter类的加载时,Bootstrap class loader优先找到并加载了低版本的Guava Splitter,而低版本中没有包含splitToList方法。
后来在同事里问了一圈,发现是有同事之前测试,将测试JAR包放到了jre/lib/ext目录,导致了该问题的发生。
解决方案
-
经确认后,将该JAR从扩展库目录中移除,处理后恢复正常;
-
禁止将业务JAR包放到JAVA库目录中,避免相关问题的再次发生;
-
在对模块进行JAR包封装,或进行SDK开发时,应尽可能较少相关第三方库依赖,避免与引用方依赖发生冲突;
-
如果无法避免引用第三方常用类库,可以使用maven shade插件,对相关第三方类库的包名进行变更,以此避免相关问题。