Coding stepping on the pit - error java.lang.NoSuchMethodError / class loading problem with the same name / parental delegation [recommended collection]

This article introduces a case of troubleshooting exceptions that is actually encountered. The knowledge points involved include: class loading mechanism, class loading order in jar package, JVM parental delegation model, code examples for destroying parental delegation model and custom class loader;

problem background

  • The business version, the old function is upgraded, and a new attribute has been added to the input parameter of the dubbo interface in a second-party package that was previously referenced. This new attribute needs to be used this time; therefore, the version of the second-party package has been upgraded in the pom;

  • Passed the test function in the local environment;

  • In the test environment, the compilation and startup are normal, and when the module code is executed at runtime, an error java.lang.NoSuchMethodError is reported ;

Troubleshooting

1. Preliminary speculation is that the snapshot second-party package used was replaced before deploying the test environment , and the package where the original newly added attributes were located was replaced by the old version code, resulting in NoSuchMethodError;

By checking the records of package uploads in the warehouse, it is found that the package has not been replaced;

This situation is quite extreme. If it is replaced before compilation, the compilation should fail in theory ;

2. It is speculated that there may be multiple classes with "fully qualified class names exactly the same" in the project

Ctrl+N searches for the class name and finds that it is indeed the case!

Historical reasons

These two classes are actually related;

  • Due to historical reasons, the project where JarA is located has been split into microservices, and the dubbo interface where class C is located has been split into a new microservice project where JarB is located;

  • In order to reduce the impact of switching microservices on the business side, when the microservice project where JarB is packaged, the dubbo interface where class C is located and the fully qualified class names of related classes are consistent with the original project where JarA is located;

  • At the same time, the dubbo service of the project where the original JarA is located internally forwards the call to the new service;

  • In this way, it is guaranteed that after the new service is deployed, the old and new service names in zk are the same, and the business side has no perception;

problem analysis

  • There are 2 classes with the same fully qualified class name in the project, which is recorded as class C here;

  • The C classes in the project come from two second-party packages in the Maven dependency, which are recorded as JarA and JarB;

  • The definition of class C in JarB is basically the same as the definition of class C in JarA. This time, class C in the new package of JarB adds an attribute M;

  • During the compilation process, the compiler loads JarB first according to the order in which dependencies are introduced in the pom file of Maven, so that the C class of JarB is used, so the setter method of the new attribute M is executed in the code, and the compilation is passed;

  • According to the JVM's parent delegation model, by default, classes with the same fully qualified class name will only be loaded once, so when the JVM loads the C class, it will only select one from JarA or JarB;

  • When running, the JVM loads class C. According to the selection of [operating system] , the class file of class C of JarA is loaded this time, and class C of JarA has no new attribute attribute M, so the setter method of M is executed, and a runtime exception is reported. Prompt that the setter method cannot be found;

Two C classes with the same name come from different second-party Jar packages. They are equal. According to the JVM’s class loading mechanism—the parental delegation model, classes with the same fully qualified class name will only be loaded once by default (unless the parental delegation is manually broken Model);

The classes in the Jar package are loaded using AppClassLoader, and there is a concept of namespace in the class loader. Under the same class loader, the class with the same package name and class name will only be loaded once. If it has already been loaded, directly used loaded ;

In general, the compiler selects the expected C class of JarB according to the package order in the pom, and the JVM only loads the old C class in JarA at runtime; thus it leads to - the compilation passes, and NoSuchMethodError is prompted at runtime ;

summary

1. What is the cause of java.lang.NoSuchMethodError during this run?

There are multiple classes with the same fully qualified class name in the second-party package of the project, and the wrong class is loaded at runtime;

2. Since the wrong class is selected, why is there no compilation error?

  • JVM class loading is a lazy loading mode, which randomly selects .class files in the specified directory to load during runtime;

  • For the local compiler, change the Jar order that the compiler prefers to select which class (this order can be manually adjusted in the local IDE);

For example, in the example here, because it is a maven dependency, the master needs to put the dependency of JarA in front of JarB to modify the class loading order selected by the compiler. exists, as follows:

3. If there are multiple classes with the same fully qualified class name in the dependency, which class will the JVM load?

A more reliable statement is that the operating system itself controls the default loading order of Jar packages ; that is to say, it is not clear and uncertain to us !

The loading order of the Jar package is related to the classpath parameter. When using the idea to start the springboot service, you can see the classpath parameter; the earlier the package path is, the earlier it is loaded;

In other words, if the class in the previous Jar package is loaded, and there is a class with the same name and the same path in the later Jar package, it will be ignored and will not be loaded;

4. How to solve this kind of problem?

Let me talk about the conclusion first: because the operating system controls the loading order, the class loaded at runtime may not be consistent with the class selected at compile time, so this situation needs to be avoided in principle rather than solved!

In theory, there should not be two fully qualified class names. If there are, it is usually because two second-party packages reference a certain dependency at the same time. At this time, manual exclusion can be done;

For this case, the latest package of JarA has completely removed this "duplicate class C", so it is only necessary to update the version of the second-party package of JarA, and there will be no more classes;

In addition, JDK provides some operations to specifically destroy the parental delegation model, allowing classes with the same fully qualified class name to be "loaded multiple times";

5. How to realize that classes with the same fully qualified class name are "loaded multiple times"?

The simplest way is to set the parent of the custom class loader to null and skip the application class loader, so that two such custom class loaders can load the two classes respectively; examples are as follows:

The order of Jar references in the IDE determines which class is loaded:

Code examples for loading these two classes separately:

/**
 * @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();
        }

    }

}

output:

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;当前类具备不具备属性-跳过
-----当前类加载完成-----

Supplementary knowledge: class loader

The role of class loaders

类加载器,顾名思义就是一个可以将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包加载顺序

Guess you like

Origin blog.csdn.net/minghao0508/article/details/129047769