作者:李家琦 评阅人:高邱雅 鹿凯翔
一、介绍
1. 目的
本文主要介绍如何使用javaAgent和ASM技术对java程序的方法调用进行跟踪,获得运行时方法之间的调用关系和方法的运行时间等信息,可以用于理解程序结构、了解方法实际执行时间、分析程序性能瓶颈等场景。使用javaAgent技术在程序加载字节码文件时,获取字节码并返回一个修改过的字节码文件,利用ASM技术可以对字节码进行增强,从而获取目标方法的运行状态。使用这种方式的好处是,可以在对代码没有入侵的情况下实现跟踪。
2. 环境
本项目中使用JDK8进行开发,并使用maven进行依赖管理。
二、 javaAgent技术介绍及使用
java.lang.Instrument包是在JDK5引入的,开发者可以通过修改方法的字节码实现动态修改类代码。下面先介绍一些相关概念:
JVMTI
首先需要介绍下JVMTI(JVM Tool Interface),它是JVM暴露出来的一些供开发者扩展的接口集合,当执行到某段程序时会调用某些回调接口,开发者可以利用这些接口扩展自己的逻辑。例如,在本项目中我们希望能够在JVM加载字节码时,获取到字节码并对其进行修改。JVMTIAgent
JVMTIAgent是一个动态库,它可以利用JVMTI暴露出的一些接口来实现一些特殊的功能。它有两种加载方式,可以在程序启动时加载,也可以在程序运行时动态进行加载。在我们使用eclipse、IDEA等IDE运行或者调试java代码时,它们就会在启动程序时加入相关参数,比如使用IDEA运行java程序,留意控制台最上方的输出,就会发现类似于如下的内容:
"D:\Programs\Java\jdk1.8.0_172\bin\java.exe" "-javaagent:D:\Program Files\JetBrains\IntelliJ IDEA 2018.2.1\lib\idea_rt.jar=52832:D:\Program Files\JetBrains\IntelliJ IDEA 2018.2.1\bin" ......
instrument agent
instrument是JVM 提供的一个 JVMTIAgent,windows环境下可以在jdk bin目录中找到一个叫做 instrument.dll的动态链接库。使用instrument agent
首先需要实现一个包含premain的类,,如下所示
public class AopAgentTest {
static private Instrumentation _inst = null;
public static void premain(String agentArgs, Instrumentation inst) {
Param.generatePARAMS(agentArgs);
System.out.println("AopAgentTest.premain() was called.");
/* Provides services that allow Java programming language agents to instrument programs running on the JVM.*/
_inst = inst;
/* ClassFileTransformer : An agent provides an implementation of this interface in order to transform class files.*/
ClassFileTransformer trans = new AopAgentTransformer();
/*Registers the supplied transformer.*/
_inst.addTransformer(trans);
}
}
在premain方法中,可以获得一个Instrumentation对象,我们可以向其中加入一个ClassFileTransformer对象,Transformer对象的实现如下所示:
public class AopAgentTransformer implements ClassFileTransformer{
public byte[] transform(ClassLoader loader, String className,
Class<?> classBeingRedefined, ProtectionDomain protectionDomain,
byte[] classfileBuffer) throws IllegalClassFormatException {
System.out.println("Transforming " + className);
/*
TODO
修改字节码,并返回修改后的字节码
*/
byte[] transformed = classfileBuffer;
return transformed;
}
}
这样就可以在JVM加载字节码文件时,获取到字节码并进行修改。然后,还需要在MANIFEST.MF中加入Premain-Class,并将程序打成jar包的形式(本项目中将程序的相关依赖也一并打进jar包),以便在目标程序中使用。使用maven插件进行打包,如下所示:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<configuration>
<archive>
<manifest>
<addClasspath>true</addClasspath>
<classpathPrefix>lib/</classpathPrefix>
<mainClass></mainClass>
<addClasspath>true</addClasspath>
<classpathPrefix>lib/</classpathPrefix>
</manifest>
<manifestEntries>
<Class-Path>.</Class-Path>
<Premain-Class>com.nju.msr.core.instrument.AopAgentTest</Premain-Class>
</manifestEntries>
</archive>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<executions>
<execution>
<id>copy</id>
<phase>package</phase>
<goals>
<goal>copy-dependencies</goal>
</goals>
<configuration>
<outputDirectory>${project.build.directory}/lib</outputDirectory>
</configuration>
</execution>
</executions>
</plugin>
最后,在运行目标程序时,只需要添加 javaagent参数即可,如下所示:
-javaagent:./test.jar
需要注意的是,一个java程序中-javaagent这个参数的个数是没有限制的,所以可以添加任意多个javaagent。所有的javaagent会按照运行时的参数顺序执行。此外,javaagent需要放在包含main方法的jar包之前,否则javaagent不会起作用。每一个java agent 都可以接收一个字符串类型的参数,也就是premain中的agentArgs。
三、 ASM技术介绍及使用
- 首先我们需要引入如下的库:
<dependency>
<groupId>org.ow2.asm</groupId>
<artifactId>asm</artifactId>
<version>6.2.1</version>
</dependency>
<dependency>
<groupId>org.ow2.asm</groupId>
<artifactId>asm-util</artifactId>
<version>6.2.1</version>
</dependency>
<dependency>
<groupId>org.ow2.asm</groupId>
<artifactId>asm-commons</artifactId>
<version>6.2.1</version>
</dependency>
- ASM使用观察者模式,依次访问类中的每个方法、属性,我们首先实现一个ClassVistior和MethodVisitor
public class ClassAdapter extends ClassVisitor implements Opcodes {
private String owner;
private boolean isInterface;
public ClassAdapter(final ClassVisitor cv) {
super(ASM6, cv);
}
@Override
public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
cv.visit(version, access, name, signature, superName, interfaces);
owner = name;
isInterface = (access & Opcodes.ACC_INTERFACE) != 0;
}
@Override
public MethodVisitor visitMethod(final int access, final String name,
final String desc, final String signature, final String[] exceptions) {
MethodVisitor mv = super.visitMethod(access, name, desc, signature, exceptions);
if (!isInterface && mv != null && !"<init>".equals(name) && !"<clinit>".equals(name)) {
mv = new MethodAdapter(mv, owner, access, name, desc, signature, exceptions);
}
return mv;
}
}
当访问到类中的每个方法时,会调用visitMethod方法,产生一个MethodVisitor对象来访问这个方法。在这里忽略了构造函数和类的静态代码块。对于java中的构造函数,我们知道它第一个调用的方法一定是父类的构造函数,当对字节码修改构造函数时,情况会有些复杂,这里暂不讨论。下面我们来实现一个MethodVisitor对象:
public class MethodAdapter extends MethodVisitor implements Opcodes {
protected String className = null;
protected int access = -1;
protected String name = null;
protected String desc = null;
protected String signature = null;
protected String[] exceptions = null;
public MethodAdapter(final MethodVisitor mv, final String className, final int access, final String name,
final String desc, final String signature, final String[] exceptions) {
super(ASM6, mv);
this.className = className;
this.access = access;
this.name = name;
this.desc = desc;
this.signature = signature;
this.exceptions = exceptions;
}
@Override
public void visitCode() {
super.visitCode();
mv.visitFieldInsn(GETSTATIC, "java/lang/System", "err", "Ljava/io/PrintStream;");
mv.visitLdcInsn("CALL classname:"+ className+ " access"+access+" name:" + name +" desc"+desc+" singature:"+signature);
mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
}
@Override
public void visitInsn(int opcode) {
if ((opcode >= Opcodes.IRETURN && opcode <= Opcodes.RETURN) || opcode == Opcodes.ATHROW) {
mv.visitFieldInsn(GETSTATIC, "java/lang/System", "err", "Ljava/io/PrintStream;");
mv.visitLdcInsn("CALL classname:"+ className+ " access"+access+" name:" + name +" desc"+desc+" singature:"+signature);
mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
}
super.visitInsn(opcode);
}
}
当开始访问一个方法时,会调用visitCode方法,我们在这里加入一段输出“方法开始信息”的代码,当访问每个操作前会调用visitInsn,我们获取它的操作类型,如果是返回类型或者是抛出异常,则输出方法结束的信息。
要学会使用ASM操作字节码,需要对java字节码有一定的了解,这里只对涉及到的相关内容做简要介绍。
JVM中使用栈来操作数据,例如:
System.out.println("hello word");
编译成字节码后,根据字节码的规则可翻译成如下结果(使用javap可以查看java编译器生成的字节码文件,或者使用IDE中的相关插件):
getstatic // Field java/lang/System.out:Ljava/io/PrintStream;
ldc // String hello word
invokevirtual // Method java/io/PrintStream.println:(Ljava/lang/String;)V
其中ldc是一个常量入栈指令,将字符串 hello word的引用入栈,然后用方法调用指令invokevirtual调用println方法,从栈顶获得操作数,并出栈。根据字节码可以查找到ASM中的相关方法。
最后在ClassFileTransform中调用即可:
byte[] transformed = null;
try {
ClassReader cr = new ClassReader(new java.io.ByteArrayInputStream(classfileBuffer));
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES);
ClassAdapter ca = new ClassAdapter(cw);
cr.accept(ca, ClassReader.EXPAND_FRAMES);
transformed = cw.toByteArray();
}catch (RuntimeException re){
re.printStackTrace();
}catch (IOException e) {
System.err.println("can't transform "+ className+" "+e);
e.printStackTrace();
}
return transformed;
四、使用ASM跟踪目标程序调用链
1. 基本思路
- 为了获得方法的调用链信息,我们需要在每个方法的开始和结束加入收集信息的方法,这里我们不详细讨论如何处理收集到的信息,只关注于如何收集信息。我们新建一个类Actions,如下:
public class Actions {
static public final String path = Actions.class.getName().replace('.','/');
static public void methodStart(String owner, String name, String desc){
//处理操作
}
static public void methodEnd(String owner, String name, String desc){
//处理操作
}
}
并在方法的开始和结束部分分别调用Actions的methodStart和methodEnd方法
mv.visitLdcInsn(className);
mv.visitLdcInsn(name);
mv.visitLdcInsn(desc);
mv.visitMethodInsn(INVOKESTATIC, Actions.path,"methodStart","(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V",false);
2. 对程序中抛出异常的处理
- 在上述过程中,我们在方法的开头调用了methodStart方法,在所有抛出异常和return操作之前调用了methodEnd方法,以此来收集调用信息。但是,抛出异常并不代表方法一定结束了,而且对于运行时异常的情况,上述的方法也无能为力。我们知道java中提供了try finally的方式可以确保我们在代码块结束时运行某段程序,而字节码中并没有这样的操作。在字节码中try finally会以如下形式转换:
源代码
try {
System.out.println();
}finally {
System.out.println();
}
字节码
Code:
0: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
3: invokevirtual #4 // Method java/io/PrintStream.println:()V
6: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
9: invokevirtual #4 // Method java/io/PrintStream.println:()V
12: goto 24
15: astore_1
16: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
19: invokevirtual #4 // Method java/io/PrintStream.println:()V
22: aload_1
23: athrow
24: return
Exception table:
from to target type
0 6 15 any
Exception table中表示:从0到6行如果抛出了任何异常,跳转到15行,在12行goto到了24行方法的结尾。所以,我们可以使用捕获所有异常的方式,来实现try finally的操作。
五、总结
- 本文中使用了javaAgent和ASM在程序运行时修改字节码,获取了调用链信息
- 在ClassFileTransformz中我们可以只修改我们需要监控的类,对于那些没有监控的方法,可以考虑通过堆栈信息获得。注意并不是所有的字节码文件都能获取到并修改,有些在ClassFileTransform 对象加入之前就加载好的类,就没法再次获得了。使用javaagent也可以对已加载类的字节码做变更,但是这种情况下会有很多的限制。