javaagenet和arm字节码增强

概述

本文介绍javaagent规范和通过asm修改字节码。并实现一个简单的统计方法执行时间的agent。

javaagent规范

java 提供了操作运行时字节码的机制,见包java.lang.instrument。可以用java开发一个jar包,以agent方式部署。比如jar包myAgent.jar
部署指令:

-javaagent:/path/myAgent.jar 

jar包规范:

在main方法之前执行

1、有Premain-Class属性
文件META-INF/MANIFEST.MF

Manifest-Version: 1.0
Implementation-Title: myagent
Premain-Class: com.thoreauz.agent.AgentMain
Implementation-Version: 1.0-SNAPSHOT
Built-By: zhaozhou

2、Premain-Class类有premain方法。

// 第一个优先级高;参数只能是一个string,如果多个参数,需要agent自己定义规范并解析
public static void premain(String agentArgs, Instrumentation inst);
public static void premain(String agentArgs);

在main方法之后执行

同premain,只是属性和实现方法的区别
1. 有Agent-Class属性
3. 必须实现public的静态方法agentmain

public static void agentmain(String agentArgs, Instrumentation inst);
public static void agentmain(String agentArgs);

Instrumentation

看premain和agentmain都有一个接收Instrumentation参数的方法,那它能干什么呢。Instrumentation是一个接口,提供了服务使得可以在运行时操作java程序,包括改变字节码,新增一个jar包,替换class等。 于是可以通过它实现各种功能的agent,比如监控,覆盖率分析,打印日志,动态部署等工具。
主要方法:
1. addTransformer:可以在加载字节码时注册拦截器装换源代码
2. redefineClasses: 替换class
3. appendToBootstrapClassLoaderSearch: 运行时指定jar包给bootclassload加载
目前提供10多个方法,详细见api。

使用asm增强字节码

ASM库可以用来生成、转换和分析编译后的java类。asm提供了两套api,核心的API是基于事件的,而Tree API是基于对象的。基于对象模型的api构建在基于事件的模型之上。asm有如下特点:
1. 小,且设计良好模块化的API,且易于使用
2. 文档完善,有eclipse何idea插件帮助方便生产字节码操作api。
3. 社区完善、开发。

两套api各有优缺点,基于事件的api速度更快,使用内存空间更新,但实现复杂转换较困难。下文简单介绍基于事件的api用法。

byte[] b1 = ...;
ClasssWriter cw = new ClassWriter();
ClassAdapter ca = new ClassAdapter(cw); 
ClassReader cr = new ClassReader(b1);
cr.accept(ca, 0);
byte[] b2 = cw.toByteArray(); 


Reader相当于字节码生产者,把所有事件传递给writer,而adapter是一个中间适配器,形成一个转换链。下面写一个示例,往一个class的方法里面写一行代码;

public static void main(String[] args) throws Exception {
        // 获取class byte
        String className = "com.thoreauz.agent.Test";
        InputStream resourceAsStream = asmTest.class
                .getClassLoader()
                .getResourceAsStream(className.replace('.', '/') + ".class");
        byte[] bytes = IOUtils.toByteArray(resourceAsStream);

        // 往方法里面添加代码System.out.println("hello");
        ClassWriter cw = new ClassWriter(8);
        TestVisitor ca = new TestVisitor(cw);
        ClassReader cr = new ClassReader(bytes);
        cr.accept(ca, 0);
        byte[] b2 = cw.toByteArray();

        FileOutputStream output = new FileOutputStream(new File("/tmp/target.class"));
        IOUtils.write(b2, output);
    }

    static class TestVisitor extends ClassVisitor {
        public TestVisitor(ClassVisitor classVisitor) {
            super(Opcodes.ASM7, classVisitor);
        }

        @Override
        public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
            MethodVisitor mv = cv.visitMethod(access, name, descriptor, signature, exceptions);
            if (name.equals("<init>") || mv == null) {
                // 构造方法不需要添加
                return mv;
            }
            mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
            mv.visitLdcInsn("hello");
            mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
            return mv;
        }
    }

详细文档见asm官网

示例

下面写一个javaagent,统计应用方法执行时间。
第一步: 构建agent包

public class AgentMain {
    //代理程序入口函数
    public static void premain(String args, Instrumentation inst) throws ClassNotFoundException, IllegalAccessException, InstantiationException {
        System.out.println("agent premain begin");
        //添加字节码转换器
        inst.addTransformer(new Transformer(), true);
        System.out.println("agent premain end");
    }
}

