一次Maven依赖冲突踩坑,把依赖调解、类加载彻底整明白了

1,263 阅读5分钟

今年年初的时候,阅读过《Maven实战》,当时有了解到Maven可以依赖调解,即当包的版本不一致时,会根据一定规则选择相应的包来加载,从而避免冲突。当时不解的是既然Maven都能解决冲突,为何还经常听到“发生了依赖冲突”,冲突不是解决了吗,还存在什么问题呢?直到这周在工作中自己遇到了,就明白是咋回事了。下面从我的实际经历说起。

1. Maven依赖冲突经历

我在Y模块中,写了一个Encryptor类,主要是使用了DigestUtilsMessageDigestHmacUtils等类对字符串进行加密(下面代码是随便写的,只表示使用到了这些类),如下:

import org.apache.commons.codec.binary.Hex;
import org.apache.commons.codec.digest.DigestUtils;
import org.apache.commons.codec.digest.HmacUtils;

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;

public class Encryptor {

    public String encrype(String s) {
        MessageDigest sha256Digest = DigestUtils.getSha256Digest();
        String result = Hex.encodeHexString(sha256Digest.digest(s.getBytes(StandardCharsets.UTF_8)));
        return Hex.encodeHexString(HmacUtils.getHmacSha256(result.getBytes()).doFinal(result.getBytes()));
    }

    public static void main(String[] args) {
        Encryptor encryptor = new Encryptor();
        String s = "test";
        String result = encryptor.encrype(s);
        System.out.println(result);
    }
    
    /**
    output: fdd04dcac94e9803a72e4268141f773e2024a8fe46ba19a263be22c5ca83e931
    **/

}

执行单元测试可以正常运行。但是当整个应用启动时,则会报IllegalAccessError错误。

应用启动报错IllegalAccessError

在Y模块下的单元测试运行时不会报错,但是当整个应用启动,作为程序入口的X模块,调用Y模块中的Encryptor时,发生了IllegalAccessError报错。根据图中的具体报错信息,是说没有权限访问getSha256Digest方法,我Ctrl+B点进getSha256Digest方法查看,如下:

getSha256Digest是public

getSha256Digest方法是public的访问级别,我一脸懵。由于这个方法很简单,既然报错,那我就索性不用了,换成下面这种写法。

public String encrype(String s) {
    try {
        MessageDigest sha256Digest = MessageDigest.getInstance("SHA-256");
        String result = Hex.encodeHexString(sha256Digest.digest(s.getBytes(StandardCharsets.UTF_8)));
        return Hex.encodeHexString(HmacUtils.getHmacSha256(result.getBytes()).doFinal(result.getBytes()));
    } catch (NoSuchAlgorithmException e) {
        e.printStackTrace();
        return "error";
    }
}

又报错了,好吧,真是躲不过了!报错如下:

应用启动报错ClassNotFoundException

这次报的是ClassNotFoundException,HmacUtils这个类找不到。可是我Ctrl+B进去,这个类好好的就在那里啊。这时我才把注意力集中在思考是不是发生了依赖冲突。我打开pom.xml,用Dependency Analyzer查看,果然我使用的commons-codec包发生了冲突。

在Y模块中,依赖关系:Y -> B -> C -> commons-codec-1.10。而在X模块中,引用了A包:X -> A -> commons-codec-1.6,也引用了Y模块:X -> Y -> B -> C -> commons-codec-1.10。可见commons-codec包有两个版本1.6和1.10,所以Maven会进行依赖调解,第一原则是“路径最短者优先”,自然只会使用1.6版本的包。而我再去查看1.6的包下,getSha256Digest方法是private的访问级别,HmacUtils这个类也不存在。解释了之前的报错。解决该冲突,通过排除依赖便能解决了,将A包下的commons-codec排除,如下:

<dependencies>
    <dependency>
        <groupId>com.chaycao.maven.dependency</groupId>
        <artifactId>A</artifactId>
        <version>1.0-SNAPSHOT</version>
        <exclusions>
            <exclusion>
                <artifactId>commons-codec</artifactId>
                <groupId>commons-codec</groupId>
            </exclusion>
        </exclusions>
    </dependency>
    <dependency>
        <groupId>com.chaycao.maven.dependency</groupId>
        <artifactId>Y</artifactId>
        <version>1.0-SNAPSHOT</version>
    </dependency>
