Agent 技术
# Agent 技术
相关文档:
instrument 规范:https://docs.oracle.com/javase/8/docs/api/java/lang/instrument/package-summary.html?is-external=true
Class VirtualMachine:https://docs.oracle.com/javase/8/docs/jdk/api/attach/spec/com/sun/tools/attach/VirtualMachine.html#loadAgent-java.lang.String-
Interface ClassFileTransformer:https://docs.oracle.com/javase/8/docs/api/java/lang/instrument/ClassFileTransformer.html
# 前言
JDK在1.5的时候,发布了一个特性,可以依赖于JVMTI(JVM Tool Interface)实现的java.lang.instrument的premain方式,实现agent,但是这个只能是JVM启动的时候通过javaagent参数指定agent-jar,比如链路追踪框架skywalking,集成的时候需要在启动参数中加入java -javaagent:/xxxx/skywalking-agent.jar
JDK1.6又新增了agentmain方式,实现了运行时动态绑定,依赖于Attach API,比如常用的Arthas
下面来主要来分析一下,agentmain方式以及动态Attach的实现原理
# 1、JVM中的Instrument
首先,不论是premain(之前)还是agentmain(之后)都是依赖于instrument,instrument的底层实现依赖于JVMTI,它是JVM暴露出来的一些供用户扩展的接口集合,JVMTI是基于事件驱动的,JVM每执行到一定的逻辑就会调用一些事件的回调接口(类似于JVM提供的一些埋点,供用户实现)。而instrument agent可以理解为一类JVMTIAgent动态库,别名是JPLISAgent(Java Programming Language Instrumentation Services Agent),也就是专门为java语言编写的插桩服务提供支持的代理,下图是JVM具体加载埋点的顺序图:图一是premain、图二是agentmain
# 2、Agent 加载
Agent实际上就是一个jar包,jar包里面的MANIFEST.MF 文件必须指定 Premain-Class 项(通过maven插件可以自定义参数),Premain-Class指定的java类必须实现premain() 或者agentmain()方法,不论是哪个方法,都是严格遵守签名的,具体的签名以agentmain为例:存在下面两个签名
public static void agentmain(String agentArgs, Instrumentation inst)
public static void agentmain(String agentArgs)
2
3
JVM会优先加载带 Instrumentation 签名的方法,加载时序图如下所示:
加载agent之后,用户自己编写的agent包就可以拿到Instrumentation 类,Instrumentation封装了很多的API,常用的如下所示:
public interface Instrumentation {
//增加一个Class 文件的转换器,转换器用于改变 Class 二进制流的数据,参数 canRetransform 设置是否允许重新转换。
void addTransformer(ClassFileTransformer transformer, boolean canRetransform);
//在类加载之前,重新定义 Class 文件,ClassDefinition 表示对一个类新的定义,
如果在类加载之后,需要使用 retransformClasses 方法重新定义。addTransformer方法配置之后,后续的类加载都会被Transformer拦截。
对于已经加载过的类,可以执行retransformClasses来重新触发这个Transformer的拦截。类加载的字节码被修改后,除非再次被retransform,否则不会恢复。
void addTransformer(ClassFileTransformer transformer);
//删除一个类转换器
boolean removeTransformer(ClassFileTransformer transformer);
//是否允许对class retransform
boolean isRetransformClassesSupported();
//在类加载之后,重新定义 Class。这个很重要,该方法是1.6 之后加入的,事实上,该方法是 update 了一个类。
void retransformClasses(Class<?>... classes) throws UnmodifiableClassException;
//是否允许对class重新定义
boolean isRedefineClassesSupported();
//此方法用于替换类的定义,而不引用现有的类文件字节,就像从源代码重新编译以进行修复和继续调试时所做的那样。
//在要转换现有类文件字节的地方(例如在字节码插装中),应该使用retransformClasses。
//该方法可以修改方法体、常量池和属性值,但不能新增、删除、重命名属性或方法,也不能修改方法的签名
void redefineClasses(ClassDefinition... definitions) throws ClassNotFoundException, UnmodifiableClassException;
//获取已经被JVM加载的class,有className可能重复(可能存在多个classloader)
@SuppressWarnings("rawtypes")
Class[] getAllLoadedClasses();
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
具体使用:比如说我们拿到了加载的一个Java类字节码,想要修改字节码文件,就需要重新写一个ClassFileTransformer,然后在transform方法内部进行Class的修改,使用一些字节码工具修改该类的字节码,然后调用Instrumentation#retransformClasses方法重新加载这个字节码,就可以实现动态修改线上代码的功能,来实现链路追踪、方法耗时记录、输出日志等功能,而且对于原业务代码零侵入。(当然,对于一些框架性质的代码还是没有那么简单的,比如Spring的IOC和AOP机制,业务中使用的都是代理类,Agent如果只修改原始类的字节码文件是无效的,要想真正实现热部署,还需要考虑Spring的Bean卸载、重新加载、注入等逻辑),下面举一个例子,如何使用Agent进行方法级别的耗时监控:
1、自定义ClassFileTransformer
public class WatchTransformer implements ClassFileTransformer {
@Override
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
if(className.equals("xxxxx")){
ClassPool classPool = ClassPool.getDefault();
CtClass clazz = classPool.get(className);
//获取要监控的方法test
CtMethod ctMethod = clazz.getDeclaredMethod("test");
//预制参数开始时间、结束时间、执行时间
ctMethod.addLocalVariable("startTime", CtClass.longType);
ctMethod.addLocalVariable("endTime", CtClass.longType);
ctMethod.addLocalVariable("execTime", CtClass.longType);
//编写代码计算耗时
ctMethod.insertBefore("startTime = System.currentTimeMillis();");
ctMethod.insertAfter("endTime = System.currentTimeMillis();");
ctMethod.insertAfter("execTime = endTime - startTime;");
ctMethod.insertAfter("System.out.println(\“方法耗时:\”+execTime+\"ms\");");
//detach的意思是将内存中曾经被javassist加载过的对象移除
clazz.detach();
return clazz.toBytecode();
}
return null;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
2、通过Instrumentation加载ClassFileTransformer
WatchTransformer watchTransformer = new WatchTransformer();
Instrumentation.addTransformer(watchTransformer, true);
Instrumentation.retransformClasses(Class);
2
3
这样操作之后,当程序再次调用test方法的时候就会输出执行时间。
下面具体了解一下如何进行动态Attach。
# 3、Attach API 简介
从JDK1.6开始可以使用Attach API连接到目标JVM上并让目标JVM加载一个Java Agent。 Attach API的包名称为com.sun.tools.attach
。如下图所示主要包含2个类:VirtualMachine和VirtualMachineDescriptor
VirtualMachine代表一个Java虚拟机,也就是监控的目标虚拟机,而VirtualMachineDescriptor用来描述虚拟机信息,配合VirtualMachine类完成各种功能
// 列出当前主机上的所有JVM
public static List<VirtualMachineDescriptor> list()
// 执行attach/detach
public static VirtualMachine attach(VirtualMachineDescriptor vmd)
public abstract void detach() throws IOException
// 加载Agent
public abstract void loadAgentLibrary(String agentLibrary, String options)
public void loadAgentLibrary(String agentLibrary)
public abstract void loadAgentPath(String agentPath, String options)
public void loadAgentPath(String agentPath)
public abstract void loadAgent(String agent, String options)
public void loadAgent(String agent)
// 获取JVM系统参数
public abstract Properties getSystemProperties() throws IOException
public abstract Properties getAgentProperties() throws IOException
// 在目标虚拟机中启动JMX管理代理
public abstract void startManagementAgent(Properties agentProperties) throws IOException
public abstract String startLocalManagementAgent() throws IOException;
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 4、实现原理
# 4.1、Attach客户端源码解析
JDK8上VirtualMachine实现类的的继承关系如下图所示:
看下HotSpotVirtualMachine
类的loadAgentLibrary方法:
代码位置:src/jdk.attach/share/classes/sun/tools/attach/HotSpotVirtualMachine.java
private void loadAgentLibrary(String agentLibrary, boolean isAbsolute, String options)
throws AgentLoadException, AgentInitializationException, IOException
{
if (agentLibrary == null) {
throw new NullPointerException("agentLibrary cannot be null");
}
// jdk11返回字符串"return code: 0"
String msgPrefix = "return code: ";
// 执行load指令,给目标 jvm 传输 agent jar路径和参数
InputStream in = execute("load",
agentLibrary,
isAbsolute ? "true" : "false",
options);
try (BufferedReader reader = new BufferedReader(new InputStreamReader(in))) {
String result = reader.readLine();
// 返回结果
if (result == null) {
throw new AgentLoadException("Target VM did not respond");
} else if (result.startsWith(msgPrefix)) {
int retCode = Integer.parseInt(result.substring(msgPrefix.length()));
if (retCode != 0) {
throw new AgentInitializationException("Agent_OnAttach failed", retCode);
}
} else {
throw new AgentLoadException(result);
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
上面的代码是加载一个Java Agent,核心实现在 execute
方法中,来看下execute方法的源码:
// 在目标JVM上执行给定的命令,需要由子类来实现
abstract InputStream execute(String cmd, Object ... args)
throws AgentLoadException, IOException;
2
3
execute是一个抽象方法,需要在子类中实现,HotSpotVirtualMachine类中的其他方法大多数最终都会调用这个execute方法.
看下Linux系统上的实现类LinuxVirtualMachine
代码
代码位置:src/jdk.attach/linux/classes/sun/tools/attach/VirtualMachineImpl.java
VirtualMachineImpl(AttachProvider provider, String vmid)
throws AttachNotSupportedException, IOException
{
super(provider, vmid);
int pid;
try {
pid = Integer.parseInt(vmid);
} catch (NumberFormatException x) {
throw new AttachNotSupportedException("Invalid process identifier");
}
// 在/tmp目录下寻找socket文件是否存在
File socket_file = new File(tmpdir, ".java_pid" + pid);
socket_path = socket_file.getPath();
if (!socket_file.exists()) {
// 创建 attach_pid 文件
File f = createAttachFile(pid);
try {
// 向目标JVM 发送 kill -3 信号
sendQuitTo(pid);
// 等待目标JVM创建socket文件
final int delay_step = 100;
final long timeout = attachTimeout();
long time_spend = 0;
long delay = 0;
do {
// 计算等待时间
delay += delay_step;
try {
Thread.sleep(delay);
} catch (InterruptedException x) { }
time_spend += delay;
if (time_spend > timeout/2 && !socket_file.exists()) {
sendQuitTo(pid); // 发送kill -3 信号
}
} while (time_spend <= timeout && !socket_file.exists());
// 等待时间结束后,确认socket文件是否存在
if (!socket_file.exists()) {
throw new AttachNotSupportedException(
String.format("Unable to open socket file %s: " +
"target process %d doesn't respond within %dms " +
"or HotSpot VM not loaded", socket_path,
pid, time_spend));
}
} finally {
// 最后删除 attach_pid 文件
f.delete();
}
}
// 确认socket文件权限
checkPermissions(socket_path);
// 尝试连接socket,确认可以连接到目标JVM
int s = socket();
try {
connect(s, socket_path);
} finally {
close(s);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
再次梳理下attach通信的过程:
第一步: 发起attach的进程在/tmp目录下查找目标JVM是否已经创建了.java_pid ,如果已经创建,直接跳到第六步;
第二步: attach进程创建socket通信的握手文件.attach_pid;
第三步: attach进程给目标JVM发送SIGQUIT(kill -3)信号,提示目标JVM外部进程发起了attach请求;
第四步: attach进程循环等待目标JVM创建.java_pid文件;
第五步: 删除握手文件.attach_pid文件;
第六步: attach进程校验socket文件权限;
第七步: attach进程测试socket连接可用性;
# 4.2、Attach服务端源码解析
Attach 机制详细的交互流程可以用下面的图描述
# 4.3、Attach机制涉及到的JVM参数
名称 | 含义 | 默认值 |
---|---|---|
ReduceSignalUsage | 禁止信号量使用 | false |
DisableAttachMechanism | 禁止attach到当前JVM | false |
StartAttachListener | JVM 启动时初始化AttachListener | false |
EnableDynamicAgentLoading | 允许运行时加载Agent | true |
# 5、Javassist & ASM
- Javassist源代码级API比ASM中实际的字节码操作更容易使用
- Javassist在复杂的字节码级操作上提供了更高级别的抽象层。Javassist源代码级API只需要很少的字节码知识,甚至不需要任何实际字节码知识,因此实现起来更容易、更快。
- Javassist使用反射机制,这使得它比运行时使用Classworking技术的ASM慢。
- 总的来说ASM比Javassist快得多,并且提供了更好的性能。Javassist使用Java源代码的简化版本,然后将其编译成字节码。这使得Javassist非常容易使用,但是它也将字节码的使用限制在Javassist源代码的限制之内。
总之,如果有人需要更简单的方法来动态操作或创建Java类,那么应该使用Javassist API 。如果需要注重性能地方,应该使用ASM库。