Maven 依赖冲突解决以及思考

807 阅读10分钟

背景

由于一次生产上线的过程中出现的一次关于 maven 依赖冲突导致的问题,遂写下此小记

发现问题

一段代码在开发和测试以及灰度环境都运行正常,但是到了预生产环境之后却离奇的出现了出现了抛出java.lang.ClassNotFoundException 在仔细排查之后发现了其中的问题

1、预生产和其他环境对比起来,前者是物理机运行,后者是容器运行所有的 JDK 的底包都有一个统一的镜像,环境不一样 2、把包拉下来之后查看依赖确实缺少对应的依赖,说明maven打包的内容并没有加载进去

分析问题

1. 环境差异

先说下第二点,环境的差异性带来的问题,这也就是为啥要引入容器化的一个主要原因,容器能保证在不同机器运行的底包环境是一致的。有些生产的事故也是因为环境不同导致问题的产生

image.png

关于这点就阐述到这里。也不过多赘述容器的好处

2. maven 依赖冲突

重点说下这个 maven 依赖冲突的问题 先查看本地项目的依赖树信息

本地或服务器可以执行mvn dependency:tree -Dverbose

img_v3_02af_50e5978e-a385-4283-a751-7504167e33ag.jpg 一堆冲突。。。。。

image.png

这个命令行查看起来也不太方便,如果是idea编辑器可以用Maven Helper

img_v3_02af_1c3d30f5-721f-4361-91eb-2f4573b1be5g.jpg

点开 pom 文件下方有个 dependency analyze,点击之后就可以查看冲突项

img_v3_02af_726818ee-fa11-4e58-962f-4a74ed5c4a0g.jpg

img_v3_02af_75906e9e-041d-441c-b9ed-5745f135cf2g.jpg

点击 exclude 之后就会在相应的 dependency 上排除对应的冲突依赖

了解 Maven 的依赖机制和仲裁机制以及作用范围

如有依赖关系为A->B->C,A依赖B,称为直接依赖。A本身不依赖C,但C通过B传递给A,称C为A的传递性依赖

image.png

Maven的依赖仲裁规则遵循以下约定:

① 最短路径优先原则
image.png 这里 A 使用的 D 依赖是 1.0 的版本,因为其引用路径最短,(A-E-D)短于(A-B-C-D),前者路径较短,所以会先使用前者的版本

② 相同路径优先声明原则

image.png

这里 A 使用的 D 依赖是 1.0 的版本,因为相同路径优先声明,(A-B-C-D)路径最长,所以先排除了(A-E-D)和(A-F-D)路径相同,但是由于前者先声明了,所以会先使用前者的版本

上面 2 点是传递性依赖所遵循的规则,如果是直接依赖的话,最后声明的会覆盖掉之前的声明

<!-- 该pom文件最终引入D-3.0的依赖包。 -->

<dependencies>
  <dependency>
    <groupId>myGroupId</groupId>
    <artifactId>D</artifactId>
    <version>1.0</version>
  </dependency>
  <dependency>
    <groupId>myGroupId</groupId>
    <artifactId>D</artifactId>
    <version>2.0</version>
  </dependency>
  <dependency>
    <groupId>myGroupId</groupId>
    <artifactId>D</artifactId>
    <version>3.0</version>
  </dependency>
</dependencies>

了解完了依赖的传递性以及其仲裁机制之后,再来看看传递依赖的作用范围如下表:

作用域(Scope)描述
compile编译依赖, 此作用域表示项目classpath中的依赖可以使用,为默认作用域。并且依赖项将会打包编译且传播到依赖项目
provided已提供依赖, 此作用域表示依赖将由JDK或者运行时的Web服务器或容器提供。 只对于编译和测试classpath有效,运行时无效,如Servlet API,此范围不具有传递性
runtime运行时依赖, 此作用域表示依赖在编译时不需要,但在执行时需要
test测试依赖, 此作用域表示依赖只在测试编译和执行阶段可用,如junit。此范围不具有传递性
system系统依赖, 此作用域表示你必须提供系统路径,该依赖于三种classpath的关系和provided依赖范围完全一致。区别在于system依赖范围必须通过systemPath元素显示的指定依赖文件的路径。
import导入依赖, 此作用域只在依赖是POM类型时使用。此作用域表示特定的POM需要替换成被引入的POM的部分中的依赖。此范围不具有传递性