</dependencies>

排除后,这时将只有1.10版本的包,程序也可以正常运行了。

2. 为什么需要Maven依赖调解

问题已经解决了,大家是不是也明白了,为什么依赖冲突会常导致发生NoClassDefFoundErrorNoSuchMethodExceptionIllegalAccessError等错误。虽然Y模块在编译时,由于引入了commons-codec 1.10能正常编译,但是在运行时,由于依赖冲突,只加载了1.6版本的包,所以不能正常运行。

注意:代码的编译仅仅是编译当前的代码。编译成功后,最后能否正常运行,还要取决于运行时的环境是否等同或兼容编译时环境。

下面我们想想为什么需要Maven依赖调解,如果不调解行不行。

当使用Maven的过程中,如果同时引入了groupId和artifactId相同而version不同的包时,Maven会认为发生了依赖冲突,将进行依赖调解,通过两个原则决定使用哪个版本的包:第一原则,路径最近者优先,如前文。如果路径相同,则使用第二原则,在pom中第一声明者优先。而当我们在点击Run运行时,classpath中将只会有一个明确版本的包。

思考一下。Java在运行时,是否能引用版本不同的包。其实这个问题是在问,java命令的classpath参数中能不能引用版本不同的包,当然是可以的。classpath参数的是用于指示JVM如何搜索class文件,当你在classpath中指定了多个版本不同的包,JVM就都会去jar包下搜索class文件进行加载,而至于class能不能成功加载,则在于ClassLoader的逻辑,当同名类被加载时,则不会再被加载,即同一个类只会被加载一次。这也意味,当有多个版本不同的包时,包在classpath中的顺序,决定了哪个包中的类能先被加载。而这样具有不确定性。因为在生产环境下通常使用shell命令将jar包拼接:

LIB_DIR=lib
LIB_JARS=`ls $LIB_DIR|grep .jar|awk '{print "'$LIB_DIR'/"$0}'|tr "\n" ":"`

不同环境下得到的jar包顺序可能是不同的。而Maven依赖调解将使得只有一个明确版本的包参与构建,从而避免不确定性

3. 排查在线问题的利器-Arthas

Arthas,早有听说,但一直未使用过,这次我尝试了下,觉得确实可以,安利下。对于前文说的依赖冲突情况,当发生IllegalAccessError报错时,可以通过Arthas直接查看运行情况下的DigestUtils。我们把代码变为最初的情况,且在Main类中加个死循环,为了让程序不死掉,以通过Arthas观察。

public class Main {

    public static void main(String[] args) {
        while (true) {
            try {
                Encryptor encryptor = new Encryptor();
                String s = "1234567890";
                String result = encryptor.encrype(s);
                System.out.println(result);
            } catch (Throwable e) {

            }
        }
    }

}

打开Arthas,连接上我们的程序(可以通过官方教程学习),然后通过sc命令查看DigestUtils

[arthas@32328]$ sc -d org.apache.commons.codec.digest.DigestUtils
 class-info        org.apache.commons.codec.digest.DigestUtils
 code-source       /D:/mavenrepo/commons-codec/commons-codec/1.6/commons-codec-1.6.jar
 name              org.apache.commons.codec.digest.DigestUtils
 isInterface       false
 isAnnotation      false
 isEnum            false
 isAnonymousClass  false
 isArray           false
 isLocalClass      false
 isMemberClass     false
 isPrimitive       false
 isSynthetic       false
 simple-name       DigestUtils
 modifier          public
 annotation
 interfaces
 super-class       +-java.lang.Object
 class-loader      +-sun.misc.Launcher$AppClassLoader@58644d46
                     +-sun.misc.Launcher$ExtClassLoader@24e74ca5
 classLoaderHash   58644d46

可以从code-source中清晰的查到DigestUtils是哪个包下的Class,这时就该意识到发生了依赖冲突问题。

而通过jad命令,还能反编译,在线看代码。好用!

4. 参考

  1. Arthas 实战,助你解决同名类依赖冲突问题
  2. Maven依赖冲突问题原理简析
  3. 重新看待Jar包冲突问题及解决方案