本篇介绍一个实际遇到的排查异常的case,涉及的知识点包括:类加载机制、jar包中的类加载顺序、JVM双亲委派模型、破坏双亲委派模型及自定义类加载器的代码示例;

问题背景

  • 业务版本,旧功能升级,原先引用的一个二方包中的dubbo接口入参新增了属性,本次需要用到这个新属性;因此在pom中升级了该二方包的version;

  • 在本地环境测试功能通过;

  • 到test环境时,编译启动都正常,当运行时执行到该模块代码时报错java.lang.NoSuchMethodError

问题排查

1. 初步推测是使用的snapshot二方包在部署test环境前被替换,原先的新增加的属性所在的包被旧版本代码替换,导致NoSuchMethodError;

通过查看仓库中包上传的记录,发现包并没有被替换;

这种情况比较极端,如果是编译前被替换,理论上应该编译不通过才对

2. 推测可能是工程中存在"全限定类名完全一致"的多个类

Ctrl+N搜索类名,发现确实如此!

历史原因

这两个类实际上是有关系的;

  • 由于历史原因,对JarA所在的工程做了微服务拆分,将C类所在的dubbo接口拆到了JarB所在的新的微服务工程;

  • 为了减小切换微服务对业务方的影响,JarB所在的微服务工程打包时,C类所在的dubbo接口及相关类的全限定类名与原JarA所在的工程保持一致;

  • 同时,原JarA所在的工程的dubbo服务,内部转发调用到新服务;

  • 这样,就保证了新服务部署后,zk中新老服务名相同,业务方无感知;

问题分析

  • 项目中存在2个全限定类名相同的类,这里记为C类;

  • 工程中的C类分别来自Maven依赖中的2个二方包,分别记为JarA和JarB;

  • JarB中的C类的定义与JarA中的C类的定义基本一致,本次JarB新包中C类新增了一个属性M;

  • 在编译过程中,编译器根据Maven的pom文件引入依赖的顺序,先加载了JarB,从而使用JarB的C类,因此代码中执行新属性M的setter方法,编译是通过的;

  • 根据JVM的双亲委派模型,默认情况下相同全限定类名的类只会加载一次,因此JVM加载C类时只会从JarA或JarB选一个;

  • 运行时,JVM加载类C,根据【操作系统】的选择,本次加载了JarA的C类的class文件,而JarA的C类没有新属性属性M,因此执行M的setter方法,报运行时异常提示找不到setter方法;

同名的两个C类来自不同的二方Jar包,他们是平级的,根据JVM的类加载机制——双亲委派模型,相同全限定类名的类默认只会加载一次(除非手动破坏双亲委派模型);

Jar包中的类是使用AppClassLoader加载的,而类加载器中有一个命名空间的概念,同一个类加载器下,相同包名和类名的class只会被加载一次,如果已经加载过了,直接使用加载过的

总的来说,编译器根据pom中的引包顺序选择了我们预期的JarB的C类,而运行时JVM仅加载了JarA中的旧的C类;因此导致——编译通过,运行时提示NoSuchMethodError;

小结

1.本次运行时java.lang.NoSuchMethodError产生的原因?

项目二方包中存在多个全限定类名相同的类,运行时加载错了类;

2.既然选错了类,为什么没有编译错误?

  • JVM类加载是一种懒加载模式,运行时在指定目录随机选择.class文件加载;

  • 本地的编译器,改变编译器优先选择的Jar顺序从而选择哪个类(这个顺序在本地IDE中是可以手动调整的);

例如这里的例子中,由于是maven依赖,因此主需要把JarA的依赖放在JarB前面即可修改编译器选择的类加载顺序,修改后则此处直接编译不通过,提示新属性的setter方法不存在,如下:

3.如果依赖中有多个全限定类名相同的类,那JVM会加载哪一个类呢?

比较靠谱的说法是,操作系统本身,控制了Jar包的默认加载顺序;也就是说,对于我们来说是不明确不确定的!

而Jar包的加载顺序,是跟classpath这个参数有关,当使用idea启动springboot的服务时,可以看到classpath参数的;包路径越靠前,越先被加载;

换句话说,如果靠前的Jar包里的类被加载了,后面Jar包里有同名同路径的类,就会被忽略掉,不会被加载;

4.如何解决这类问题?

先说结论:因为操作系统控制加载顺序,运行时加载的类可能跟编译时选择的类不一致,因此这种情况原则上需要避免而不是解决!

理论上不应该出现两个全限定类名,如果有一般是因为2个二方包同时引用了某个依赖,此时做手动排除即可;

对于本次情况,JarA的最新包已经全部去除了这个"重复的类C",因此只需要更新JarA的二方包version即可,就不会有多个类了;

此外,JDK提供了一些骚操作来专门破坏双亲委派模型,可以让全限定类名相同的类被"加载多次";

