本篇介绍一个实际遇到的排查异常的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包加载顺序