【Java】Jar 包依赖冲突排查思路和解决方法

时间:2024-03-26 10:00:15

一、现象

通过几轮开发、测试和验证后,在上预发环境时,应用突然无法启动,查看 tomcat 报错原因,发现是 类转换失败 ClassCastException

1.1、报错原因

Class path contains multiple SLF4J binding
23-Mar-2024 16:04:25.300 INFO [localhost-startStop-1] org.apache.jasper.servlet.TldScanner.scanJars At least one JAR was scanned for TLDs yet contained no TLDs. Enable debug logging for this logger for a complete list of JARs that were scanned but no TLDs were found in them. Skipping unneeded JARs during scanning can improve startup time and JSP compilation time.
SLF4J: Class path contains multiple SLF4J bindings.
SLF4J: Found binding in [jar:file:/home/admin/xxx/WEB-INF/lib/slf4j-log4j12-1.6.1.jar!/org/slf4j/impl/StaticLoggerBinder.class]
SLF4J: Found binding in [jar:file:/home/admin/xxx/WEB-INF/lib/logback-classic-1.1.3.jar!/org/slf4j/impl/StaticLoggerBinder.class]
SLF4J: See http://www.slf4j.org/codes.html#multiple_bindings for an explanation.
SLF4J: Actual binding is of type [org.slf4j.impl.Log4jLoggerFactory]
org.slf4j.impl.Log4jLoggerFactory cannot be cast to ch.qos.logback.classic.LoggerContext
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'cn.com.xxx.framework.log.integration.LogbackInitializer#0' defined in class path resource [spring/spring-log-init.xml]: Invocation of init method failed; nested exception is java.lang.ClassCastException: org.slf4j.impl.Log4jLoggerFactory cannot be cast to ch.qos.logback.classic.LoggerContext
    ...
Caused by: java.lang.ClassCastException: org.slf4j.impl.Log4jLoggerFactory cannot be cast to ch.qos.logback.classic.LoggerContext
    # 出问题的加载地方
 at ch.qos.logback.ext.spring.LogbackConfigurer.initLogging(LogbackConfigurer.java:72)
 at cn.com.xxx.framework.log.integration.LogbackInitializer.init(LogbackInitializer.java:49)
 at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
 at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
 at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
 at java.lang.reflect.Method.invoke(Method.java:498)
 at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.invokeCustomInitMethod(AbstractAutowireCapableBeanFactory.java:1706)
 at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.invokeInitMethods(AbstractAutowireCapableBeanFactory.java:1645)
 at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1574)
 ... 26 more
23-Mar-2024 15:59:12.398 SEVERE [localhost-startStop-1] org.apache.catalina.core.StandardContext.startInternal One or more listeners failed to start. Full details will be found in the appropriate container log file

1.2、查看报错代码

public static void initLogging(String location) throws FileNotFoundException, JoranException {
   String resolvedLocation = SystemPropertyUtils.resolvePlaceholders(location);
   URL url = ResourceUtils.getURL(resolvedLocation);
   LoggerContext loggerContext = (LoggerContext)StaticLoggerBinder.getSingleton().getLoggerFactory();
   loggerContext.reset();
   new ContextInitializer(loggerContext).configureByResource(url);
}

可以看到,通过 StaticLoggerBinder.getSingleton().getLoggerFactory() 获取 logger 上下文这段代码报错了,通过仔细定位,发现了有两个 StaticLoggerBinder

更重要的是,他们两兄弟竟然虽然不是同一个 jar 包,但是包路径和名称都一模一样!!!

二、解决办法

  1. 通过 POM 文件排查包冲突

  2. 安装 IDEA 的插件 Maven Helper

  3. 定位到编译 WAR 包的 POM 文件(我们框架定义的在 Deploy 模块中)

  4. 在搜索框中,输入搜索内容,点击右键可以看到选项框

    1. Jump To Source(跳转到源文件处)

    2. Exclude(排除掉)

例如我点击了 Exclude ,就能看到 pom 文件中,这个依赖就被排除掉了

<dependency>
    <groupId>com.nio.ado</groupId>
    <artifactId>ado-subscribe-common</artifactId>
    <exclusions>
        <exclusion>
            <artifactId>slf4j-log4j12</artifactId>
            <groupId>org.slf4j</groupId>
        </exclusion>
    </exclusions>
</dependency>

三、思考

包冲突解决是简单的,通过 maven 插件可以精确找到依赖,然后进行 Exclude,可是在本地开发、测试环境都没有出现的问题,却在预发环境出现了,所以排除了业务逻辑代码的原因,简单考虑了几个因素和原因:

  • jdk 版本

  • tomcat 版本

  • 类加载机制

  • 第三方 jar 互相依赖

由于 jdk 和 tomcat 这两者没有明显的报错原因,所以先去排查类的加载机制。

四、类加载机制

4.1、Class Loader 分类