把AgentMain添加到jar包属性中,可以借助maven-assembly-plugin,把agent依赖都打入包中并制定premain类。

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-assembly-plugin</artifactId>
    <executions>
        <execution>
            <goals>
                <goal>single</goal>
            </goals>
            <phase>package</phase>
            <configuration>
                <descriptorRefs>
                    <descriptorRef>jar-with-dependencies</descriptorRef>
                </descriptorRefs>
                <archive>
                    <manifestEntries>
                        <Premain-Class>com.thoreauz.agent.AgentMain</Premain-Class>
                        <Can-Redefine-Classes>true</Can-Redefine-Classes>
                        <Can-Retransform-Classes>true</Can-Retransform-Classes>
                    </manifestEntries>
                </archive>
            </configuration>
        </execution>
    </executions>
</plugin>

生成的jar包含了依赖的类,比如asm。

第二步: 编写Transformer

public class Transformer implements ClassFileTransformer {
    @Override
    public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
                            ProtectionDomain protectionDomain, byte[] classfileBuffer) {
        if (className == null) {
            //返回null,将会使用原生class。
            return null;
        }
        if (className.startsWith("java") ||
                className.startsWith("javax") ||
                className.startsWith("jdk") ||
                className.startsWith("sun") ||
                className.startsWith("com/sun") ||
                className.startsWith("com/intellij") ||
                className.startsWith("org/jetbrains") ||
                className.startsWith("com/thoreauz/agent")

        ) {
            // 不对JDK类以及agent类增强
            return null;
        }
        //读取类的字节码流
        ClassReader reader = new ClassReader(classfileBuffer);
        //创建操作字节流值对象,ClassWriter.COMPUTE_MAXS:表示自动计算栈大小
        ClassWriter writer = new ClassWriter(reader, ClassWriter.COMPUTE_MAXS);
        //接收一个ClassVisitor子类进行字节码修改
        reader.accept(new TimeClassVisitor(writer, className), 8);
        //返回修改后的字节码流
        return writer.toByteArray();
    }
}
public class TimeClassVisitor extends ClassVisitor {
    private String className = null;
    public TimeClassVisitor(ClassVisitor classVisitor, String className) {
        super(Opcodes.ASM7, classVisitor);
        this.className = className.replace('/','.');
    }
    @Override
    public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
        MethodVisitor mv = cv.visitMethod(access, name, descriptor, signature, exceptions);
        //过来待修改类的构造函数
        if (name.equals("<init>") || mv == null) {
            // 对象初始化方法就不增强了
            return mv;
        }
        mv = new AdviceAdapter(Opcodes.ASM7, mv, access, name, descriptor) { 
            @Override
            public void onMethodEnter() {
                //TODO 1方法进入时计时
            }
            @Override
            public void onMethodExit(int opcode) {
            	//TODO 方法退出时获取结束时间并计算执行时间
            }
        };
        return mv;
    }
}

通过AdviceAdapter分别在方法进入和退出时修改方法字节码。这儿引入一个保存时间的类:

public class TimeCache {
    public static Map<String, Long> startTimeMap = new HashMap<>();
    public static Map<String, Long> endTimeMap = new HashMap<>();
    public static void setStartTime(String methodName, long time) {
        startTimeMap.put(methodName, time);
    }
    public static void setEndTime(String methodName, long time) {
        endTimeMap.put(methodName, time);
    }
    public static String getCostTime(String methodName) {
        long start = startTimeMap.get(methodName);
        long end = endTimeMap.get(methodName);
        return methodName + "[" + (end - start) + " ms]";
    }
}

假设有一个test方法如下

public class Test {
    public void test() {
         // do something
    }
}

实现计时功能,只需要把test改成如下即可:

public class Test {
    public void test() {
        TimeCache.setStartTime("test",System.currentTimeMillis());
        // do something
        TimeCache.setEndTime("test",System.currentTimeMillis());
        System.out.println(TimeCache.getCostTime("test"));
    }
}

于是,怎么通过asm把test方法转换成如上代码便是关键,有两种方式:
第一种:通过javap命令,反编译class为字节码结构