5.如何实现全限定类名相同的类被"加载多次"?

这里使用最简单的方式,将自定义的类加载器的parent置位null,跳过应用程序类加载器,这样2个这样的自定义类加载器就可以分别加载这2个类;示例如下:

IDE中Jar引用顺序决定哪个类被加载:

分别加载这2个类的代码示例:

/**
 * @author Akira
 * @description
 * @date 2023/2/10
 */
public class SameClassTestLocal {

    public static void main(String[] args) {

        ClassLoader classloader = Thread.currentThread().getContextClassLoader();
        try {
            // 获取所有SameClassTest的.class路径
            Enumeration<URL> urls = classloader.getResources("pkg/my/SameClassTest.class");
            while (urls.hasMoreElements()) {
                // url有2个:jar:file:/E:/jar/jarB.jar!/pkg/my/SameClassTest.class和jar:file:/E:/jar/jarA.jar!/pkg/my/SameClassTest.class
                URL url = (URL) urls.nextElement();
                String fullPath = url.getPath();
                System.out.println("fullPath: " + fullPath);
                // 截取fullPath 获取jar文件的路径以及文件名,用来读取.class文件
                String[] strs = fullPath.split("!");
                String jarFilePath = strs[0].replace("file:/", "");
                String classFullName = strs[1].substring(1).replace(".class", "").replace("/", ".");
                System.out.println("jarFilePath: " + jarFilePath);
                System.out.println("classFullName: " + classFullName);

                // 关键步骤:用兄弟类加载器分别加载 父加载器置位null
                File file = new File(jarFilePath);
                URLClassLoader loader = new URLClassLoader(new URL[]{file.toURI().toURL()}, null);
                try {
                    // 加载类 .class转Class对象
                    Class<?> clazz = loader.loadClass(classFullName);
                    // 反射创建实体
                    Object obj = clazz.getDeclaredConstructor().newInstance();
                    // 获取全部属性
                    final Field[] fields = clazz.getDeclaredFields();
                    if (fields.length > 0) {
                        // 这里通过反射获取Fields 判断是否有新属性"reqNo"
                        final boolean containsReqNo = Stream.of(fields).map(Field::getName).collect(Collectors.toSet()).contains("reqNo");
                        if (containsReqNo) {
                            // 如果有则执行setter方法
                            Method method = clazz.getMethod("setReqNo", String.class);
                            method.invoke(obj, "seqStr");
                            System.out.println("当前类的包路径:" + jarFilePath + ";当前类具备reqNo属性 " + "json:" + JSON.toJSONString(obj));
                        } else {
                            System.out.println("当前类的包路径:" + jarFilePath + ";当前类具备不具备属性-跳过");
                        }
                    }
                } catch (Exception e) {
                    e.printStackTrace();
                }
                System.out.println("-----当前类加载完成-----");
            }
        } catch (IOException e) {
            e.printStackTrace();
        }

    }

}

输出:

fullPath: file:/E:/jar/jarB.jar!/pkg/my/SameClassTest.class
jarFilePath: E:/jar/jarB.jar
classFullName: pkg.my.SameClassTest
当前类的包路径:E:/jar/jarB.jar;当前类具备reqNo属性 json:{"reqNo":"seqStr"}
-----当前类加载完成-----

fullPath: file:/E:/jar/jarA.jar!/pkg/my/SameClassTest.class
jarFilePath: E:/jar/jarA.jar
classFullName: pkg.my.SameClassTest
当前类的包路径:E:/jar/jarA.jar;当前类具备不具备属性-跳过
-----当前类加载完成-----

补充知识:类加载器

类加载器的作用

类加载器,顾名思义就是一个可以将Java字节码加载为java.lang.Class实例的工具;这个过程包括,读取字节数组、验证、解析、初始化等;

类加载器的特点

  • 懒加载/动态加载:JVM并不是在启动时就把所有的.class文件都加载一遍,而是在程序运行的过程中,用到某个类时动态按需加载;这个动态加载的特点为热部署、热加载做了有力支持;

  • 依赖加载:跟Spring的Bean的依赖注入过程有点像,当一个类加载器加载一个类时,这个类所依赖的、引用的其他所有类,都由这个类加载器加载;除非在程序中显式地指定另外一个类加载器加载;

