问题起源
最近在工作中实现一个链路追踪系统,使用到了javaagent技术,通过字节码注入技术无侵入的对应用常用的组件进行链路追踪和监控。我把写好的javaagent程序打好包应用到目标程序,当使用idea或者eclipse启动应用程序时,没有什么问题;由于我们的项目是springboot项目,一般会使用spring-boot-maven-plugin
插件进行打包,这时通过java -javaagent:xxxx.jar -jar xxx.jar
来启动应用,这时会抛出一个异常java.lang.NoClassDefFoundError
。这是怎么回事呢?要弄清楚这个问题所在,就必须深入理解java的类加载机制了。
问题复现
讲述这个问题之前,先来用一个简单的例子来复现下这个场景。
- 创建一个springboot应用:agent-springboot-a,简单操作jedis客户端
- 创建一个javaagent应用:agent-bytebuddy,使用bytebutty框架来操作字节码,拦截jedis的set方法。
代码地址:https://gitee.com/yanghuijava...
将两个项目各自打好包后,通过命令以下命令启动应用
java -javaagent:D:\workspace1\agent-tutorial\agent-bytebuddy\target\agent-bytebu
ddy.jar -jar agent-springboot-a-0.0.1-SNAPSHOT.jar
通过浏览器访问地址
http://localhost:8080/user/login?name=admin&password=123456
这时应用会出现一个异常:java.lang.NoClassDefFoundError
分析原因
我们知道java语言本身给我提供了三种类加载器:
- Bootstrap ClassLoader:负责加载java核心类库/jre/lib/rt.jar
- Extension ClassLoader:负责加载/jre/lib/ext/下的jar包
- App ClassLoader:加载系统变量CLASSPATH下的类
如果有必要我们也可以自定义自己的classLoader,它们的关系如下:
java的类加载机制是遵循双亲委派原则的,当某个类加载器要去加载某个class时,首先会查找自己有没有加载过,如果有,直接返回;如果没有,自己并不会去加载,而是委托给父类去加载,一直追溯到Bootstrap ClassLoader,加载不到就依次回退直到当前类加载器,这时如果还是加载不到,则抛出java.lang.ClassNotFoundException
异常,简单描述就是(如上图所示):
- 类的加载是自上而下
- 类的查找是自下而上
为什么要有需要这种双亲委派机制呢?试想一下,如果没有这个机制,每个类加载器加载了都是自己先去加载,那么如果用户编写一个类跟java核心类库的类一模一样(包名和类名一样),这时我们编写的这个类就会被优先加载,而java核心类库的类就没法加载了,这样就篡改了java的核心类库。
类加载时,jvm到底是如何选择类加载器来加载呢?是这样,每一个加载好的Class对象都会记录它的classLoader引用,jvm在加载类时首先使用的是类的类加载器,当作当前类的类加载器。怎么理解呢?举个列子,有一个A类,里面存在代码:B b = new B();那么B类会使用A的类加载器作为起始类加载器。
了解上面两个类加载机制,现在我们来看看异常是怎么发生的?首先javaagent永远都是使用App ClassLoader来加载,当我们使用eclipse或者idea来启动应用程序时,使用的也是App ClassLoader来加载的,这种方式启动的classLoader和javaagent的是一样的,所以会运行的很好。但是,我们使用spring-boot-maven-plugin
插件打包应用,通过java -javaagent:xxxx.jar -jar xxxx.jar来启动应用时,应用的classLoader已经不再是App ClassLoader了,而是使用springboot自定义的LaunchedClassLoader来加载,来看一下springboot打包后目录结构:
这时classLoader的类关系如下:
可以看到springboot方式打包的应用,main方法的入口类已经不是我们应用自己写的了,而是org.springframework.boot.loader.JarLauncher类,这个类会使用LaunchedClassLoader来加载我们应用类,而我们javaagent使用到的应用的类,这时就没法加载了,因为这时classpath下没有任何应用的类,App ClassLoader是没法加载的,也无法访问子classLoader(LaunchedClassLoader)加载的类,故而抛出NoClassDefFoundError的异常。
解决方法
一、第一种方式(不推荐)
我们回到agent-bytebuddy项目的maven配置:
......
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>3.3.0</version>
<scope>provided</scope>
</dependency>
......
jedis的maven scope是provided,也就是说我们使用maven打包时不会把jedis的相关jar打入agent-bytebuddy.jar中;有上面的分析我们知道,既然是因为appClassloader加载不到jedis类而引发的异常,那么如果我们把jedis的相关类打进agent-bytebuddy.jar中,这时appClassloader不就可以加载到么?是的,答案是肯定的;我们去掉<scope>provided</scope>
,重新打包,再次运行agent-springboot-a
项目,就没有异常发生了;这时jedis的classLoader是APPClassLoader,而非springboot的LaunchedClassLoader。不过细心的你可能会发现,这时项目启动竟然没有了日志输出。其实这正是双亲委派机制造成,具体为什么,你可以自己先想想,如果想不明白,可以给我留言。我再给你解答。知道为什么不推荐这种方式了吧,因为把相关jar打进agent jar包里面,会对我们的应用造成影响,而这种影响是不好解决的。
二、第二种方式(推荐):插件机制
这种方式我们需要自定义classLoader,实现方式有点复杂,我们一步步来讲解
1、在项目agent-bytebuddy
,新建一个类AgentClassloader
public class AgentClassloader extends URLClassLoader {
public AgentClassloader(URL[] urls, ClassLoader parent) {
super(urls,parent);
}
}
2、新建一个插件接口
public interface IMethodInterceptor {
Object before(Object thisObj);
void after(Object params);
}
3、新建一个项目agent-plugin
,写一个类JedisMethodInterceptor
实现IMethodInterceptor
接口,
public class JedisMethodInterceptor implements IMethodInterceptor {
@Override
public Object before(Object thisObj) {
Long start = System.currentTimeMillis();
try{
Jedis jedis = (Jedis) thisObj;
System.out.println(jedis.info());
}catch (Throwable t){
t.printStackTrace();
}
return start;
}
@Override
public void after(Object params) {
Long end = System.currentTimeMillis();
Long start = (Long)params;
System.out.println("耗时:" + (end - start) + "毫秒");
}
}
使用maven命令:mvn clean package
打好包,待后面使用,我的jar包位置是:D:\workspace1\agent-tutorial\agent-plugin\target\agent-plugin-0.0.1-SNAPSHOT.jar
4、回到agent-bytebuddy
项目,新建一个Interceptor
类,代码如下:
public static class Interceptor {
private IMethodInterceptor methodInterceptor;
public Interceptor(ClassLoader classLoader){
try{
AgentClassloader myClassLoader = new AgentClassloader(new URL[] {
new URL("file:D:\\workspace1\\agent-tutorial\\agent-plugin\\target\\agent-plugin-0.0.1-SNAPSHOT.jar")},
classLoader);
Object plugin = Class.forName("com.yanghui.agent.plugin.JedisMethodInterceptor",
true,myClassLoader).newInstance();
this.methodInterceptor = (IMethodInterceptor)plugin;
}catch (Throwable t){
t.printStackTrace();
}
}
@RuntimeType
public Object intercept(@This Object obj, @Origin Method method, @AllArguments Object[] allArguments,@SuperCall Callable<?> callable) throws Exception{
Object before = this.methodInterceptor.before(obj);
try{
return callable.call();
}finally {
this.methodInterceptor.after(before);
}
}
}
Interceptor构造方法的实现是关键,我们使用自己的AgentClassloader
去加载方法拦截器,注意AgentClassloader
的构造方法传入了一个classLoader对象,这里的classLoader是哪里来的呢?请看先这段代码:
public static void premain(String agentArgs, Instrumentation inst) throws Exception {
AgentBuilder agentBuilder = new AgentBuilder.Default();
AgentBuilder.Transformer transformer = (builder, typeDescription, classLoader, module) -> {
builder = builder
.method(ElementMatchers.named("set"))
.intercept(MethodDelegation.to(new Interceptor(classLoader)));
return builder;
};
agentBuilder.type(
ElementMatchers.named("redis.clients.jedis.Jedis")
)
.transform(transformer)
.with(new Listener())
.installOn(inst);
}
我们使用bytebuddy来做字节码注入实现拦截,这个classLoader就是bytebuddy传过来的,这里我们要对redis.clients.jedis.Jedis
这个类进行拦截,也就是说这个classLoader就是加载Jedis类的classLoader,我们使用的springboot项目,这里就是LaunchedClassLoader
,我用下图来描述这种类加载器的关系:
好了到这里我们的代码实现已经完成了,先修改下agent-bytebuddy
项目pom.xml文件指定agent的premain方法入口:
......
<manifestEntries>
<Premain-Class>com.yanghui.agent.agentBytebuddy.plugin.AgentBootUsePlugin</Premain-Class>
<Can-Retransform-Classes>true</Can-Retransform-Classes>
</manifestEntries>
......
打包后再次运行agent-springboot-a
项目,发现没有报错了,完美解决。
熟悉开源项目skywalking的读者可能会发现,这就是skywalking实现插件机制的核心原理。由于这只是个演示项目,所以比较简陋,后续我会推出手把手教你实现一个链路追踪系统,到时会教你实现一个通用的插件加载器,一个微内核架构。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。