# javap -c Test 
public class com.thoreauz.agent.Test {
  public com.thoreauz.agent.Test();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public void test();
    Code:
       0: ldc           #2                  // String test
       2: invokestatic  #3                  // Method java/lang/System.currentTimeMillis:()J
       5: invokestatic  #4                  // Method com/thoreauz/agent/TimeCache.setStartTime:(Ljava/lang/String;J)V
       8: ldc           #2                  // String test
      10: invokestatic  #3                  // Method java/lang/System.currentTimeMillis:()J
      13: invokestatic  #5                  // Method com/thoreauz/agent/TimeCache.setEndTime:(Ljava/lang/String;J)V
      16: getstatic     #6                  // Field java/lang/System.out:Ljava/io/PrintStream;
      19: ldc           #2                  // String test
      21: invokestatic  #7                  // Method com/thoreauz/agent/TimeCache.getCostTime:(Ljava/lang/String;)Ljava/lang/String;
      24: invokevirtual #8                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      27: return
}

对照操作指令转成asm的方法。

第一种:借助asm的ide插件直接转


> intelliJ 插件 asm bytecode outline

最终得到TimeClassVisitor如下:

public class TimeClassVisitor extends ClassVisitor {
    private String className = null;


    public TimeClassVisitor(ClassVisitor classVisitor, String className) {
        super(Opcodes.ASM7, classVisitor);
        this.className = className.replace('/','.');
    }

    @Override
    public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
        MethodVisitor mv = cv.visitMethod(access, name, descriptor, signature, exceptions);
        //过来待修改类的构造函数
        if (name.equals("<init>") || mv == null) {
            // 对象初始化方法就不增强了
            return mv;
        }
        String key = className + ":" + name;
        mv = new AdviceAdapter(Opcodes.ASM7, mv, access, name, descriptor) {
            //方法进入时获取开始时间
            @Override
            public void onMethodEnter() {
                mv.visitLdcInsn(key);
                mv.visitMethodInsn(INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false);
                mv.visitMethodInsn(INVOKESTATIC,
                        "com/thoreauz/agent/TimeCache",
                        "setStartTime",
                        "(Ljava/lang/String;J)V",
                        false);
            }

            //方法退出时获取结束时间并计算执行时间
            @Override
            public void onMethodExit(int opcode) {
                mv.visitLdcInsn(key);
                mv.visitMethodInsn(INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false);
                mv.visitMethodInsn(INVOKESTATIC,
                        "com/thoreauz/agent/TimeCache",
                        "setEndTime",
                        "(Ljava/lang/String;J)V",
                        false);

                mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
                mv.visitLdcInsn(key);
                mv.visitMethodInsn(INVOKESTATIC,
                        "com/thoreauz/agent/TimeCache",
                        "getCostTime",
                        "(Ljava/lang/String;)Ljava/lang/String;",
                        false);
                mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);

            }
        };
        return mv;
    }
}

第三步:测试

public class Application {
    public static void main(String[] args) throws InterruptedException {
        System.out.println("hello world!");
        add(1, 2);
    }

    private static int add(int a, int b) throws InterruptedException {
        Thread.sleep(300);
        return a + b;
    }
}

打印出Application的main和add方法的运行时间:

# java  -javaagent:/省略了path/myagent-1.0-SNAPSHOT-jar-with-dependencies.jar com.thoreauz.test.Application 
agent premain begin
agent premain end
hello world!
com.thoreauz.test.Application:add[305 ms]
com.thoreauz.test.Application:main[305 ms]

总结

本文简单介绍了javaagent的使用规范,并写了premain的agent,通过asm增强代码统计方法运行时间。

javaagent的使用场景很多,agentmain在main方法运行后操作java应用更是提供了无数可能。但是真正开发agent还有许多难点比如:

  1. agent本身依赖asm等其他二方包,为了不污染jdk自带类和应用类,应该自定义classLoader,隔离agent类。
  2. asm字节码增强,需要考虑排查一些类,避免造成死循环调用。比如PrintStream.println方法注入System.out.println。还有对已经注入代码的类的判断避免重复注入(同标志或者代码锁)。
  3. 字节码增强已经改变了类,agent的问题代码可能影响应用的执行。

最后推荐阿里开源的两个工具,基于本文提到的原理开发而来。

  1. TProfiler:性能分析工具,代码比较简单,可以作为初步学习参考。
  2. arthas:java问题诊断神器,功能强大丰富。
CONTENTS