从Java5开始提供了 instrument (java.instrument.*) 功能,依赖于JVMTI,简而言之就是可以利用 instrument 来构建独立于应用的agent程序,甚至可以动态修改类的行为定义等。
以下从两个例子说明一下如何使用agent机制来在运行时动态修改类(行为)。
例一
第一个例子展示的是如何通过独立的进程attach到一个已经运行的虚拟机进程中进行类的修改。
Main.java1
2
3
4
5
6
7
8
9
10
11package cn.demonk.agent;
public class Main {
public static void main(String[] args) throws InterruptedException {
while (true) {
TestClass.printMsg();
Thread.sleep(1000);
}
}
}
Main方法很简单,就是不断调用TestClass的一个静态方法,然后打印一句话而已。
TestClass.java1
2
3
4
5
6
7
8package cn.demonk.agent;
public class TestClass {
public static void printMsg() {
System.out.println("I'm from testClass_1");
}
}
如此测试程序就完了,接下来的Agent的编写。
TestAgent.java1
2
3
4
5
6
7
8
9
10
11
12
13
14
15package cn.demonk.agent;
import java.lang.instrument.Instrumentation;
import java.lang.instrument.UnmodifiableClassException;
public class TestAgent {
//当类需要加载时,JVM会调用agentmain这个方法
//需要在manifest.mf中声明这个类
public static void agentmain(String agentArgs, Instrumentation inst) throws UnmodifiableClassException {
//注册一个转换器
inst.addTransformer(new TestTransformer(), true);
inst.retransformClasses(TestClass.class);
}
}
这个Agent的类需要在MANIFEST.MF中声明
MANIFEST.MF1
2
3Manifest-Version: 1.0
Agent-Class: cn.demonk.agent.TestAgent
Can-Retransform-Classes: true
如此声明让TestAgent专为一个Agent类,以为让接下来attach到vm时可以直接启动这个加载TestAgent类。
当Agent类加载后,当需要加载类时,agentmain这个方法就会执行。然后通过Instrumentation的添加一个转换器,这个转换器的作用可以理解为将虚拟机中获取类字节的源切换到另一个源上。
TestTransformer.java1
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
49package cn.demonk.agent;
import java.io.File;
import java.io.FileInputStream;
import java.io.InputStream;
import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.IllegalClassFormatException;
import java.security.ProtectionDomain;
public class TestTransformer implements ClassFileTransformer {
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
className = className.replace('/', '.');
String classNameSimple = className.substring(className.lastIndexOf('.') + 1);
if (!classNameSimple.equals("TestClass")) {
return null;
}
String newClassFile = "/home/ligs/Desktop/cn/demonk/agent/" + classNameSimple + ".class";
return getBytesFromClass(newClassFile);
}
private byte[] getBytesFromClass(String fileName) {
File file = new File(fileName);
long length = file.length();
try (InputStream is = new FileInputStream(file)) {
byte[] bytes = new byte[(int) length];
int offset = 0;
int numRead = 0;
while (offset < bytes.length && (numRead = is.read(bytes, offset, bytes.length - offset)) >= 0) {
offset += numRead;
}
if (offset < bytes.length) {
throw new Exception("Could not completely read file " + file.getName());
}
is.close();
return bytes;
} catch (Exception e) {
System.out.println("error occurs in _ClassTransformer!" + e.getClass().getName());
return null;
}
}
}
当每一个类需要加载时,transform方法都会执行,返回的数据类型是byte[],实际上就是class的字节数组。上面针对了TestClass这个类进行了拦截与转换。
如此一来,准备工作就已经做好了,将以上文件都打包成一个jar包,并将以上MANIFEST.MF也一并输出,我在这里将其输出成一个普通的jar(test_1.jar),执行命令:
1 | java -cp test_1.jar cn.demonk.agent.Main |
如此,终端中就会不断地打印“I’m from testClass_1”这句话。
此时,需要新建另一个可执行类,用于动态地attach到刚才执行的vm进程上。我将其定义为另一个工程。
AttachMain.java1
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
49package cn.demonk.agent;
import java.util.List;
import com.sun.tools.attach.VirtualMachine;
import com.sun.tools.attach.VirtualMachineDescriptor;
public class AttachMain {
public static void main(String[] args) {
new AttachThread(args[0], args[1]).start();
}
private static class AttachThread extends Thread {
private String mJarPath;
private String mPid;
public AttachThread(String path, String pid) {
this.mJarPath = path;
this.mPid = pid;
}
public void run() {
VirtualMachine vm = null;
List<VirtualMachineDescriptor> list = null;
try {
//获取当前系统内正在运行的vm列表,可以通过jps获取
list = VirtualMachine.list();
for (VirtualMachineDescriptor vmd : list) {
if (this.mPid.equals(vmd.id())) {
//通过pid找到要替换在vm,然后attach上去
vm = VirtualMachine.attach(vmd);
System.out.println("has attached to pid " + this.mPid);
break;
}
}
//开始load对应jar上指定的Agent,来实现动态加载类
vm.loadAgent(this.mJarPath);
//执行完就可以detach了
vm.detach();
} catch (Exception e) {
System.err.println(e.getMessage());
}
}
}
}
内容很简单,就是将当前系统中运行的VM进程都列出,然后选中刚才我们启动的进程,再启动其中的agent(此时为TestAgent)类。
虽然注意的是,工程需要引用tools.jar库。
如此便完工,可以将这个类连同tools.jar(为了方便)一并导出,此处我导出成一个可执行文件patch.jar。
另外,还需要将TestClass.java作一点修改,可以将输出的内容作一点修改,此处我将“I’m from testClass_1”修改为“I’m from testClass_999”,将其放置到”/home/ligs/Desktop/cn/demonk/agent/TestClass.class”位置,随便此类将会测试是否能正常加载。
将以上jar文件都放置到同一位置。
显示,pid为17895,此时便可以执行patch.jar来测试能否将新的TestClass.class加载。
如图执行patch.jar,
则测试进程的输出此时就会改变成修改后的“I’m from testClass_999”
证明新的TestClass类已经被成功地加载。
整合地调用流程也不复杂,主要是在启动TestAgent后,使用注册好的Transform类对将要加载的类进行替换,而替换的办法也就是将类的字节流改变,如此而已。
例二
以上例子是在不同的进程中通过改变运行类的字节流来实现动态地改变类的行为,新类的行为需要预先定义好。从以上可知,关键的动作是需要使用transform来改变类字节流,以下利用同样的技术直接修改运行中的类行为,需要用到javassist工具(一个动态修改运行过程中类行为的工具,比ASM慢,但比反射要快)。
首先也是demo
Main.java1
2
3
4
5
6
7
8
9
10
11
12
13package cn.demonk.agent;
public class Main {
public static void main(String[] args) {
try {
Hello.hello_1();
Hello.hello_2("demonk");
} catch (Exception e) {
System.err.println(e.getMessage());
}
}
}
Hello.java1
2
3
4
5
6
7
8
9
10
11
12
13package cn.demonk.agent;
public class Hello {
public static void hello_1() throws InterruptedException {
System.out.println("hello world");
Thread.sleep(500);
}
public static void hello_2(String name) throws InterruptedException {
System.out.println("hello " + name);
Thread.sleep(500);
}
}
也是很简单地打印信息。将上面信息打成一个可运行的jar(run.jar)。
agent这次改个运行方式,如下
TestAgent.java1
2
3
4
5
6
7
8
9
10package cn.demonk.agent;
import java.lang.instrument.Instrumentation;
public class TestAgent {
public static void premain(String agentArgs, Instrumentation inst) {
inst.addTransformer(new TestTransformer());
}
}
具体的操作也是跟例一差不多,主要是注册一个TestTransformer。
TestTransformer.java1
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
64package cn.demonk.agent;
import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.IllegalClassFormatException;
import java.security.ProtectionDomain;
import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtMethod;
import javassist.CtNewMethod;
public class TestTransformer implements ClassFileTransformer {
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
if (className.equals("cn/demonk/agent/Hello")) {
className = className.replace("/", ".");
CtClass ctclass = null;
//使用javassist获取className,需要注意javassist要找得到,为了方便我是直接集成进来了
try {
ctclass = ClassPool.getDefault().get(className);
CtMethod[] methods = ctclass.getMethods();
for (CtMethod method : methods) {
String methodName = method.getName();
if (!methodName.startsWith("hello_")) {
continue;
}
String newMethodName = methodName + "$a";//新建一个方法名,用于接纳旧方法
method.setName(newMethodName);//修改原来方法的名字
//从原来方法中复制一个新方法
CtMethod newMethod = CtNewMethod.copy(method, methodName, ctclass, null);
StringBuilder sb = new StringBuilder();
sb.append("{");
//定义一个开始时间
sb.append("\nlong startTime = System.currentTimeMillis();\n");
//调用newMethodName方法,由于newMethodName已经赋于原来的method了,这里意思即为调用原方法内容
sb.append(newMethodName + "($$);\n");
//定义一个结束时间
sb.append("\nlong endTime = System.currentTimeMillis();\n");
//输出相差时间
sb.append("\nSystem.out.println(\"method " + methodName + " cost:\" +(endTime - startTime) +\"ms.\");");
sb.append("}");
//将以上定义的方法内容设置到方法对象中去,替换掉原来的方法内容
newMethod.setBody(sb.toString());
//将新增的方法添加到类中,名字与原来一样(原来的已经修改为method$a)
ctclass.addMethod(newMethod);
}
return ctclass.toBytecode();
} catch (Exception e) {
e.printStackTrace();
System.err.println(e.getMessage());
}
}
return null;
}
}
这次用于修改类字节数据的是javassist,通过筛选出合适的类以及其中需要修改的方法,需要javassist中的CtNewMethod来新建一个新的方法,然后往里面添加新的方法,同时保留原来方法的的逻辑,最后达到动态的往方法中添加了运行耗时逻辑的效果。
同时,MANIFEST.MF也要作修改
MANIFEST.MF1
2
3
4Manifest-Version: 1.0
Premain-Class: cn.demonk.agent.TestAgent
Can-Redefine-Classes: true
Boot-Class-Path: javassist.jar
将以上内容也打成一个普通的jar(test_2.jar),与run.jar放于同一个目录。
需要注意的是,由于test_2.jar引用于了javassist.jar的内容,所以 javassist.jar也要与其放于同一目录,MANIFEST.MF也要添加相应的描述;另外需要注意的是,在javassist官网上下载的编译好的包有可能是使用高版本JDK来编译的,如果直接使用的话有可能在低版本中使用不了,最好是把javassist的源码拉下来自己编译。
接下来,运行run.jar即可,运行时需要添加VM参数-javaagent
1 | java -javaagent:/home/ligs/Desktop/agent_2/test_2.jar -jar run.jar |
如此,便将test_2.jar作为 agent来导入 ,当在run中执行到Hello类的方法时,就会触发 TestTransformer 中的替换方法,以此来动态地往Hello类中添加行为。
两次运行的对比图:
则以后只需要更换TestTransformer中的替换方法即可以动态修改原类的动作。