环境:Java17
本篇文章介绍java agent技术,然后通过一个示例讲解如何使用agent,该示例的功能是通过agent技术实现api接口调用耗时情况。
1. 简介
什么是agent?Java Agent也称为Java探针,它 是一个独立的 JAR 包,是在JDK1.5中引入的一种技术,允许动态修改Java字节码。这种技术使得Java应用程序的Instrumentation API能够与虚拟机进行交互。Java类在编译之后形成字节码,这些字节码随后被JVM执行。在JVM执行这些字节码之前,Java Agent可以获取这些字节码信息,并且通过字节码转换器对这些字节码进行修改,以实现一些额外的功能。
核心API
Instrumentation
public interface Instrumentation {
/**
* 注册提供的转换器。以后的所有类定义都可以通过转换器看到,但所有已注册的转换器所依赖的类定义除外。
* 如果注册了多个转换器,那么会按添加的顺序调用它们。
* 如果转换器在执行期间抛出异常,则 JVM 仍将按顺序调用其他已注册的转换器。
* 可以多次添加同一转换器。在任何外部 JVMTI ClassFileLoadHookAll 事件监听器看到类文件之前,用 addTransformer 注册的所有转换器始终可以看到类文件。
*/
void addTransformer(ClassFileTransformer transformer);
/**
* 注销提供的转换器。以后的类定义将不显示给该转换器。
* 移除最近添加的转换器的匹配实例。由于类加载的多线程特性,在调用被移除后,转换器还可能接收调用。
* 所以编写的转换器应防止出现这种情况。
*/
boolean removeTransformer(ClassFileTransformer transformer);
/**
* 返回当前 JVM 配置是否支持类的重定义。
* 重定义已加载类的能力是 JVM 的一个可选功能。
* 在执行单个 JVM 的单实例化过程中,对此方法的多个调用将始终返回同一应答。
*/
boolean isRedefineClassesSupported();
/**
* 使用提供的类文件重新定义提供的类集。
* 此方法用于在不引用现有类文件字节的情况下替换类的定义,就像从源代码重新编译以修复并继续调试时可能会做的那样。
* 如果要转换现有的类文件字节(例如字节码插入),则应使用retransformClassess。
* 此方法对一个集合进行操作,以便允许同时对多个类进行相互依存的更改(对类a的重新定义可能需要对类B的重新定义)。
*/
void redefineClasses(ClassDefinition... definitions) throws ClassNotFoundException, UnmodifiableClassException;
/**
* 返回JVM当前加载的所有类的数组。
* 返回的数组包括所有类和接口,包括隐藏类或接口,以及所有类型的数组类。
*/
Class[] getAllLoadedClasses();
/**
* 返回指定对象所消耗的存储量的特定于实现的近似值。
* 该结果可以包括对象的一些或全部开销,因此对于实现内部的比较是有用的,但对于实现之间的比较则不有用。
* 在JVM的一次调用过程中,估计值可能会发生变化。
*/
long getObjectSize(Object objectToSize);
}
ClassFileTransformer/**
* 类文件的转换器。代理使用addTransformer方法注册该接口的实现,以便在加载、重新定义或重新转换类时调用转换器的转换方法。
*/
public interface ClassFileTransformer {
/**
* 转换给定的类文件并返回新的替换类文件
* loader: 要转换的类的定义加载程序,如果引导加载程序,则可以为null。
* className: 类的名称,内部形式为完全限定的类和接口名称,如Java虚拟机规范中定义的那样。例如,“java/util/List”。
* classBeingRedefined: 如果这是由重新定义或重传触发的,则类被重新定义或重新转换;如果这是类装入,则为null。
* protectionDomain: 正在定义或重新定义的类的保护域。
* classfileBuffer: 类文件格式的输入字节缓冲区-不能修改
*/
default byte[] transform(ClassLoader loader,
String className,
Class<?> classBeingRedefined,
ProtectionDomain protectionDomain,
byte[] classfileBuffer) throws IllegalClassFormatException {
return null;
}
}
编写规范
要编写一个agent 入口程序有2个核心的方法(二选其一)
Java 虚拟机 (JVM) 初始化后,premain 方法将被调用,然后才是真正的应用程序 main 方法。premain 方法必须返回才能继续启动。
JVM 首先尝试在代理类上调用以下方法:
public static void premain(String agentArgs, Instrumentation instrumentation)
如果代理类未实现此方法,则 JVM 将尝试调用:
public static void premain(String agentArgs)
有个上面定义的类之后还需要一个MANIFEST.MF文件
Manifest-Version: 1.0
Premain-Class: com.pack.agent.MonitorAgent
Can-Redefine-Classes: true
Premain-Class:指定了当在 JVM 启动时指定代理时,此属性指定代理类。即,包含 premain 方法的类。当在 JVM 启动时指定代理程序时,此属性是必需的。如果该属性不存在,JVM 将中止。
Can-Redefine-Classes:是否能够重新定义此代理所需的类。
有了上面的基础知识后接下来我们就通过一个实例来更加清晰的认识Agent。
2. 实战案例
添加依赖
<!--将通过javassit来修改类信息-->
<dependency>
<groupId>org.javassist</groupId>
<artifactId>javassist</artifactId>
<version>3.29.2-GA</version>
</dependency>
编写Transformer类用来转换修改要加载的类
public class MonitorTransformer implements ClassFileTransformer {
@Override
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
// 在上面的API说明中已经说了,这里的className不是. 而是'/'
className = className.replace("/", ".");
// 这里只拦截com.pack及子包下的类
if (className.startsWith("com.pack")) {
try {
// 以下的相关API就是javassit;可自行查看javassit相关的文章
CtClass ctClass = ClassPool.getDefault().get(className) ;
CtMethod[] ctMethods = ctClass.getDeclaredMethods() ;
for (CtMethod ctMethod : ctMethods) {
// 获取执行的方法名称
String methodName = ctMethod.getName() ;
// 打印方法执行耗时时间
String executeTime = "\nSystem.out.println(\"" + methodName + " 耗时:\" + (end - start) + " + "\" ms\");\n" ;
// 添加2个局部变量
ctMethod.addLocalVariable("start", CtClass.longType) ;
ctMethod.addLocalVariable("end", CtClass.longType) ;
// 为上面2个局部变量赋值
ctMethod.insertBefore("start = System.currentTimeMillis() ;\n") ;
ctMethod.insertAfter("end = System.currentTimeMillis();\n") ;
// 将打印时间的语句插入到方法体的最后一行
ctMethod.insertAfter(executeTime) ;
}
// 返回修改后的字节码(这里就是重写字节码文件)
return ctClass.toBytecode();
} catch (Exception e) {
e.printStackTrace() ;
}
}
return null;
}
}
编写Agent入口
public class MonitorAgent {
// 这里的premain是我们Agent的入口,首先执行的就是该premain,然后才是main
// agentArgs是agent运行时添加的参数,我们可以在下面看到如何定义参数
public static void premain(String agentArgs, Instrumentation instrumentation) {
// 添加转换器
instrumentation.addTransformer(new MonitorTransformer());
}
// 这里完全没必要main,只是为了在eclipse中生成jar包方便
public static void main(String[] args) {
}
}
编写MANIFEST.MF文件
Manifest-Version: 1.0
Premain-Class: com.pack.agent.MonitorAgent
Can-Redefine-Classes: true
以上步骤完成后,我们就可以打包了。我是通过Eclipse直接导出的jar,这种方式导出的jar会自动生成MANIFEST.MF文件,所以最后通过压缩软件将上面的MANIFEST.MF文件手动添加进去。最后看下生成的jar结构
图片
编写SpringBoot程序
这里随便写一个API接口即可。
@RestController
@RequestMapping("/demos")
public class DemoController {
@GetMapping("/index")
public Object index() throws Exception {
TimeUnit.SECONDS.sleep(new Random().nextInt(5)) ;
return "success" ;
}
}
非常简单的一个测试接口。我们会通过上面写的agent来输出当前接口执行时间。
将该测试程序打包成jar,当前目录。
图片
MANIFEST.MF不是必须在这里,我这里是为了替换CosAgent.jar中的文件。
接下来是运行,运行需要指定agent jar包。
java -javaagent:CostAgent.jar -jar test.jar
通关-javaagent:CostAgent.jar指定了agent的jar包,我们可以在后面跟上参数,这样在premain方法中的第一个参数就可以接收到参数信息。
启动后访问测试接口/demos/index
index 耗时:0 ms
index 耗时:0 ms
index 耗时:0 ms
index 耗时:1008 ms
index 耗时:2012 ms
我们的接口访问,成功的输出了接口调用耗时时间。
我们可以通过arthas进行查看DemoController接口类
jad com.pack.DemoController
输出结果
@GetMapping(value={"/index"})
publicObject index() throws Exception {
long start = System.currentTimeMillis();
TimeUnit.SECONDS.sleep(new Random().nextInt(5));
String string= "success";
long l = System.currentTimeMillis();
String string2 = string;
System.out.println(new StringBuffer().append("index 耗时:").append(l - var1_1).append(" ms").toString());
return string2;
}
线上的类已经通过Agent修改了。