首先Java的类加载器可被分为四类

  1. 启动类加载器Bootstrap ClassLoader

    1. 用于加载Java的核心类,由底层的 C++ 实现。启动类加载器不属于 Java 类库,无法被 Java 程序直接引用。

    2. Bootstrap ClassLoader 的 parent 属性为 null

  2. 标准扩展类加载器 Extension ClassLoader

    1. 由 sun.misc.Launcher$ExtClassLoader 实现

    2. 负责加载 JAVA_HOME 下 libext 目录下的或者被 java.ext.dirs 系统变量所指定的路径中的所有类库

  3. 应用类加载器 Application ClassLoader

    1. 由 sun.misc.Launcher$AppClassLoader 实现

    2. 负责在 JVM 启动时加载用户类路径上的指定类库

  4. 用户自定义类加载器 User ClassLoader

    1. 当上述 3 种类加载器不能满足开发需求时,用户可以自定义加载器

    2. 自定义类加载器时,需要继承 java.lang.ClassLoader 类。如果不想打破双亲委派模型,那么只需要重写 findClass 方法即可;如果想打破双亲委派模型,则需要重写 loadClass 方法

Bootstrap引导类加载器 → Extension拓展类加载器 → Application系统类加载器 → User自定义类加载器

4.2、Class Loader 加载机制

Java 使用的是双亲委派加载机制,通过查看 ClassLoader 类,可以对此有所了解。

类被成功加载后,将被放入到内存中,内存中存放 Class 实例对象。

protected Class<?> loadClass(String name, boolean resolve)
    throws ClassNotFoundException
{
    synchronized (getClassLoadingLock(name)) {
        // First, check if the class has already been loaded
        // 首先,检查 class 是否已经被加载
        Class<?> c = findLoadedClass(name);
        if (c == null) {
            // 如果没有被加载
            long t0 = System.nanoTime();
            try {
                if (parent != null) {
                    // 寻找 parent 加载器
                    c = parent.loadClass(name, false);
                } else {
                    // 如果父加载器不存在,则委托给启动类加载器加载
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                // ClassNotFoundException thrown if class not found
                // from the non-null parent class loader
            }
            if (c == null) {
                // If still not found, then invoke findClass in order
                // to find the class.
                // 如果仍然无法加载,才会尝试自身加载
                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;
    }
}

我们写的 Java 应用代码,一般是通过 App ClassLoader 应用加载器进行加载,它不会自己先去加载它,而是通过 Extension ClassLoader 扩展类加载器进行加载(其中扩展类加载器又会去找 Bootstrap ClassLoader 启动类加载器进行加载),只有父加载器无法加载情况下,才会让下级加载器进行加载。

4.3、类加载顺序

从代码中了解到,如果某个名字的类被加载后,类加载器是不会再重新加载,所以我们的问题根本原因可以是出现在:

先加载了 org.slf4j 包的 org.slf4j.impl.StaticLoggerBinder,同名的 ch.qos.logback 包下的 StaticLoggerBinder 类没有被加载

通过查阅文章:

  • 跟JAR文件的文件名有关。按照字母的顺序加载JAR文件。有了这个类以后,后面的类则不会加载了。 jvm 加载包名和类名相同的类时,先加载classpath中jar路径放在前面的,包名类名都相同,那jvm没法区分了,如果使用ide一般情况下是会提示发生冲突而报错,若不报错,只有第一个包被引入(在classpath路径下排在前面的包),第二个包会在classloader加载类时判断重复而忽略。

  • 而这个顺序实际上是由文件系统决定的,linux内部是用inode来指示文件的。 这种储存文件元信息的区域就叫做inode,中文译名为”索引节点”。每一个文件都有对应的inode,里面包含了与该文件有关的一些信息。

  • Unix/linux系统内部不使用文件名,而使用inode号码来识别文件。对于系统来说,文件名只是inode号码便于识别的别称或者绰号。

五、总结

5.1、冲突提示信息

  • java.lang.ClassNotFoundException :类型转换错误,这个报错跟我这次遇到的一样,本应该引入的是 logback 包的类,但是实际引入的是 slf4j 下的同名类,导致类型转换错误

  • java.lang.NoSuchMethodError :找不到特定方法,如果有两个同名的包但是不同版本,例如 xxx-1.1和 xxx-1.2包同时存在,先加载了 1.1 版本的类,但是 1.2 版本中才提供了新方法,导致提示找不到特定方法

  • java.lang.NoClassDefFoundError,java.lang.LinkageError

5.2、排查思路

1、查看 catalina.sh 堆栈信息,找到有问题的类

2、通过 IDEA ,在打包的 POM 文件中,使用 Maven Helper 插件找出冲突的依赖,确定项目需要的 jar 包,Exclude 掉不需要的依赖。

5.3、提前预防

1、使用工具检查依赖冲突

冲突检测插件 :maven-enforcer-plugin

引用新的第三方依赖(工具包或者框架包),通过 Maven 插件检查一下 conflict 依赖,提前进行 Exclude

2、统一服务器版本

在测试阶段,准备好和生产环境一样的服务器,提前进行测试,避免依赖冲突的 WAR 包上传到生产环境,例如我们有一台 UAT 服务器,与生产环境一样配置,提前测试,暴露风险和解决问题。