思考

由于此次升级仅仅是一个其他中间件版本的升级,但是上述中的 Maven 冲突确实是已经存在了一段时间了,那为啥之前安然无恙,如今却出现了如此问题

回过头看看,一个类是如何被加载的?

首先,我们需要知道的是,Java语言系统中支持以下4种类加载器:

  • Bootstrap ClassLoader 启动类加载器
  • Extention ClassLoader 标准扩展类加载器
  • Application ClassLoader 应用类加载器
  • User ClassLoader 用户自定义类加载器

这四种类加载器之间,是存在着一种层次关系的,如下图 image.png

一般认为上一层加载器是下一层加载器的父加载器,那么,除了BootstrapClassLoader之外,所有的加载器都是有父加载器的。

那么,所谓的双亲委派机制,指的就是:当一个类加载器收到了类加载的请求的时候,他不会直接去加载指定的类,而是把这个请求委托给自己的父加载器去加载。只有父加载器无法加载这个类的时候,才会由当前这个加载器来负责类的加载。

那么,什么情况下加载器会无法加载某一个类呢?其实,Java中提供的这四种类型的加载器,是有各自的职责的:

双亲委派机制的核心有两点:第一,自底向上检查类是否已加载;其二,自顶向下尝试加载类

  • Bootstrap ClassLoader ,主要负责加载Java核心类库,%JRE_HOME%\lib下的rt.jar、resources.jar、charsets.jar和class等。
  • Extention ClassLoader,主要负责加载目录%JRE_HOME%\lib\ext目录下的jar包和class文件。
  • Application ClassLoader ,主要负责加载当前应用的classpath下的所有类
  • User ClassLoader , 用户自定义的类加载器,可加载指定路径的class文件

那么也就是说,一个用户自定义的类,如com.mypackage.MyClass 是无论如何也不会被Bootstrap和Extention加载器加载的。加载过程如图所示:

image.png

二话不说上源码:

protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException{
        synchronized (getClassLoadingLock(name)) {
            // First, check if the class has already been loaded
            // 首先,检查这类是否已经被加载过了
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                long t0 = System.nanoTime();
                try {
                    if (parent != null) {
                        // 如果有父类加载器,则优先使用父类加载器加载类
                        c = parent.loadClass(name, false);
                    } else {
                        // 否则使用Bootstrap ClassLoader类加载器加载类
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    // ClassNotFoundException thrown if class not found
                    // from the non-null parent class loader
                    // 如果父类加载器抛出ClassNotFoundException异常 
                    // 则说明父类加载器无法完成加载请求
                }
                if (c == null) {
                    // If still not found, then invoke findClass in order
                    // to find the class.
                    // 在父类加载器无法加载时 
                    // 再调用本身的findClass方法来进行加载
                    long t1 = System.nanoTime();
                    c = findClass(name);
                    // this is the defining class loader; record the stats
                    sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                    sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                    sun.misc.PerfCounter.getFindClasses().increment();
                }
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }

那为啥要提及双亲委派,他和 Maven 依赖冲突有什么关系呢

双亲委派防止加载同一个class文件。通过委托的方式去询问父级是否已经加载过该class,如果加载过了就不需要重新加载。从而保证了数据安全

通过委托的方式,保证Java核心class不被篡改,即使被篡改也不会被加载,即使被加载也不会是同一个class对象,因为不同的加载器加载同一个.class也不是同一个Class对象。这样则保证了Class的执行安全。

那么上面已经看到一旦一个类被加载之后,全局限定名相同的类可能就无法被加载了。而Jar包被加载的顺序直接决定了类加载的顺序。

决定Jar包加载顺序通常有以下因素:

  • 第一,Jar包所处的加载路径。也就是加载该Jar包的类加载器在JVM类加载器树结构中所处层级。上面讲到的四类类加载器加载的Jar包的路径是有不同的优先级的。
  • 第二,文件系统的文件加载顺序。因Tomcat、Resin等容器的ClassLoader获取加载路径下的文件列表时是不排序的,这就依赖于底层文件系统返回的顺序,当不同环境之间的文件系统不一致时,就会出现有的环境没问题,有的环境出现冲突。

本人遇到的问题属于第二种因素中的一个分支情况,即同一目录下不同Jar包的加载顺序不同。 项目中依赖如下(存在依赖冲突版本)

graph TD
主POM --> A --> C-1.0
主POM --> B --> C-2.0 --> D

最后拉包下来发现 平时的开发测试(docker)环境最终依赖引用路径如下

graph TD
主POM --> B --> C-2.0 --> D

预生产(物理机)环境最终依赖引用路径如下

graph TD
主POM --> A --> C-1.0

然后实际执行的代码用到了 D 的类,最终导致抛出ClassNotFoundException 至于最后为何选择出现了不一样,发现 Tomcat 获取类信息的顺序并未进行任何排序,所有的排序都是经文件系统排序返回的。

开始怀疑是名称的问题,但是后来查阅了相关资料发现 linux 的排序并不是根据文件名来进行排序的, linux 内部是用inode来指示文件的

解法

Jar包冲突往往是很诡异的事情,也很难排查,但也会有一些共性的表现。

  • 抛出java.lang.ClassNotFoundException:典型异常,主要是依赖中没有该类。导致原因有两方面:第一,的确没有引入该类;第二,由于Jar包冲突,Maven仲裁机制选择了错误的版本,导致加载的Jar包中没有该类。
  • 抛出java.lang.NoSuchMethodError,java.lang.NoSuchFieldError:找不到特定的方法或者字段。Jar包冲突,导致选择了错误的依赖版本,该依赖版本中的类对不存在该方法,或该方法已经被升级。
  • 抛出java.lang.NoClassDefFoundError,java.lang.LinkageError等,原因同上。
  • 没有异常但预期结果不同:加载了错误的版本,不同的版本底层实现不同,导致预期结果不一致。

1、排除冲突的依赖,合理的管理版本

最好的做法就是从根源上解决问题,通常的做法是用exclusion标签排除不需要的jar包,然而这种做法的后果是每次引入新的jar包都可能传递依赖到导致冲突的jar包,所以最好所有的依赖都集中管理,如果是多模块的则定义一个父级 pom 管理所有的三方或者二方依赖,子 pom 直接引入相关依赖就好了,无需关注版本

2、冲突检查

通过命令:上面提到的 mvn dependency:tree -Dverbose

通过Maven插件检查:Maven Enforcer 插件检查依赖

通过编辑器插件检查:上面提到的 Maven Helper

3、类分离打包

maven-plugin-shade 插件

使用该插件将冲突的类分开在不同的的 shade 中并依赖,本质上是多个版本类并存的方式,只不过引用的包变了

4、类加载器隔离

Java 类隔离加载的正确姿势

蚂蚁金服-Sofa-插件

淘宝-pandora-插件

此类插件的实现机制是用过实现自定义的类加载器的方式隔离不同的依赖,这样也能达到相同的效果

image.png

总结

以上为 Maven 依赖冲突排查的过程以及思考

解法 1 和 解法 2 属于相对常规且简单的做法

解法 3 和解法 4 在一些大型互联网公司项目中对于中间件或者第三方依赖版本的把控很难所有人都保证统一版本,所以这种围魏救赵的方式也不失为一种可行的解决方案

如有遗漏或者错误欢迎指正,如果对你有帮助欢迎点赞+收藏❤️❤️❤️

参考文章

安全同学讲Maven间接依赖场景的仲裁机制

Maven依赖机制

从Jar包冲突搞到类加载机制,就是这么霸气

Jvm加载jar包的顺序

使用 Maven Enforcer 插件检查依赖