基于javaAgent和ASM字节码技术跟踪java程序调用链

作者:李家琦        评阅人:高邱雅   鹿凯翔

一、介绍

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也可以对已加载类的字节码做变更,但是这种情况下会有很多的限制。
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,293评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,604评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,958评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,729评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,719评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,630评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,000评论 3 397
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,665评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,909评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,646评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,726评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,400评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,986评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,959评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,197评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,996评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,481评论 2 342

推荐阅读更多精彩内容