JavaAgent 技术原理及实战
1、引子
线上故障群发来一条用户投诉:用户抱怨页面加载时间过长,有时甚至超时。这时,你首先检查了服务器和数据库,但并未发现有问题。你尝试在开发环境中复现问题,但一切运行正常。这个问题只在生产环境的特定时段出现,常规的Debug方式并不奏效。
此刻的你感到困惑和无助,不禁开始怀疑:是不是应用程序的某个部分在增加系统的延迟?有没有方法可以帮助你追踪代码的运行过程,看一看到底哪里出现了问题?
此时,JavaAgent技术闪耀登场。这项技术可以帮助你深入观察应用程序的运行状态,助你洞察问题的根源。那么我们就一起来探索一下JavaAgent技术。
2、JavaAgent 简单示例:方法开始和结束时打印日志
2.1 创建 Agent
创建javaagent-demo工程,目录结构如下:
新建 pom.xml
,引入 javassist
用来修改目标类的字节码,增加自定义代码。通过 maven-assembly-plugin
插件打包自定义的 agent jar。
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<parent>
<artifactId>javaagent-demo</artifactId>
<groupId>org.example</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>agent-demo</artifactId>
<packaging>jar</packaging>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
</properties>
<dependencies>
<dependency>
<groupId>org.javassist</groupId>
<artifactId>javassist</artifactId>
<version>3.25.0-GA</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-assembly-plugin</artifactId>
<version>3.1.1</version>
<configuration>
<descriptorRefs>
<!--将应用的所有依赖包都打到jar包中。如果依赖的是 jar 包,jar 包会被解压开,平铺到最终的 uber-jar 里去。输出格式为 jar-->
<descriptorRef>jar-with-dependencies</descriptorRef>
</descriptorRefs>
<archive>
<!-- 设置manifest配置文件-->
<manifestEntries>
<!--Premain-Class: 代表 Agent 静态加载时会调用的类全路径名。-->
<Premain-Class>com.atu.DemoAgent</Premain-Class>
<!--Agent-Class: 代表 Agent 动态加载时会调用的类全路径名。-->
<Agent-Class>com.atu.DemoAgent</Agent-Class>
<!--Can-Redefine-Classes: 是否可进行类定义。-->
<Can-Redefine-Classes>true</Can-Redefine-Classes>
<!--Can-Retransform-Classes: 是否可进行类转换。-->
<Can-Retransform-Classes>true</Can-Retransform-Classes>
</manifestEntries>
</archive>
</configuration>
<executions>
<execution>
<!--绑定到package生命周期阶段上-->
<phase>package</phase>
<goals>
<!--绑定到package生命周期阶段上-->
<goal>single</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.1</version>
<configuration>
<source>${maven.compiler.source}</source>
<target>${maven.compiler.target}</target>
</configuration>
</plugin>
</plugins>
</build>
</project>
编写agent核心代码 DemoAgent.java,我们使用了premain()
静态加载方式,agentmain()
动态加载方式。并用到了Instrumentation
类结合javassist代码生成库进行字节码的修改。
public class DemoAgent {
/**
* 被转换的类
*/
private static String TRANSFORM_CLASS = "com.atu.Test";
public static void premain(String agentArgs, Instrumentation inst) {
// 在这里可以对应用程序进行字节码转换
// 例如,添加一个Transformer
inst.addTransformer(new MyClassTransformer());
}
/**
* 动态加载。Java agent指定的premain方法,会在main方法之前被调用
*/
public static void agentmain(String args, Instrumentation inst) {
System.out.println("agentmain start!");
inst.addTransformer(new MyClassTransformer());
Class<?>[] classes = inst.getAllLoadedClasses();
if (classes != null) {
for (Class<?> c : classes) {
if (c.isInterface() || c.isAnnotation() || c.isArray() || c.isEnum()) {
continue;
}
if (c.getName().equals(TRANSFORM_CLASS)) {
try {
System.out.println("retransformClasses start, class: " + c.getName());
/*
* retransformClasses()对JVM已经加载的类重新触发类加载。使用的就是上面注册的Transformer。
* retransformClasses()可以修改方法体,但是不能变更方法签名、增加和删除方法/类的成员属性
*/
inst.retransformClasses(c);
System.out.println("retransformClasses end, class: " + c.getName());
} catch (UnmodifiableClassException e) {
System.out.println("retransformClasses error, class: " + c.getName() + ", ex:" + e);
e.printStackTrace();
}
}
}
}
System.out.println("agentmain end!");
}
}
public class MyClassTransformer implements ClassFileTransformer {
/**
* 被转换的类
*/
private static String TRANSFORM_CLASS = "com.atu.Test";
@Override
public byte[] transform(ClassLoader loader, String className,
Class<?> classBeingRedefined, ProtectionDomain protectionDomain,
byte[] classfileBuffer) throws IllegalClassFormatException {
// 在这里可以修改字节码
// 例如,打印类名
try {
className = className.replace("/", ".");
if (className.equals(TRANSFORM_CLASS)) {
final ClassPool classPool = ClassPool.getDefault();
final CtClass clazz = classPool.get(TRANSFORM_CLASS);
for (CtMethod method : clazz.getMethods()) {
/*
* Modifier.isNative(methods[i].getModifiers())过滤本地方法,否则会报
* javassist.CannotCompileException: no method body at javassist.CtBehavior.addLocalVariable()
* 报错原因如下
* 来自Stack Overflow网友解答
* Native methods cannot be instrumented because they have no bytecodes.
* However if native method prefix is supported ( Transformer.isNativeMethodPrefixSupported() )
* then you can use Transformer.setNativeMethodPrefix() to wrap a native method call inside a non-native call
* which can then be instrumented
*/
if (Modifier.isNative(method.getModifiers())) {
continue;
}
method.insertBefore("System.out.println(\"" + clazz.getSimpleName() + "."
+ method.getName() + " start.\");");
method.insertAfter("System.out.println(\"" + clazz.getSimpleName() + "."
+ method.getName() + " end.\");", false);
}
return clazz.toBytecode();
}
} catch (Exception e) {
e.printStackTrace();
}
return classfileBuffer;
}
}
编译打包:
2.2 编写验证 agent 功能的测试类
public class Test {
public static void main(String[] args) throws InterruptedException {
System.out.println("helloworld!");
Thread.sleep(20000);
}
}
2.2.1 使用JavaAgent 静态加载方式
在 IDEA 的 Run/Debug Configurations
中,点击 Modify options
,勾选上 add VM options
,在 VM options
栏增加 -javaagent:全路径\agent-demo-1.0-SNAPSHOT-jar-with-dependencies.jar
。
运行 Test.java 的 main 方法,可以看到控制台日志:
2.2.2 使用 JavaAgent 动态加载方式
动态加载不是通过 -javaagent:
的方式实现,而是通过 Attach API 的方式。
编写调用 Attach API 的测试类:
public class AttachMain {
public static void main(String[] args) throws Exception {
// agentmain()方法所在jar包
String jar = "E:\\projects\\mycode\\javaagent-demo\\agent-demo\\target\\agent-demo-1.0-SNAPSHOT-jar-with-dependencies.jar";
for (VirtualMachineDescriptor virtualMachineDescriptor : VirtualMachine.list()) {
// 针对指定名称的JVM实例
if (virtualMachineDescriptor.displayName().equals("com.atu.Test")) {
System.out.println("将对该进程的vm进行增强:com.atu.Test的vm进程, pid=" + virtualMachineDescriptor.id());
// attach到新JVM
VirtualMachine vm = VirtualMachine.attach(virtualMachineDescriptor);
// 加载agentmain所在的jar包
vm.loadAgent(jar);
// detach
vm.detach();
}
}
}
}
先直接运行 com.atu.Test#main,注意不用加 -javaagent: 启动参数。
约 5 秒后,再运行 com.atu.AttachMain#main,可以看到 com.atu.AttachMain#main 打印的日志:
将对该进程的vm进行增强:com.atu.Test的vm进程, pid=15544
之后可以看到 com.atu.Test#main打印的日志中多了记录方法运行开始和结束的内容。
helloworld!
agentmain start!
retransformClasses start, class: com.atu.Test
retransformClasses end, class: com.atu.Test
agentmain end!
2.3、小结
可以看到静态加载或动态加载相同的 agent,都能实现了记录记录方法运行开始和结束日志的功能。
下面进入正题:什么是JavaAgent?
3、JavaAgent
3.1 JavaAgent是什么?
JavaAgent 是一种特殊的类,它提供了一种能力,使得我们可以在Java程序运行期间,对加载到JVM中的类进行字节码层面的修改和增强。
简单来说,JavaAgent能够“拦截”类加载过程,在类被使用前“改变”它的行为。
JavaAgent本质上可以理解为一个插件,该插件就是一个精心提供的jar包,这个jar包通过JVMTI(JVM Tool Interface)完成加载,最终借助JPLISAgent(Java Programming Language Instrumentation Services Agent)完成对目标代码的修改。
JavaaAgent通常用在如性能监控(Profiler)、代码热替换、动态追踪等领域。一些知名的工具,比如JRebel(代码热替换)、SkyWalking(性能监控)就是基于JavaAgent 实现的。
3.2 原理解析
3.2.1 JVMTI
JVMTI (JVM Tool Interface)是 Java 虚拟机对外提供的 Native 编程接口,通过 JVMTI ,外部进程可以获取到运行时 JVM 的诸多信息,比如线程、GC 等。
JVMTI是基于事件驱动的,JVM每执行一定的逻辑就会触发一些事件的回调接口,通过这些回调接口,用户可以自行扩展实现自己的逻辑。
JVMTI 是一套 Native 接口,在 Java SE 5 之前,要实现一个 Agent 只能通过编写 Native 代码来实现。从 Java SE 5 开始,可以使用 Java 的Instrumentation 接口(java.lang.instrument)来编写 Agent。无论是通过 Native 的方式还是通过 Java Instrumentation 接口的方式来编写 Agent,它们的工作都是借助 JVMTI 来进行完成。
Instumentation API 可以支持 Java 语言实现 agent 功能,但是 JVMTI 功能比 Instumentation API 更强大。
3.2.2 Instrumentation
Instrumentation 是 Java 提供的 JVM 接口,该接口提供了一系列查看和操作 Java 类定义的方法,例如修改类的字节码、向 classLoader 的 classpath 下加入 jar 文件等。使得开发者可以通过 Java 语言来操作和监控 JVM 内部的一些状态,进而实现 Java 程序的监控分析,甚至实现一些特殊功能(如 AOP、热部署)。
Instrumentation接口中最常用的方法是 addTransformer(ClassFileTransformer transformer)
,这个方法可以在类加载时做拦截,对输入的类的字节码进行修改,其参数是一个ClassFileTransformer
接口:
public interface ClassFileTransformer {
/**
* 传入参数表示一个即将被加载的类,包括了classloader,classname和字节码byte[]
* 返回值为需要被修改后的字节码byte[]
*/
byte[]
transform( ClassLoader loader,
String className,
Class<?> classBeingRedefined,
ProtectionDomain protectionDomain,
byte[] classfileBuffer)
throws IllegalClassFormatException;
}
3.2.3 JavaAgent 的加载
JavaAgent 支持静态加载和动态加载。
3.2.3.1 静态加载
静态加载,即 JVM 启动时加载,对应的是 premain()
方法。通过 vm 启动参数-javaagent
将 agent jar 挂载到目标 JVM 程序,随目标 JVM 程序一起启动。
- 加载 JavaAgent:JavaAgent 中的 class 通常是由
system calss loader
(默认AppClassLoader) 加载。 - 调用 premain方法: JVM 加载完成 JavaAgent 的入口类之后,会调用其 premain 方法
- 使用 Instrumentation API:通过 premain 方法,JavaAgent 得到了 Instrumentation 的实例。JavaAgent 可以使用这个 Instrumentation 实例来注册一个或多个 ClassFileTransformer,或者进行其它需要的操作。这是 JavaAgent 真正开始发挥作用的地方。
- Instrumentation API 调用 JVMTI: JVMTI 是 JNI (Java Native Interface) 的一部分,提供了丰富的接口供 Instrumentation API 和 JVM 进行交互。
- 执行 ClassFileTransformer:当 Instrumentation 需要载入类时,如果 Java Agent 对该类注入了 ClassFileTransformer,JVMTI 会回调
Instrumentation API
,然后调用对应的ClassFileTransformer.transform
方法。在 transform 方法中,JavaAgent 可以修改类的字节码。 - premain() 方法会调用 Instrumentation API,然后 Instrumentation API 调用 JVMTI(JVMTI 的内容将在后面补充),在需要加载的类需要被加载时,会回调 JVMTI,然后回调 Instrumentation API,触发 ClassFileTransformer.transform(),最终修改 class 的字节码。
- 加载主类并启动应用: 所有的 JavaAgent 加载和初始化完成后,JVM 会准备开始加载主类并执行其 main 方法。
ClassFileTransformer.transform:是 Java Instrumentation API 中的核心方法,它的作用是在类文件被 JVM 加载之前,对其进行字节码级别的转换和修改。
3.2.3.2 动态加载
JVM运行时加载,可以在 main 函数开始运行之后再运行。通过Attach API动态地加载 JavaAgent,对应的是 agentmain()
方法。
基本流程:
- 创建一个 VirtualMachine 实例:首先,使用
VirtualMachineDescriptor
获取要附加的Java虚拟机的描述,并通过VirtualMachine.attach()
方法连接到该JVM。 - 加载JavaAgent:在连接到 JVM 后,使用
VirtualMachine.loadAgent()
方法加载JavaAgent的jar包。 - 修改字节码
- 断开连接:改完成后,通过
VirtualMachine.detach()
方法断开与JVM的连接。所有的更改都会在目标JVM中保存,不会影响到运行Agent的JVM。
4、实战:使用JavaAgent实现全链路监控基础版
4.1 概述
- 利用 javassist 对 Log 框架进行切面增强。
- 利用 Spring 的拦截器技术实现了 Web 请求的 traceId 初始赋值。
- 通过整合 dubbo SPI,结合 dubbox 调用拦截器,实现 traceId 的拦截及赋值。
- 引入TransmittableThreadLocal 解决父子线程上下文传递的问题。
4.2 技术环境
Javassist 3.25.0-GA+Dubbox 2.6.5+Spring Boot 2.0.5.RELEASE+Transmittable 2.12.2
4.3、常见问题
4.3.1 JavaAgent
JavaAgent 是一种特殊的 Java 程序,它利用 Java 的 Instrumentation 机制在运行时改变或分析其他Java 程序的行为,并对其进行监控、调试、或性能优化等操作。
有了 JavaAgent 技术,可以在字节码这个层面对类和方法进行修改,可以把 JavaAgent 理解成一种代码注入的方式,或者可以说 JavaAgent 就是 JVM 层面的代理程序。
4.3.2 常用的修改字节码的工具?
- ASM
- Javassist
- ByteBuddy
4.3.3 为什么跨线程不能透传traceId?如何解决?
我们常用的日志框架,比如 Logback,Log4j 等,通过使用 MDC(Mapped Diagnostic Context,映射调试上下文)在多线程环境下记录日志。
我们的请求链路ID,也是借助 MDC 实现传递的。
MDC 是以线程为基础的存储结构,每个线程都有其自己的一份独立的 MDC 数据。这是通过底层的 ThreadLocal 实现的,ThreadLocal 为每个线程提供了一个独立的数据副本,每个线程都只能看到及修改自己的 ThreadLocal 副本数据,而看不到其他线程的数据,这样可以有效避免数据之间的相互影响。
那么当异步方法切换线程的时候,就会出现上下文信息传递丢失的问题。从而导致 TraceID 丢失的问题。
我们需要在父线程中手动获取并传递 MDC 数据到子线程,解决数据跨线程传递的问题。
4.3.4 ThreadLocal、InheritableThreadLocal和TransmittableThreadLocal三者区别?
1)、ThreadLocal
ThreadLocal主要是为每个ThreadLocal对象创建一个ThreadLocalMap来保存对象和线程中的值的映射关系。
当创建一个ThreadLocal对象时会调用get()或set()方法,在当前线程的中查找这个ThreadLocal对象对应的Entry对象,如果存在,就获取或设置Entry中的值;否则,在ThreadLocalMap中创建一个新的Entry对象。
ThreadLocal类的实例被多个线程共享,每个线程都拥有自己的ThreadLocalMap对象,存储着自己线程中的所有ThreadLocal对象的键值对。
ThreadLocal的实现比较简单,但需要注意的是,如果使用不当,可能会出现内存泄漏问题,因为ThreadLocalMap中的Entry对象并不会自动删除。
2)、InheritableThreadLocal
InheritableThreadLocal的实现方式和ThreadLocal类似,但不同之处在 Thread 类的 init() 方法中,当创建新的线程时,会调用 inheritThreadLocals(parentThread) 方法,这个方法就是将父线程的 InheritableThreadLocalMap (注意这里并不是 ThreadLocalMap)复制一份到子线程中。
局限性:InheritableThreadLocal 支持子线程访问在父线程的核心思想是在创建线程的时候将父线程中的本地变量值复制到子线程,即复制的时机为创建子线程时。
线程池能够复用线程,减少线程的频繁创建与销毁,如果使用 InheritableThreadLocal ,那么线程池中的线程拷贝的数据来自于第一个提交任务的外部线程,即后面的外部线程向线程池中提交任务时,子线程访问的本地变量都来源于第一个外部线程,造成线程本地变量混乱。
3)、TransmittableThreadLocal
TransmittableThreadLocal 是阿里巴巴开源的专门解决 InheritableThreadLocal 的局限性,实现线程本地变量在线程池的执行过程中,能正常的访问父线程设置的线程变量。
TTL 的设计理念在于,每次线程执行任务时,都会备份当前线程的 TTL 值,然后从提交任务的线程那里拷贝一份新的 TTL 值到当前线程,
任务执行完成后,再将备份的 TTL 值恢复回当前线程。
具体流程如下:
- 任务提交到线程池时,首先会将提交任务的线程(父线程)的 TTL 值拷贝一份,然后作为本次任务执行的上下文。
- 线程获取到任务执行时,先将线程原先的 TTL 值进行备份,然后将第一步拷贝的 TTL 值设置到线程中。
- 任务执行完成后,线程将自身的 TTL 值设置回第二步备份的值。
通过这种方式,TTL 成功解决了在使用 InheritableThreadLocal 时线程池中由于线程复用导致的问题,确保了每次任务执行时,线程内的 TTL 值都是我们期望的那个值。
4.3.5 Spring Boot Starter?
Spring Boot Starter 是 Spring Boot 框架提供的一种特性,它是一种提供依赖项的方式,可以帮助开发人员快速集成各种第三方库和框架。
Spring Boot Starter 的目的是简化 Spring 应用程序的依赖管理,将一组相关的依赖项打包在一起,并提供一个依赖项描述文件,使开发人员可以快速集成。
Spring Boot Starter 本质上是一个包含了必要依赖和自动配置类的 Maven 依赖(是一系列依赖集合),它能够自动配置应用程序的运行环境,并提供默认的配置选项,让开发人员可以快速开始开发。
举个例子,如果在 Spring Boot 项目中使用 Spring MVC,需要引入多个与Spring MVC相关的依赖,包括 spring-webmvc、spring-web等,这时候如果使用 spring-boot-starter-web 这个starter,只需要添加一个依赖就可以了,它会包含使用 Spring MVC 所需要的所有依赖。
4.3.6 JavaAgent与Spring AOP和AspectJ之间有什么区别?
1)、JavaAgent
Java Agent 是 Java 5 引入的一种机制,它能够通过预处理(Pre-processing)和类转换(Class Transformation)的方式,修改已有的字节码。
Java Agent 通常在 JVM 启动或者类加载时进行操作。每当一个类被 JVM 加载,都会调用 Java Agent 的代理方法,以便进行类字节码的转换。
因此,Java Agent 更底层,使用复杂,但功能十分强大。
2)、AspectJ
AspectJ 是最早的切面编程框架之一,并且它提供了非常强大的切面编程能力。
AspectJ 通过类似于 Java 语言的 AspectJ 语言来书写切面,并且提供了一个 AspectJ 编译器,将 AspectJ 代码编译成可以运行的字节码。
AspectJ 支持更多更细粒度的切入点,如方法调用,实例创建等,能够在运行时进行热替换和精细控制,其功能强大,但使用和学习成本较高。
3)、Spring AOP
Spring AOP 是 Spring 框架提供的切面编程实现。
它主要利用 Java 的动态代理机制以及 CGLIB 库来在运行时动态地创建对象的代理。
与 AspectJ 相比,Spring AOP 更轻量级且简单,但其切入点种类有限,主要支持方法执行切点,不能做到类似 AspectJ 的构造函数或属性切入。
使用便捷,适用于一般的日志、事务等场景,对于复杂的切点和切面控制较为局限。
源码下载
【文章参考】
【JVM】Java agent超详细知识梳理