在游戏服务器开发过程中,我们经常会用到第三方的jar,尤其是在做分布式系统的时候,会参考大量现成的分布式开放源库,但是森林太大了,各种鸟都有,依赖大量包带来的冲突问题也很突出。经常会出现本地运行没问题,但是打包部署到服务器的时候就出现GameOver(),出现一些莫名其妙的问题。Jar包冲突是个老生常谈的问题。几乎每个Java程序员都不可避免地遇到过,通常的原因是同一个Jar包由于maven传递依赖等原因引入了多个不同的版本。因此,依赖排除、依赖管理等常规方法可以尝试解决这个问题,但这些方法真的能彻底解决冲突问题吗?答案是否定的。笔者之所以将文章标题命名为“重考”,是因为之前对Jar包冲突问题的理解仅仅停留在上面提到的那些。直到工作中遇到了一系列Jar包冲突的问题,才发现并解决了问题。没那么简单。带着对问题的新认识,本文将着重介绍Jar包冲突问题的本质和相关解决方案。一、冲突的本质Jar包冲突的本质是什么?google了半天也没有找到满意的完整定义。其实从Jar包冲突的结果我们可以总结一下,这里给出如下定义(如果这里有什么不对的地方,请拍砖-):Java应用由于某些原因无法加载正确的类。它的行为并不像预期的那样。具体可以分为两种情况:1)应用依赖的同一个Jar包存在多个不同版本,选择了错误的版本,导致JVM无法加载需要的类或者加载错误的版本类的,为了描述方便,笔者称之为类Jar包冲突;2)同一个类(类的完全限定名完全相同)出现在多个不同的依赖Jar包中,即类有多个版本,由于Jar包的加载顺序,JVM加载类版本错误,称为第二类Jar包问题。这两种情况的结果其实是一样的,应用程序将无法加载正确的类,其行为自然会与预期不符。下面分别对两种类型进行详细分析。1.1同一个Jar包存在多个不同版本随着Jar包的迭代升级,我们所依赖的开源或者内部Jar包工具就会有多个不同版本,版本升级自然不可避免的会改变方法签名类的,甚至是类名的替换,而我们现在的应用往往依赖某个类M的某个版本,由于maven的传递依赖,会出现同一个Jar包的多个版本。当maven的仲裁机制选择了错误的版本,恰好在这个版本中去掉了类M,或者更改了方法签名,导致应用程序找不到需要的类M或者在类M中找不到具体的方法,***会出现ClassJar冲突问题。归纳出此类冲突的三个必要条件:由于maven的传递依赖,在依赖树中出现同一个Jar包的多个版本。多个版本的Jar包之间存在接口差异,比如类名替换,方法签名替换等,应用依赖更改的类或方法Maven的仲裁机制选择了错误的版本1.2同一个类出现在多个不同的Jar包应用中出现同一个classdepends在两个或多个不同的Jar包中会造成什么问题?我们知道,同一个类加载器只会加载同一个类一次(多个不同的类加载器会单独讨论,这也是解决Jar包冲突的思路,后面会讲到),那么当一个类出现在多个Jar包,假设有A、B、C等,由于Jar包依赖路径的长度,声明的顺序,或者文件系统的文件加载顺序,类加载器先从Jar中加载类packageA之后,其余Jar包中的类将不会被加载,那么问题来了:如果此时应用需要Jar包B中类的版本,而该类在Jar包中是不同的A和B(方法不同,成员不同等),但是JVM加载的是Jar包A中的类版本,与预期不符,自然会出现各种奇葩问题。从上面的描述可以发现,不同Jar包冲突的必要条件有3个:同一个类M出现在多个依赖的Jar包中。为了描述方便,假设还是有两个:A和BJar包A和B中的类M是不一样的,不管是方法签名还是成员变量,只要能引起实际的行为即可加载的类与预期不一致。如果A、BJar包中的类完全一样,无论先加载哪个Jar包,类加载器得到的都是同一个版本的M类,不会有任何影响,不会出现Jar包冲突奇怪的问题来了。加载的类M不是预期的版本,即加载了错误的Jar包。2.冲突原因2.1maven仲裁机制。Maven目前很流行。依赖机制。传递依赖是Maven2.0引入的新特性。我们只关注直接依赖的Jar包。对于间接依赖的Jar包,Maven会通过解析从远程仓库获取的依赖包的pom文件来隐式包含它。这给我们的开发带来了极大的方便,但同时也带来了通病——版本冲突,即同一个Jar包存在多个不同的版本。Maven也有一套仲裁机制,用来决定最后选择哪个版本,但是Maven的选择往往不一定是我们所期望的,这也是Jar包冲突最常见的原因之一。先来看看Maven的仲裁机制:依赖管理元素中指定的版本声明优先仲裁。这时,下面两个原则就失效了。如果没有版本声明,则按照“短路径优先”(Maven2.0)的原则进行仲裁,即选择依赖树中路径最短的版本。如果路径长度相同,则按照“***声明优先”的原则进行仲裁,即从maven仲裁机制中选择POM中***声明的版本。可以发现,除了第一个仲裁规则(这也是解决Jar包冲突的常用手段之一)外,后两个原则,对于同一个Jar包的不同版本的选择,maven的选择有点“一厢情愿”,也许这是maven研发团队在总结大量项目依赖管理经验后得出的两个结论,或者是发现没有统一的方式满足所有场景后的无奈之举。可能这对于大部分场景都适用,但不一定适合我——现在的应用,因为每个应用都有自己的特殊性,依赖哪个版本,maven帮不了你完全,如果你不会用没有适当的来管理依赖,就注定无法逃避类似***的Jar包冲突问题。2.1Jar包的加载顺序对于第二类Jar包冲突问题,即多个不同的Jar包存在类冲突,这个比***类问题要难一些。你为什么这么说?在这种情况下,两个不同的Jar包,假设A和B,具有不同的名称,甚至可能根本不相关。如果没有冲突,你可能不会发现他们有共同的类!对于A和B这两个Jar包,maven无能为力,因为maven只会为你仲裁同一个Jar包的不同版本,而这两个属于不同的Jar包,不在maven的依赖管理范围之内。这时,当A和B同时出现在应用程序的类路径中时,就会存在潜在的冲突风险,即A和B的加载顺序决定了JVM最终选择的类版本。出现了一种奇怪的第二种类型的冲突问题。那么哪些因素决定了Jar包的加载顺序呢?具体如下:Jar包的加载路径,也就是JVM类加载器树结构中加载Jar包的类加载器的层级。由于JVM类加载的双亲委派机制,上层类加载器先加载其加载路径下的类。顾名思义,引导类加载器(bootstrapClassLoader,也叫启动类加载器)首先加载其路径下的Jar包,然后是扩展类加载器(extensionClassLoader),然后是系统类加载器(系统ClassLoader,即应用加载器appClassLoader)。Jar包加载路径的不同决定了其加载顺序的不同。比如我们在eclipse中配置web应用的resin环境时,需要仔细考虑依赖的Jar包应该加入BootstrapEntries还是UserEntries。文件系统的文件加载顺序。这个因素很容易被忽视,往往是环境不一致导致的各种怪异冲突问题的罪魁祸首。因为tomcat、resin等容器的ClassLoader获取的是加载路径下的文件列表,没有排序,这取决于底层文件系统返回的顺序。当不同环境下的文件系统不一致时,就会出现一些环境。没问题,有些环境会冲突。例如,对于Linux操作系统,返回顺序由iNodes的顺序决定。如果测试环境的linux系统和线上环境不一致,很可能会出现一个典型的情况:测试环境不管怎么测试都没有问题,但是一上线就出现冲突。避免这个问题最好的办法就是尽量保证测试环境和线上一致。三、冲突的症状Jar包冲突可能会导致哪些问题?一般在编译或运行时出现,主要分为两类问题:一类是比较直观,最常见的错误是抛出各种运行时异常,另一类是比较晦涩的问题,不会报错,而且另一种是表现形式是应用程序的行为与预期不符,分列如下:java.lang.ClassNotFoundException,即找不到java类。这类典型的异常通常是由于在依赖管理中没有声明版本,maven仲裁时选择了错误的版本,而这个版本缺少我们需要的某个类,从而导致报错。例如,当httpclient-4.4.jar升级到httpclient-4.36.jar时,类org.apache.http.conn.ssl.NoopH??ostnameVerifier被删除。如果我们原本需要4.4版本,使用了NoopH??ostnameVerifier类,maven仲裁时如果选择4.6,就会抛出ClassNotFoundException。java.lang.NoSuchMethodError,即找不到具体的方法。第一类冲突和第二类冲突都可能导致这个问题——加载的类不正确。如果***类冲突,是因为错误版本的Jar包中的类接口与需要的Jar包版本不一致。比如当antlr-2.7.2.jar升级到antlr-2.7.6.Jar后,接口antlr.collections.AST.getLine()发生变化,当maven仲裁选择了错误的版本,加载了错误的版本classAST,会引发异常;如果是第二种冲突,是因为不同的Jar包含同名类接口不一致造成的,典型案例:Apache的commons-lang包,从2.x升级到3.x时,包名直接从commons-lang改为commons-lang3,部分接口也发生了变化,由于包名和传递性依赖的不同,classpath中往往同时存在两种Jar包,org.apache.commons.lang.StringUtils.isBlank是有差异的接口之一。由于Jar包的加载顺序,加载了错误版本的StringUtils类。可能会出现NoSuchMethodError异常。java.lang.NoClassDefFoundError、java.lang.LinkageError等,原因与上述类似,不再给出具体案例分析。没有报告错误异常,但应用程序的行为不符合预期。这种问题也是由于运行时加载了错误版本的类导致的,但不同的是,冲突的类接口是一致的,只是具体实现逻辑不同而已。当我们加载的类版本不是我们需要的逻辑实现时,就会出现与预期不一致的行为。这种问题一般出现在我们内部实现的多个Jar包中。由于包路径和类名命名等问题,导致两个不同的Jar包有同名类,接口相同,实现逻辑不同,导致这个问题。解决方法一、排查与解决如果堆栈信息异常,可以根据错误信息定位到引起冲突的类名,然后可以发现该类存在于多个依赖的Jar包中,如果第1步不能定位冲突的类来自哪个Jar包,可以在应用启动时添加JVM参数-verbose:class或-XX:+TraceClassLoading,日志中会打印出各个类的加载信息,比如是哪个Jar包它来自。定位到冲突类的Jar包后,使用mvndependency:tree-Dverbose-Dincludes=:查看Jar包的版本导入位置。确定Jar包的来源后,如果是第一种类型的Jar包冲突,可以使用排除不需要的Jar包版本或者在依赖管理声明版本;如果是第二种Jar包冲突,如果可以排除,使用排除删除不需要的Jar包。如果无法排序,则需要考虑升级Jar包或者换成其他Jar包。当然,除了这些方法,你还可以从类加载器的角度来解决这个问题。可以参考博文——如果jar包冲突在所难免,如何实现jar包隔离值得学习。2、有效规避从上一节的解决方案中可以发现,当出现第二种Jar包冲突,且无法排除冲突的Jar包时,问题变得相当棘手。这时候处理冲突问题需要付出很大的代价,所以最好的办法就是在冲突发生之前有效的避免!就像数据库死锁问题一样,避免死锁和防止死锁非常重要。如果真的发生了死锁,传统的做法只能是回滚和重启一些事务,这是捉襟见肘的。那么如何才能有效避免Jar包冲突呢?2.1好习惯:依赖管理对于***类型的Jar包冲突的问题,通常的做法是使用来排除不需要的版本,但是这种做法带来的问题是每次都是传递依赖的Jarintroduced打包的时候需要一个一个排除,很麻烦。为此,maven提供了一种集中管理依赖信息的机制,即依赖管理元素,对依赖的Jar包进行统一的版本管理,一劳永逸。通常的做法是在父模块的pom文件中尽可能声明所有相关依赖Jar包的版本,在子pom.xml中简单引用该组件。让我们看一个例子。当开发时确定使用的httpclient版本为4.5.1时,可以在父pom中配置如下:...4.5.1org.apache.httpcomponentshttpclient<版本>${httpclient.version}...然后每个需要依赖Jar包的子pom配置如下依赖:...org.apache.httpcomponentshttpclient...2.2冲突检测插件针对第二种Jar包冲突,前面说了,核心是同名类出现在多个不同的Jar中包。解决这个问题,需要一个一个打开每个Jar包,然后相互比较,看是否有同名类。多么浪费能源?!幸运的是,这种费时费力的体力劳动可以交给程序来完成。maven-enforcer-plugin,这个强大的maven插件,结合extra-enforcer-rules工具,可以自动扫描Jar包,检测并打印出冲突。遗憾的是笔者在工作之前并没有听说过这样的插件的存在,可能是工作中没有遇到过这样的冲突吧,所以还是挺不错的姿势。原理其实比较简单。通过扫描Jar包中的类,记录每个类对应的Jar包列表。如果不止一个,那就是冲突,就不用深究了。我们只需要注意如何使用它。在最终需要打包运行的应用模块pom中引入了maven-enforcer-plugin的依赖,在build阶段就可以发现并解决问题。例如,对于有父pom的多模块项目,需要在应用模块的pom中声明插件依赖。看到这里的童鞋们可能会疑惑,为什么不在父pom中声明插件依赖呢?不是所有依赖它的应用子模块都被复用了吗?这里之所以强调“打包运行应用模块pom”,是因为冲突检测是针对最终集成的应用,关注的是应用运行时是否会发生冲突,每个不同的应用模块依赖的Jarpackage集合不同,得到的列表也不同,所以插件只能针对应用模块pom单独引入。先看示例使用方法如下:...org.apache.maven.pluginsmaven-enforcer-plugin1.4.1enforceenforceenforce-ban-duplicate-classesenforcejavax.*org.junit.*net.sf.cglib.*org.apache.commons.logging.*org.springframework.remoting.rmi.RmiInvocationHandlertruetrueorg.codehaus.mojoextra-enforcer-rules1.0-beta-6maven-enforcer-plugin是通过很多预定义的标准规则(标准规则)和用户自定义规则用于约束maven的环境因素,如maven版本、JDK版本等。它有很多有用的特性,具体请参考官网。ExtraEnforcerRules是MojoHaus项目下的一个插件,提供为maven-enforcer-plugin开发的附加规则,其中包含了上面提到的重复类检测功能。具体用法可以在官网找到,这里不再详细介绍。典型案例***Jar包冲突这种Jar包冲突是最常见的,也是比较容易解决的。部分情况已在第3节中列出。出现冲突,这里不再赘述。第二种Jar包冲突Spring2.5.6和Spring3.xSpring2.5.6和Spring3.x,从单个模块拆分成多个模块,Jar包名(artifactId)也由spring改为spring-submoduleName,比如spring-context,spring-aop等,还有少量的接口变化(这也是Jar包升级过程中不可避免的)。由于是不同的Jar包,通过maven的传递依赖机制,这两个版本的Spring会经常存在于classpath中,造成潜在的冲突。