java字节码工程:asm介绍

ASM是一个通用的Java字节码操作和分析框架。它可以用于修改已有的class或者直接以二进制形式动态生成class。ASM提供了一些常用的字节码转换和分析算法,据此可以构建出自定义的复杂的转换和代码分析工具。ASM提供了与其它Java字节码框架相似的功能,但其专注于性能。因为它被设计和实现得尽可能小而快,它适合用在动态系统中(但当然也可以以静态的方式使用,例如在编译器中)。

ASM已经用在众多工程工程中,包括:

  • OpenJDK,用于生成lambda call sites,并且用于Nashorn编译器中
  • Groovy编译器和Kotlin编译器
  • CoberturaJacoco
  • CGLIB,用于动态生成proxy类(它用在如下工程中,如MockitoEasyMock
  • Gradle,用于在运行时生成一些类

Java字节码

Java字节码是JVM的指令集。每个指令由一个字节的操作码(opcode),以及后面跟随的零到多个操作数(operand)组成。例如,iadd,接收两个整形作为操作数并且将两者相加。以下是一个Java指令集的分组,可以帮助我们快速了解Java字节码都有些什么:

  • 加载和存储(例如, aload_0, istore
  • 算术和逻辑(例如, ladd, fcmpl
  • 类型转换(例如, i2b, d2i
  • 对象创建和操作(new, putfield
  • 操作数栈管理(例如,swapdup2
  • 控制转移(例如,ifeqgoto
  • 方法调用和返回(例如,invokespecial, areturn

JVM(Java Virtual Machine)

为了理解字节码的细节,我们需要讨论Java虚拟机是如何执行字节码的。JVM是一个独立于平台的执行环境,它将Java字节码转换为机器语言并进行执行。一个JVM是一个基于栈的机器。每个线程都有一个JVM栈,栈中存储着帧。一个帧在每次调用一个方法时调用,它由一个操作数栈、一个局部变量数组和一个对当前方法所在类的运行时常量池的引用组成。

Java虚拟机栈

更多关于JVM的知识可以参考这里

基于栈的虚拟机

为了更好地理解Java字节码,我们需要了解一些关于基于栈的虚拟机的知识。一个基于栈的虚拟机的内存结构中,操作数存储于一个栈数据结构中。通过从栈中弹出数据来执行操作,处理后将它们以后进先出的顺序压回栈中。在一个基于栈的虚拟机中,将两个数字相加通常需要以如下方式进行其中20,7和“result”是操作数):

基于栈的虚拟机

如果你想了解更多关于这部分的内容,可以在这里找到更多关于基于栈的虚拟机和基于寄存器的虚拟机的知识。

例子,考虑如下Java代码:

public class Test
{
    public static void main(String[] args) {
        printOne();
        printOne();
        printTwo();
    }
    
    public static void printOne() {
        System.out.println("Hello World");
    }
    
    public static void printTwo() {
        printOne();
        printOne();
    }
}

我们使用javac来编译Java程序,然后使用javap -c来反编译class文件,这样就可以看到java字节码。一个Java class文件(.class文件名后缀)是一个包含Java字节码并可以在JVM中执行的文件。以下是我们得到的字节码:

public class Test {
  public Test();
    Code:
       0: aload_0       
       1: invokespecial #1                  // Method java/lang/Object."":()V
       4: return        

  public static void main(java.lang.String[]);
    Code:
       0: invokestatic  #2                  // Method printOne:()V
       3: invokestatic  #2                  // Method printOne:()V
       6: invokestatic  #3                  // Method printTwo:()V
       9: return        

  public static void printOne();
    Code:
       0: getstatic     #4                  // Field java/lang/System.out:Ljava/io/PrintStream;
       3: ldc           #5                  // String Hello World
       5: invokevirtual #6                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
       8: return        

  public static void printTwo();
    Code:
       0: invokestatic  #2                  // Method printOne:()V
       3: invokestatic  #2                  // Method printOne:()V
       6: return        
}

解析:

  • 看看public的Test()构造器。这个构造器的字节码包含三个操作码指令。第一个操作码,aload_0,将索引为0的局部变量表中的值压入到操作数栈中。局部变量表用于给方法传递参数。下一个操作码指令,invokespecial,调用该类的超类的构造器。因为所有没有显式地继承任何其它类的类都隐式地继承java.lang.Object,因此编译器提供必要的字节码来调用这个基类的构造器。在这个操作码中,操作数栈中最顶部的值被弹出。最后一个指令,return,仅仅返回该返回的。字节码指令的索引数字不是连续的,因为有些操作码具有一些参数,这些参数会在字节码数组中占据一些空间。

  • #号数字是用于在常量池中查找常量的常量索引。常量池是一个表结构的数据,代表各种字符串常量、类和接口名以及其它在class文件结构和其子结构中被引用的其它常量。我们可以使用javac -c -v查看整个常量池。

  • Java编程语言提供了两个基本类型的方法:实例方法(invokevirtual)和类方法(invokestatic,也称静态方法)。当Java虚拟机调用了一个类方法时,它根据对象引用的类型(type)来选择调用的方法,这总是在编译时就确定了的。另一方面,当虚拟机调用一个实例方法时,它根据对象的实际的类(class)来选择调用的方法,这只在运行时可以知晓。

  • 这里有一个关于Java字节码的好的技术文章

访问者模式(Visitor Pattern)

在软件工程的面向对象编程中,访问者模式是将一个算法从它所作用的对象结构上进行分离的方式。这种分离的实际效果是在无需改变这些对象结构的前提下向其中添加新的操作。

本质上讲,访问者模式允许一个人将新的虚方法(virtual functions)添加到一个类家族中,而无需改变类本身;取而代之的是创建一个实现了所有这些适合的特定虚方法的访问者类来达到我们的目的。访问者将实例引用作为输入,并通过双重分发(double dispatch)来实现目标。

访问者模式需要一个支持单分发(single dispatch)的编程语言。这种条件下,考虑两个对象,每个都是某种类类型,一个称为“element”,另一个称为“visitor”。一个元素有一个接收一个访问者作为参数的accept()方法。accept()方法调用访问者的一个visit()方法,元素将其自身作为参数传递给visit()方法。

代码示例:

本例中我们会使用类似ASM中用于操作字节码的访问器模式来

  • 添加一个accept(Visitor)方法到“element”层次结构中
  • 创建一个“visitor”基类,对每种类型的“element”类型它都有一个对应的visit()方法
  • 创建一个“visitor”的派生类,用于在“elements”做相应的操作
  • 客户端创建“visitor”对象,并将每个访问器对象都传递给accept()调用
interface Element {
   // 1. accept(Visitor) interface
   public void accept( Visitor v ); // first dispatch
}

class This implements Element {
   // 1. accept(Visitor) implementation
   public void   accept( Visitor v ) {
     v.visit( this );
   } 
   public String thiss() {
     return "This";
   }
}

class That implements Element {
   public void   accept( Visitor v ) {
     v.visit( this );
   }
   public String that() {
     return "That";
   }
}

class TheOther implements Element {
   public void   accept( Visitor v ) {
     v.visit( this );
   }
   public String theOther() {
     return "TheOther"; 
   }
}

// 2. Create a "visitor" base class with a visit() method for every "element" type
interface Visitor {
   public void visit( This e ); // second dispatch
   public void visit( That e );
   public void visit( TheOther e );
}

// 3. Create a "visitor" derived class for each "operation" to perform on "elements"
class UpVisitor implements Visitor {                   
   public void visit( This e ) {
      System.out.println( "do Up on " + e.thiss() );
   }
   public void visit( That e ) {
      System.out.println( "do Up on " + e.that() );
   }
   public void visit( TheOther e ) {
      System.out.println( "do Up on " + e.theOther() );
   }
}

class DownVisitor implements Visitor {
   public void visit( This e ) {
      System.out.println( "do Down on " + e.thiss() );
   }
   public void visit( That e ) {
      System.out.println( "do Down on " + e.that() );
   }
   public void visit( TheOther e ) {
      System.out.println( "do Down on " + e.theOther() );
   }
}

class VisitorDemo {
   public static Element[] list = { new This(), new That(), new TheOther() };

   // 4. Client creates "visitor" objects and passes each to accept() calls
   public static void main( String[] args ) {
      UpVisitor    up   = new UpVisitor();
      DownVisitor  down = new DownVisitor();
      for (int i=0; i < list.length; i++) {
         list[i].accept( up );
      }
      for (int i=0; i < list.length; i++) {
         list[i].accept( down );
      }
   }
}

其输出看起来是这样的:

do Up on This                do Down on This
do Up on That                do Down on That
do Up on TheOther            do Down on TheOther

ASM中的访问者(Visitors)

ASM使用了访问者模式。“Objects”或者“Acceptors”包含ClassReader类和MethodNode类。“Visitor”接口包括ClassVisitorAnnotationVisitorFieldVisitorMethodVisitor

accept()方法属于MethodNode类并具有如下签名:

void accept(ClassVisitor cv)

void accept(MethodVisitor mv)

visit()方法和其它方法家族,例如visitField()等,都是ClassVisitor类的一部分并具有如下签名:

void visit(int version, int access, String name, String signature, String superName, String[] interfaces)

AnnotationVisitor visitAnnotation(String desc, boolean visible)

void visitAttribute(Attribute attr)

void visitEnd()

FieldVisitor visitField(int access, String name, String desc, String signature, Object value)

void visitInnerClass(String name, String outerName, String innerName, int access)

MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions)

void visitOuterClass(String owner, String name, String desc)

void visitSource(String source, String debug)

ASM访问器模式

调用追踪仪

在这一章节中,我们使用ASM实现了一个调用栈跟踪仪器。代码会记录每个方法并返回。输出日志可以容易地解析为一个调用上下文树。

配置

你需要准备好JDK以及Apache Ant工具。本文的代码在此处

Hello ASM:拷贝class文件

我们的第一个ASM程序会简单地制作一个.class文件拷贝。这个代码后面会用到,Copy.java的代码如下所示:

import java.io.FileInputStream;
import java.io.FileOutputStream;

import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassWriter;

public class Copy {
    public static void main(final String args[]) throws Exception {
        FileInputStream is = new FileInputStream(args[0]);

        ClassReader cr = new ClassReader(is);
        ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES);
        cr.accept(cw, 0);

        FileOutputStream fos = new FileOutputStream(args[1]);
        fos.write(cw.toByteArray());
        fos.close();
    }
}

拷贝程序接收两个命令行参数:args[0]是要拷贝的.class文件,args[1]是要拷贝到的.class文件。

本例中我们使用了两个ASM类:ClassReader从文件读取Java字节码,ClassWriter将字节码写入到文件。ASM使用了访问者模式:ClassWriter实现了ClassVisitor接口,并且通过调用cr.accept(cw, 0)来触发ClassReader对字节码输入进行遍历,通过对cw的一系列调用来生成一样的字节码作为输出。

传递给ClassWriter构造器的参数ClassWriter.COMPUTE_FRAMES是可选的标志,它指示我们希望ClassWriter为我们计算栈帧的大小。cr.accept的第二个参数也是可选的。传入0表明我们想要默认的行为。

# Compile Copy
$ javac -cp asm-all-5.0.3.jar Copy.java

# Use Copy to copy itself
$ java -cp .:asm-all-5.0.3.jar Copy Copy.class Copy2.class

生成调用轨迹

现在我们有了一些ASM基础,可以开始着手调用轨迹记录仪的开发。我们会记录方法调用并返回到stderr,对于每个方法调用都进行这样的操作并进行换行。例如,让我们考虑如下简单的Java程序:

public class Test
{
    public static void main(String[] args) {
        printOne();
        printOne();
        printTwo();
    }
    
    public static void printOne() {
        System.out.println("Hello World");
    }
    
    public static void printTwo() {
        printOne();
        printOne();
    }
}

我们测量所有的调用点,并在调用前后将调用点打印到stderr,例如,对Test.class的调用记录与如下代码是等价的:

public class TestInstrumented
{
    public static void main(String[] args) {
        System.err.println("CALL printOne");
        printOne();
        System.err.println("RETURN printOne");

        System.err.println("CALL printOne");
        printOne();
        System.err.println("RETURN printOne");

        System.err.println("CALL printTwo");
        printTwo();
        System.err.println("RETURN printTwo");
    }
    
    public static void printOne() {
        System.err.println("CALL println");
        System.out.println("Hello World");
        System.err.println("RETURN println");
    }
    
    public static void printTwo() {
        System.err.println("CALL printOne");
        printOne();
        System.err.println("RETURN printOne");

        System.err.println("CALL printOne");
        printOne();
        System.err.println("RETURN printOne");
    }
}

我们通过修改上面的Copy代码来实现记录仪,为了修改类文件,我们需要在ClassReaderClassWriter之间插入一些代码,我们使用适配器模式来做到。适配器将(被适配的目标)对象包装起来,重写它的一些方法并委托给其它对象。这样就可以很容易地改变被包装的对象的行为。这里我们会适配ClassWriter类,这样当我们为调用点生成字节码时,也就在调用的前后生成了产生追踪日志的代码。

由于方法调用位置发生在方法内部,我们的记录工作会发生在方法定义内部。还有一点,由于方法定义在类中,我们需要遍历一个类来记录它的方法。

第一步是使用ClassAdapter来适配ClassWriter对象。默认所有继承于ClassVisitorClassAdapter方法都仅仅是简单地调用被适配的ClassWriter的相同的方法。大多数时候我们希望默认的行为。我们会重写ClassWriter.visitMethod方法,对一个类中的每个方法定义都会调用该方法一次。visitMethod会返回一个MethodVisitor对象,它用于处理方法正文。ClassWriter.visitMehtod返回一个MethodVisitor为一个方法生成字节码。我们会适配ClassWriter.visitMethod返回的MethodVisitor,并插入额外的打印调用轨迹的指令。

class ClassAdapter extends ClassVisitor implements Opcodes {

    public ClassAdapter(final ClassVisitor cv) {
        super(ASM5, cv);
    }

    @Override
    public MethodVisitor visitMethod(final int access, final String name,
            final String desc, final String signature, final String[] exceptions) {
        MethodVisitor mv = cv.visitMethod(access, name, desc, signature, exceptions);
        return mv == null ? null : new MethodAdapter(mv);
    }
}

class MethodAdapter extends MethodVisitor implements Opcodes {

    public MethodAdapter(final MethodVisitor mv) {
        super(ASM5, mv);
    }

    @Override
    public void visitMethodInsn(int opcode, String owner, String name, String desc, boolean itf) {
        /* TODO: System.err.println("CALL" + name); */
  
        /* do call */
        mv.visitMethodInsn(opcode, owner, name, desc, itf);

        /* TODO: System.err.println("RETURN" + name);  */
    }
}

现在我们的MethodAdapter没有添加任何指令,它简单地将调用委托给包装的MethodVisitormv。现在我们有一个以Java语法编写的记录仪代码,但不知道如何用ASM的API去表达,我们可以使用ASMifier工具:

$ javac TestInstrumented.java
$ java -cp .:asm-all-5.0.3.jar org.objectweb.asm.util.ASMifier TestInstrumented
/** WARNING: THINGS ARE ELIDED **/
{
mv = cw.visitMethod(ACC_PUBLIC + ACC_STATIC, "printOne", "()V", null, null);
mv.visitCode();

mv.visitFieldInsn(GETSTATIC, "java/lang/System", "err", "Ljava/io/PrintStream;");
mv.visitLdcInsn("CALL println");
mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);

mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
mv.visitLdcInsn("Hello World");
mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);

mv.visitFieldInsn(GETSTATIC, "java/lang/System", "err", "Ljava/io/PrintStream;");
mv.visitLdcInsn("RETURN println");
mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);

mv.visitInsn(RETURN);
mv.visitMaxs(2, 0);
mv.visitEnd();
}
/** WARNING: MORE THINGS ARE ELIDED **/

ASMifier的输出是一个生成TestInstrumented.class的程序,我们需求的重点是调用System.err.println

mv.visitFieldInsn(GETSTATIC, "java/lang/System", "err", "Ljava/io/PrintStream;");
mv.visitLdcInsn("CALL println");
mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);

现在我们知道如何调用System.err.println,我们可以完成我们的MethodAdapter

class MethodAdapter extends MethodVisitor implements Opcodes {

    public MethodAdapter(final MethodVisitor mv) {
        super(ASM5, mv);
    }

    @Override
    public void visitMethodInsn(int opcode, String owner, String name, String desc, boolean itf) {
        /* System.err.println("CALL" + name); */
        mv.visitFieldInsn(GETSTATIC, "java/lang/System", "err", "Ljava/io/PrintStream;");
        mv.visitLdcInsn("CALL " + name);
        mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
  
        /* do call */
        mv.visitMethodInsn(opcode, owner, name, desc, itf);

        /* System.err.println("RETURN" + name);  */
        mv.visitFieldInsn(Opcodes.GETSTATIC, "java/lang/System", "err", "Ljava/io/PrintStream;");
        mv.visitLdcInsn("RETURN " + name);
        mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
    }

编译并运行例子:

# Build Instrumenter
$ javac -cp asm-all-5.0.3.jar Instrumenter.java

# Build Example
$ javac Test.java

# Move Test.class out of the way
$ cp Test.class Test.class.bak

# Instrument Test
$ java -cp .:asm-all-5.0.3.jar Instrumenter Test.class.bak Test.class

# Run!
$ java Test
CALL printOne
CALL println
Hello World
RETURN println
RETURN printOne
CALL printOne
CALL println
Hello World
RETURN println
RETURN printOne
CALL printTwo
CALL printOne
CALL println
Hello World
RETURN println
RETURN printOne
CALL printOne
CALL println
Hello World
RETURN println
RETURN printOne
RETURN printTwo

输出就是我们期望看到的,其中四个“Hello World”打印到标准输出,和输出到stderr的调用跟踪记录相互交错。

参考