哪几种类加载器

  • 启动类加载器(Bootstrap ClassLoader):负责加载<JAVA_HOME>\lib目录中的,或者被-Xbootclasspath参数所指定的路径,并且是虚拟机识别的类库加载到虚拟机内存中;无法被Java程序直接引用;自定义类加载器时,如果想设置Bootstrap ClassLoader为其父加载器,可直接设置parent=null;

  • 扩展类加载器(Extension ClassLoader):负责加载<JAVA_HOME>\lib\ext目录中的,或者被java.ext.dirs系统变量所指定路径中的所有类库;其父类加载器为启动类加载器;

  • 应用程序类加载器(Application ClassLoader):负责加载用户类路径(ClassPath)上所指定的类库;被启动类加载器加载的,但它的父加载器是扩展类加载器;在一个应用程序中,系统类加载器一般是默认类加载器;

  • 自定义类加载器(User ClassLoader):用户自己定义的类加载器;一般情况下我们不会自定义类加载器,除非特殊情况破坏双亲委派模型,需要实现java.lang.ClassLoader接口;

一个类的唯一性

一个类的唯一性由加载它的类加载器和这个类的本身决定,类唯一标识包括2部分:(1)类的全限定名(2)类加载器的实例ID

比较两个类是否相等(包括Class对象的equals()、isAssignableFrom()、isInstance()以及instanceof关键字等),只有在这两个类是由同一个类加载器加载的前提下才有意义;否则,即使这两个类来源于同一个Class文件,被同一个虚拟机加载,只要加载它们的类加载器不同,这两个类就必定不相等;

补充知识:JVM双亲委派

什么是双亲委派

实现双亲委派机制,首先检查这个类是不是已经被加载过了,如果加载过了直接返回,否则委派给父加载器加载,这是一个递归调用,一层一层向上委派,最顶层的类加载器(启动类加载器)无法加载该类时,再一层一层向下委派给子类加载器加载;

  • JVM的类加载器是分层次的,它们有父子关系,而这个关系不是继承维护,而是组合,每个类加载器都持有一个parent字段;

  • 除了启动类加载器外,其他所有类加载器都需要继承抽象类ClassLoader;

ClassLoader类的3个关键方法

  • defineClass方法:调用native方法把Java类的字节码解析成一个Class对象;

  • findClass:找到.class文件并把.class文件读到内存得到字节码数组,然后调用defineClass方法得到Class对象;

  • loadClass实现双亲委派机制;当一个类加载器收到“去加载一个类”的请求时,会先把这个请求“委派”给其父类类加载器;这样无论哪个层的类加载器,加载请求最终都会委派给顶层的启动类加载器,启动类加载器在其目录下尝试加载该类;父加载器找不到该类时,子加载器才会自己尝试加载这个类;

为什么使用双亲委派模型?

双亲委派保证类加载器"自下而上的委派,自上而下的加载",保证每一个类在各个类加载器中都是同一个类,换句话说,就是保证一个类只会加载一次

一个非常明显的目的就是保证java官方的类库<JAVA_HOME>\lib和扩展类库<JAVA_HOME>\lib\ext的加载安全性,不会被开发者覆盖

如果开发者通过自定义类尝试覆盖JDK中的类并加载,JVM一定会优先加载JDK中的类而不再加载用户自己尝试覆盖而定义的类;例如类java.lang.Object,它存放在rt.jar之中,无论哪个类加载器要加载这个类,最终都是委派给启动类加载器加载,因此Object类在程序的各种类加载器环境中都是同一个类;

此外,根据ClassLoader类的源码(java.lang.ClassLoader#preDefineClass),java禁止用户用自定义的类加载器加载java.开头的官方类,也就是说只有启动类加载器BootstrapClassLoader才能加载java.开头的官方类;

类似的,如果开发者自己开发开源框架,也可以自定义类加载器,利用双亲委派模型,保护自己框架需要加载的类不被应用程序覆盖;

破坏双亲委派?

双亲委派模型并不是一个具有强制性约束的模型,而是Java设计者推荐给开发者们的类加载器实现方式;当开发者有特殊需求时,这个委派和加载顺序完全是可以被破坏的:

  • 如想要自己显示的加载某个指定类;

  • 或者由于一些框架的特殊性,如Tomcat需要加载不同工程路径的类,Tomcat中可以部署多个web项目,为了保证每个web项目互相独立,所以不能都由AppClassLoader加载,所以自定义了类加载器WebappClassLoader;

  • 以及本篇提到的加载全限定类名相同的多个类;

破幻双亲委派模型的方式:

上面介绍了,实现双亲委派的核心就在ClassLoader#loadClass;如果想不遵循双亲委派的类加载顺序,可以自定义类加载器,重写loadClass,不再先委派父亲类加载器而是选择优先自己加载

另一种简单粗暴的方式就是直接将父加载器parent指定位null,这样做主要就是跳过了默认的应用程序类加载器(Application ClassLoader),自己来加载某个指定类

参考:

Java双亲委派模型:为什么要双亲委派?如何打破它?破在哪里?

如何加载两个jar包中含有相同包名和类名的类

JVM jar包加载顺序

Logo

旨在为数千万中国开发者提供一个无缝且高效的云端环境,以支持学习、使用和贡献开源项目。

更多推荐