Springboot上运行javaagent时出现NoClassDefFoundError错误的分析和解决
一、问题背景
二、问题概述
1.报错信息
org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'methodValidationPostProcessor' defined in class path resource [org/springframework/boot/autoconfigure/validation/ValidationAutoConfiguration.class]: Unsatisfied dependency expressed through method 'methodValidationPostProcessor' parameter 0; nested exception is org.springframework.beans.factory.CannotLoadBeanClassException:
Error loading class [com.wingli.agent.helper.util.SpringContextHolder] for bean with name 'com.wingli.agent.helper.util.SpringContextHolder': problem with class file or dependent class; nested exception is
java.lang.NoClassDefFoundError: org/springframework/context/ApplicationContextAware
at ......
Caused by: java.lang.ClassNotFoundException: org.springframework.context.ApplicationContextAware
at java.net.URLClassLoader.findClass(URLClassLoader.java:382) ~[?:1.8.0_251]
at java.lang.ClassLoader.loadClass(ClassLoader.java:418) ~[?:1.8.0_251]
at sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:355) ~[?:1.8.0_251]
at java.lang.ClassLoader.loadClass(ClassLoader.java:351) ~[?:1.8.0_251]
at java.lang.ClassLoader.defineClass1(Native Method) ~[?:1.8.0_251]
at java.lang.ClassLoader.defineClass(ClassLoader.java:756) ~[?:1.8.0_251]
at java.security.SecureClassLoader.defineClass(SecureClassLoader.java:142) ~[?:1.8.0_251]
at java.net.URLClassLoader.defineClass(URLClassLoader.java:468) ~[?:1.8.0_251]
at java.net.URLClassLoader.access$100(URLClassLoader.java:74) ~[?:1.8.0_251]
at java.net.URLClassLoader$1.run(URLClassLoader.java:369) ~[?:1.8.0_251]
at java.net.URLClassLoader$1.run(URLClassLoader.java:363) ~[?:1.8.0_251]
at java.security.AccessController.doPrivileged(Native Method) ~[?:1.8.0_251]
at java.net.URLClassLoader.findClass(URLClassLoader.java:362) ~[?:1.8.0_251]
at java.lang.ClassLoader.loadClass(ClassLoader.java:418) ~[?:1.8.0_251]
at sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:355) ~[?:1.8.0_251]
at java.lang.ClassLoader.loadClass(ClassLoader.java:405) ~[?:1.8.0_251]
at org.springframework.boot.loader.LaunchedURLClassLoader.loadClass(LaunchedURLClassLoader.java:94) ~[study-minder.jar:?]
at java.lang.ClassLoader.loadClass(ClassLoader.java:351) ~[?:1.8.0_251]
at org.springframework.util.ClassUtils.forName(ClassUtils.java:251) ~[spring-core-4.3.20.RELEASE.jar!/:4.3.20.RELEASE]
at ......
2.依赖关系
I.背景知识
- java会默认把javaagent的jar添加到
AppClassLoader
的搜索路径中 - Springboot插件打包的jar的结构,即
jar-in-jar/nested-jars
- Springboot插件打包的jar启动的大致流程及其使用的
LaunchedURLClassLoader
II.报错的类关系及其ClassLoader分析
为了方便区分描述: javaagent本身的jar包称为
agent jar
; Springboot打包的jar包称为springboot-application jar
三、问题排查
1.NoClassDefFoundError
和ClassNotFoundException
是什么错误?
Tips:这里特别注意一下
ClassNotFoundException
和NoClassDefFoundError
区别,前者是找不到类,后者是类加载过程出现错误(如静态代码块执行失败),导致加载失败。
所以翻译过来报错的原因是:
在加载 com.wingli.agent.helper.util.SpringContextHolder
的时候,找不到 org.springframework.context.ApplicationContextAware
类。
2.org.springframework.context.ApplicationContextAware
真的不在吗?
这时候就要祭出Arthas了,一看便知:
可以清晰看到,这个类是有加载了的。
那为啥在加载
com.wingli.agent.helper.util.SpringContextHolder
的时候会报 ClassNotFoundException
呢?
3.既然类加载过程报错,那类加载时候干了啥?
Tips:因为
jar-in-jar/nested-jars
使用的是自定义的classloader并对启动过程有包装,所以要加下 spring-boot-loader 的 pom, 可以方便debug
从上边的报错caused可以看到,org.springframework.util.ClassUtils#forName
处进入了类加载的逻辑,那就在这里开始打条件断点:
a.入口类加载器:LanuchedURLClassLoader
b.委托父加载器:AppClassLoader
c.委托父加载器:ExtClassLoader
d.委托父加载器:BoostrapClassLoader
e.BoostrapClassLoader失败,ExtClassLoader自行加载
f.ExtClassLoader失败,AppClassLoader自行加载
Tips:
AppClassLoader
默认会将agentjar
添加到类搜索路径
g.AppClassLoader能搜索到类,但是defineClass
失败了
defineClass抛出了上边最开始的caused:
ClassNotFoundException: org.springframework.context.ApplicationContextAware
4.整理分析
由上边类加载过程可以知道,错误抛出的原因是:AppClassLoader
尝试define
(区别于Not Found
)类com.wingli.agent.helper.util.SpringContextHolder
的时候,找不到 org.springframework.context.ApplicationContextAware
类。
5.为什么AppClassLoader
找不到org.springframework.context.ApplicationContextAware
呢?
org.springframework.context.ApplicationContextAware
是spring的依赖包中的类,通过上边的分析可以看到 AppClassLoader
的搜索路径只有 springboot-application jar
和 agent jar
,从类关系图里边可以看到,org.springframework.context.ApplicationContextAware
是在spring-context-4.3.20.RELEASE.jar
中,而该jar则是以jar in jar
的方式包含在springboot-application jar
里边,而AppClassLoader
没有将jar in jar
的URL添加到自己的类搜索路径中,因此找不到该类。
6.Arthas看到的是什么?
在前面我们使用Arthas的sc命令看到了 org.springframework.context.ApplicationContextAware
,难道是假的?
当然不是,只是它不是 AppClassLoader
加载的,而是 LanuchedURLClassLoader
加载的,该类加载器是 AppClassLoader
的子类加载器,所以AppClassLoader
无法直接使用该类。
使用 sc -d org.springframework.context.ApplicationContextAware
可以看到详细的加载信息
7.idea run为何没有问题?
因为idea默认不会以springboot-application jar
的方式启动应用,所以它没有使用LanuchedURLClassLoader
,也没有对启动过程进行包装,而应用本身的依赖(如spring)和agent jar
都在AppClassLoader
的路径中,不存在jar in jar
的依赖形式,所以不会出现父子类加载器的问题。
四、问题分析
一图胜千言:
五、解决方案
既然成因已明了,那么解决方法也呼之欲出!
要避免这个问题,就要区分好各层级类加载器需要加载的类,由于 org.springframework.context.ApplicationContextAware
存在于springboot-application jar
中,并且只能由 LanuchedURLClassLoader
进行加载,所以解决这个问题的关键就是让 LanuchedURLClassLoader
去加载com.wingli.agent.helper.util.SpringContextHolder
!
1.分离依赖【污染代码】
把 javaagent 中的需要spring加载的类分离出来,独立成一个pom依赖,然后让项目加上这个依赖,这样这个依赖就会只被打包到springboot-application jar
中,而且javaagent中只做字节码修改的操作,在触发bean加载的时候,就能全部由 LanuchedURLClassLoader
进行加载,不会出现类加载问题。
Tips:实际上如果需要加载的bean需要在线上使用,那么这样做是非常必要且合理的!
2.拦截部分类的加载过程并打破双亲委派机制 【不彻底】
既然双亲委派机制会让 AppClassLoader
尝试加载 com.wingli.agent.helper.util.SpringContextHolder
类,那针对这个类打破这个规则就好了!将agent jar
添加到LanuchedURLClassLoader
的搜索路径,
当 LanuchedURLClassLoader
遇到 com.wingli.agent.helper.util.SpringContextHolder
类时,直接由自身进行类加载,不走双亲委派。
示意:
Tips:
com.wingli.agent.helper.util.SpringContextHolder
对AppClassLoader
仍然是可见的!
3.javaagent中强制使用LanuchedURLClassLoader
提前加载com.wingli.agent.helper.util.SpringContextHolder
【不彻底】
LanuchedURLClassLoader
提前加载com.wingli.agent.helper.util.SpringContextHolder
与上一种方法类似,只是加载的地方放到的javaagent的逻辑里边,并且不需要修改classloader的类搜索路径,只需要使用javassist.CtClass#toClass()
来强制打破双亲委派机制,让LanuchedURLClassLoader
加载com.wingli.agent.helper.util.SpringContextHolder
类,由于已经加载过的类会被缓存起来,下次触发加载的时候会直接读取缓存,不会再触发类搜索,自然也不会走双亲委派。但是 com.wingli.agent.helper.util.SpringContextHolder
对AppClassLoader
也仍然是可见的!
4.分离依赖并利用jar in jar
特性控制classloader对依赖的可见性【就你了】
充分利用AppClassLoader
不读取jar in jar
中的类,而LanuchedURLClassLoader
可以读取jar in jar
中的类的这个特性,加上合理分离依赖,就能更优雅地解决这个问题。
六、实现步骤
由于javaagent打包出来肯定是一个jar包,所以我们期待加入到项目中的依赖也必须放到该jar包中,但是可以有两种方式,一种是jar-with-dependencies
方式(旧实现),只要Classloader有添加该jar路径,就能读取依赖,一种是jar in jar
的方式(将要实现的方式),Classloader需要指定路径(特殊获取的URL)才能读取依赖。
1.修改javaagent中逻辑和依赖关系
I.旧实现的示意图(所有类放到一起)
II.新实现示意图(按照Classloader期待的可见性对类和依赖进行分包)
Tips:Transform 中类是由
AppClassLoader
加载的,所以它的依赖也会由AppClassLoader加载(即使当前线程的ContextLoader是LaunchedURLClassLoader
),因此 transform 模块不能直接依赖 helper 模块,只能用反射,或者在插桩的时候使用。
2.修改javaagent打包逻辑
I.旧jar包示意图(所有类和依赖都解压到jar的顶级目录中)
II.新jar包示意图(分包并采用jar in jar
组合到一起)
将transform模块以jar-with-dependencies
方式进行打包,而
将 helper模块 以jar in jar
形式打包进transform.jar中。
3.让transorm.jar中的helper.jar对LaunchedURLClassLoader
可见
将helper.jar以jar in jar
形式放入tramsform.jar中使得AppClassLoaer
不可加载helper的类,成功了一半,还需要让LaunchedURLClassLoader
可以加载helper的类,为此,需要在代码的某处生成helper.jar的特殊URL,并添加到LaunchedURLClassLoader
的类加载路径中。
I.拦截点的选择
- 必须在使用到helper.jar中的类之前进行添加
- 仅添加一次
- 触发类加载时ClassLoader必须为
LaunchedURLClassLoader
本文选择的是:org.springframework.boot.SpringApplication
,该类是Springboot应用启动类
II.代码实现
在加载org.springframework.boot.SpringApplication
时,tranform中调用该方法即可
private void appendAgentNestedJars(ClassLoader classLoader) {
String agentJarPath = getAgentJarPath();
if (agentJarPath == null) return;
//LaunchedURLClassLoader 是属于 springboot-loader 的类,没有放到jar in jar里边,所以它是被AppClassLoader加载的
if (classLoader instanceof LaunchedURLClassLoader) {
LaunchedURLClassLoader launchedURLClassLoader = (LaunchedURLClassLoader) classLoader;
try {
Method method = URLClassLoader.class.getDeclaredMethod("addURL", URL.class);
method.setAccessible(true);
//遍历 agent jar,处理所有对应目录下的jar包,使用 JarFileArchive 获取到的url才可以处理jar in jar
JarFileArchive jarFileArchive = new JarFileArchive(new File(agentJarPath));
List<Archive> archiveList = jarFileArchive.getNestedArchives(new Archive.EntryFilter() {
@Override
public boolean matches(Archive.Entry entry) {
if (entry.isDirectory()) {
return false;
}
return entry.getName().startsWith("BOOT-INF/lib/") && entry.getName().endsWith(".jar");
}
});
for (Archive archive : archiveList) {
method.invoke(launchedURLClassLoader, archive.getUrl());
System.out.println("add url to classloader. url:" + archive.getUrl());
}
} catch (Throwable t) {
t.printStackTrace();
}
}
System.out.println("trigger add urls to classLoader:" + classLoader.getClass().getName() + " agentJarPath:" + agentJarPath);
}
七、结果验证
1.最终打包的jar结构
2.插桩情况
3.ClassLoader的类搜索路径
4.ClassLoader对类的可见性
-
LaunchedURLClassLoader
可以加载helper中的类 -
AppClassLoader
不可以加载helper中的类
5.jar in jar
中Bean加载情况
八、代码
1.github仓库
2.分支说明:
- default-dependency 为旧的实现方式
- split-dependency 为新的实现方式
END:不正之处,欢迎交流!
转载自:https://juejin.cn/post/7067363361368834061