用 BCEL 设计字节码

Java 编程的动态性,第 7 部分

用 BCEL 设计字节码

Apache BCEL 让您深入 JVM 汇编语言进行类操作的细节

系列内容:

此内容是该系列 7 部分中的第 7 部分: Java 编程的动态性

在本系列的最后三篇文章中,我展示了如何用 Javassist 框架操作类。这次我将用一种很不同的方法操纵字节码——使用 Apache Byte Code Engineering Library (BCEL)。与 Javassist 所支持的源代码接口不同,BCEL 在实际的 JVM 指令层次上进行操作。在希望对程序执行的每一步进行控制时,底层方法使 BCEL 很有用,但是当两者都可以胜任时,它也使 BCEL 的使用比 Javassist 要复杂得多。

我将首先讨论 BCEL 基本体系结构,然后本文的大部分内容将讨论用 BCEL 重新构建我的第一个 Javassist 类操作的例子。最后简要介绍 BCEL 包中提供的一些工具和开发人员用 BCEL 构建的一些应用程序。

BCEL 类访问

BCEL 使您能够同样具备 Javassist 提供的分析、编辑和创建 Java 二进制类的所有基本能力。BCEL 的一个明显区别是每项内容都设计为在 JVM 汇编语言的级别、而不是 Javassist 所提供的源代码接口上工作。除了表面上的差别,还有一些更深层的区别,包括在 BCEL 中组件的两个不同层次结构的使用——一个用于检查现有的代码,另一个用于创建新代码。我假定读者已经通过本系列前面的文章熟悉了 Javassist(请参阅侧栏 不要错过本系列的其余部分)。 因此我将主要介绍在开始使用 BCEL 时,可能会让您感到迷惑的那些不同之处。

与 Javassist 一样, BCEL 在类分析方面的功能基本上与 Java 平台通过 Relfection API 直接提供的功能是重复的。这种重复对于类操作工具箱来说是必要的,因为一般不希望在所要操作的类被修改 之前就装载它们。

BCEL 在 org.apache.bcel 包中提供了一些基本常量定义,但是除了这些定义,所有分析相关的代码都在 org.apache.bcel.classfile 包中。这个包中的起点是 JavaClass 类。这个类在用 BCEL 访问类信息时起的作用与使用常规 Java 反射时, java.lang.Class 的作用一样。 JavaClass 定义了得到这个类的字段和方法信息,以及关于父类和接口的结构信息的方法。 与 java.lang.Class 不同,JavaClass 还提供了对类的内部信息的访问,包括常量池和属性,以及作为字节流的完整二进制类表示。

JavaClass 实例通常是通过解析实际的二进制类创建的。BCEL 提供了 org.apache.bcel.Repository 类用于处理解析。在默认情况下,BCEL 解析并缓冲在 JVM 类路径中找到的类表示,从 org.apache.bcel.util.Repository 实例中得到实际的二进制类表示(注意包名的不同)。 org.apache.bcel.util.Repository 实际上是二进制类表示的源代码的接口。在默认源代码中使用类路径的地方,可以用查询类文件的其他路径或者其他访问类信息的方法替换。

改变类

除了对类组件的反射形式的访问, org.apache.bcel.classfile.JavaClass 还提供了改变类的方法。可以用这些方法将任何组件设置为新值。不过一般不直接使用它们,因为包中的其他类不以任何合理的方式支持构建新版本的组件。相反,在 org.apache.bcel.generic 包中有完全单独的一组类,它提供了 org.apache.bcel.classfile 类所表示的同一组件的可编辑版本。

像 org.apache.bcel.classfile.JavaClass 是使用 BCEL 分析现有类的起点一样, org.apache.bcel.generic.ClassGen 是创建新类的起点。它还用于修改现有的类——为了处理这种情况,有一个以 JavaClass 实例为参数的构造函数,并用它初始化 ClassGen 类信息。修改了类以后,可以通过调用一个返回 JavaClass 的方法从 ClassGen 实例得到可使用的类表示,它又可以转换为一个二进制类表示。

听起来有些乱?我想是的。事实上,在两个包之间来回转是使用 BCEL 的一个最主要的缺点。重复的类结构总有些碍手碍脚,所以如果频繁使用 BCEL,那么可能需要编写一个包装器类,它可以隐藏其中一些不同之处。在本文中,我将主要使用 org.apache.bcel.generic 包类,并避免使用包装器。不过在您自己进行开发时要记住这一点。

除了 ClassGenorg.apache.bcel.generic 包还定义了管理不同类组件的结构的类。这些结构类包括用于处理常量池的 ConstantPoolGen 、用于字段和方法的 FieldGen MethodGen 和处理一系列 JVM 指令的 InstructionList 。最后, org.apache.bcel.generic 包还定义了表示每一种类型的 JVM 指令的类。可以直接创建这些类的实例,或者在某些情况下使用 org.apache.bcel.generic.InstructionFactory helper 类。使用 InstructionFactory 的好处是它处理了许多指令构建的簿记细节(包括根据指令的需要在常量池中添加项)。在下面一节您将会看到如何使所有这些类协同工作。

用 BCEL 进行类操作

作为使用 BCEl 的一个例子,我将使用 第 4 部分中的一个 Javassist 例子——测量执行一个方法的时间。我甚至采用了与使用 Javassist 时的相同方式:用一个改过的名字创建要计时的原方法的一个副本,然后,通过调用改名后的方法,利用包装了时间计算的代码来替换原方法的主体。

选择一个试验品

清单 1 给出了一个用于展示目的示例方法: StringBuilder 类的 buildString 方法。正如我在 第 4 部分所说的,这个方法采用了所有 Java 性能专家告诫您 不要 使用的方式来构建一个 String —— 它重复地在字符串的未尾附加单个字符以创建更长的字符串。因为字符串是不可变的,所以这种方式意味着每次循环时会构建一个新的字符串,从老的字符串拷贝数据并在最后增加一个字符。总的效果就是用这个方法创建更长的字符串时,它会产生越来越大的开销。

清单 1. 要计时的方法

public class StringBuilder
{
    private String buildString(int length) {
        String result = "";
        for (int i = 0; i < length; i++) {
            result += (char)(i%26 + 'a');
        }
        return result;
    }
     
    public static void main(String[] argv) {
        StringBuilder inst = new StringBuilder();
        for (int i = 0; i < argv.length; i++) {
            String result = inst.buildString(Integer.parseInt(argv[i]));
            System.out.println("Constructed string of length " +
                result.length());
        }
    }
}

清单 2 显示了等同于用 BCEL 进行类操作改变的源代码。这里包装器方法只是保存当前时间,然后调用改名后的原方法,并在返回调用原方法的结果之前打印时间报告。

清单 2. 在原方法中加入计时

public class StringBuilder
{
    private String buildString$impl(int length) {
        String result = "";
        for (int i = 0; i < length; i++) {
            result += (char)(i%26 + 'a');
        }
        return result;
    }
     
    private String buildString(int length) {
        long start = System.currentTimeMillis();
        String result = buildString$impl(length);
        System.out.println("Call to buildString$impl took " +
            (System.currentTimeMillis()-start) + " ms.");
        return result;
    }
     
    public static void main(String[] argv) {
        StringBuilder inst = new StringBuilder();
        for (int i = 0; i < argv.length; i++) {
            String result = inst.buildString(Integer.parseInt(argv[i]));
            System.out.println("Constructed string of length " +
                result.length());
        }
    }
}

编写转换代码

用我在 BCEL 类访问一节中描述的 BCEL API 实现添加方法计时的代码。在 JVM 指令级别上的操作使代码比 第 4 部分 中 Javassist 的例子要长得多,所以这里我准备在提供完整的实现之前,一段一段地介绍。在最后的代码中,所有片段构成一个方法,它有两个参数: cgen ——它是 org.apache.bcel.generic.ClassGen 类的一个实例,用被修改的类的现有信息初始化,和方法——要计时方法的 org.apache.bcel.classfile.Method 实例。

清单 3 是转换方法的第一段代码。可以从注释中看到,第一部分只是初始化要使用的基本 BCEL 组件,它包括用要计时方法的信息初始化一个新的 org.apache.bcel.generic.MethodGen 实例。我为这个 MethodGen 设置一个空的指令清单,在后面我将用实际的计时代码填充它。在第 2 部分,我用原来的方法创建第二个 org.apache.bcel.generic.MethodGen 实例,然后从类中删除原来的方法。在第二个 MethodGen 实例中,我只是让名字加上“$impl”后缀,然后调用 getMethod() 以将可修改的方法信息转换为固定形式的 org.apache.bcel.classfile.Method 实例。然后调用 addMethod() 以便在类中添加改名后的方法。

清单 3. 添加拦截方法

// set up the construction tools
InstructionFactory ifact = new InstructionFactory(cgen);
InstructionList ilist = new InstructionList();
ConstantPoolGen pgen = cgen.getConstantPool();
String cname = cgen.getClassName();
MethodGen wrapgen = new MethodGen(method, cname, pgen);
wrapgen.setInstructionList(ilist);
     
// rename a copy of the original method
MethodGen methgen = new MethodGen(method, cname, pgen);
cgen.removeMethod(method);
String iname = methgen.getName() + "$impl";
methgen.setName(iname);
cgen.addMethod(methgen.getMethod());

清单 4 给出了转换方法的下一段代码。这里的第一部分计算方法调用参数在堆栈上占用的空间。之所以需要这段代码,是因为为了在调用包装方法之前在堆栈帧上存储开始时间,我需要知道局部变量可以使用什么偏移值(注意,我可以用 BCEL 的局部变量处理得到同样的效果,但是在本文中我选择使用显式的方式)。这段代码的第二部分生成对 java.lang.System.currentTimeMillis() 的调用,以得到开始时间,并将它保存到堆栈帧中计算出的局部变量偏移处。

您可能会奇怪为什么在开始参数大小计算时要检查方法是否是静态的,如果是静态的,将堆栈帧槽初始化为零(不是静态正好相反)。这种方式与 Java 如何处理方法调用有关。对于非静态的方法,每次调用的第一个(隐藏的)参数是目标对象的 this 引用,在计算堆栈帧中完整参数集大小时我要考虑到这点。

清单 4. 设置包装的调用

// compute the size of the calling parameters
Type[] types = methgen.getArgumentTypes();
int slot = methgen.isStatic() ? 0 : 1;
for (int i = 0; i < types.length; i++) {
    slot += types[i].getSize();
}
     
// save time prior to invocation
ilist.append(ifact.createInvoke("java.lang.System",
    "currentTimeMillis", Type.LONG, Type.NO_ARGS, 
    Constants.INVOKESTATIC));
ilist.append(InstructionFactory.createStore(Type.LONG, slot));

清单 5 显示了生成对包装方法的调用并保存结果(如果有的话)的代码。这段代码的第一部分再次检查方法是否是静态的。如果方法不是静态的,那么就生成将 this 对象引用装载到堆栈中的代码,同时设置方法调用类型为 virtual (而不是 static )。然后 for 循环生成将所有调用参数值拷贝到堆栈中的代码, createInvoke() 方法生成对包装的方法的实际调用,最后 if 语句将结果值保存到位于堆栈帧中的另一个局部变量中(如果结果类型不是 void )。

清单 5. 调用包装的方法

// call the wrapped method
int offset = 0;
short invoke = Constants.INVOKESTATIC;
if (!methgen.isStatic()) {
    ilist.append(InstructionFactory.createLoad(Type.OBJECT, 0));
    offset = 1;
    invoke = Constants.INVOKEVIRTUAL;
}
for (int i = 0; i < types.length; i++) {
    Type type = types[i];
    ilist.append(InstructionFactory.createLoad(type, offset));
    offset += type.getSize();
}
Type result = methgen.getReturnType();
ilist.append(ifact.createInvoke(cname, 
    iname, result, types, invoke));
     
// store result for return later
if (result != Type.VOID) {
   ilist.append(InstructionFactory.createStore(result, slot+2));
}

现在开始包装。清单 6 生成实际计算开始时间后经过的毫秒数,并作为编排好格式的消息打印出来的代码。这一部分看上去很复杂,但是大多数操作实际上只是写出输出消息的各个部分。它确实展示了几种我在前面的代码中没有使用的操作类型,包括字段访问(到 java.lang.System.out )和几种不同的指令类型。如果将 JVM 想象为基于堆栈的处理器,则其中大多数是容易理解的,因此我在这里就不再详细说明了。

清单 6. 计算并打印所使用的时间

// print time required for method call
ilist.append(ifact.createFieldAccess("java.lang.System", "out", 
    new ObjectType("java.io.PrintStream"), Constants.GETSTATIC));
ilist.append(InstructionConstants.DUP);
ilist.append(InstructionConstants.DUP);
String text = "Call to method " + methgen.getName() + " took ";
ilist.append(new PUSH(pgen, text));
ilist.append(ifact.createInvoke("java.io.PrintStream", "print",
    Type.VOID, new Type[] { Type.STRING }, Constants.INVOKEVIRTUAL));
ilist.append(ifact.createInvoke("java.lang.System", 
    "currentTimeMillis", Type.LONG, Type.NO_ARGS, 
    Constants.INVOKESTATIC));
ilist.append(InstructionFactory.createLoad(Type.LONG, slot));
ilist.append(InstructionConstants.LSUB);
ilist.append(ifact.createInvoke("java.io.PrintStream", "print", 
    Type.VOID, new Type[] { Type.LONG }, Constants.INVOKEVIRTUAL));
ilist.append(new PUSH(pgen, " ms."));
ilist.append(ifact.createInvoke("java.io.PrintStream", "println", 
    Type.VOID, new Type[] { Type.STRING }, Constants.INVOKEVIRTUAL));

生成了计时消息代码后,留给清单 7 的就是保存包装的方法的调用结果值(如果有的话),然后结束构建的包装器方法。最后这部分涉及几个步骤。调用 stripAttributes(true) 只是告诉 BCEL 不对构建的方法生成调试信息,而 setMaxStack()setMaxLocals() 调用计算并设置方法的堆栈使用信息。完成了这一步后,就可以实际生成方法的最终版本,并将它加入到类中。

清单 7. 完成包装器

// return result from wrapped method call
if (result != Type.VOID) {
    ilist.append(InstructionFactory.createLoad(result, slot+2));
}
ilist.append(InstructionFactory.createReturn(result));
     
// finalize the constructed method
wrapgen.stripAttributes(true);
wrapgen.setMaxStack();
wrapgen.setMaxLocals();
cgen.addMethod(wrapgen.getMethod());
ilist.dispose();

完整的代码

清单 8 显示了完整的代码(稍微改变了一下格式以适合显示宽度),包括以类文件的名字为参数的 main() 方法和要转换的方法:

清单 8. 完整的转换代码

public class BCELTiming
{
    private static void addWrapper(ClassGen cgen, Method method) {
         
        // set up the construction tools
        InstructionFactory ifact = new InstructionFactory(cgen);
        InstructionList ilist = new InstructionList();
        ConstantPoolGen pgen = cgen.getConstantPool();
        String cname = cgen.getClassName();
        MethodGen wrapgen = new MethodGen(method, cname, pgen);
        wrapgen.setInstructionList(ilist);
         
        // rename a copy of the original method
        MethodGen methgen = new MethodGen(method, cname, pgen);
        cgen.removeMethod(method);
        String iname = methgen.getName() + "$impl";
        methgen.setName(iname);
        cgen.addMethod(methgen.getMethod());
        Type result = methgen.getReturnType();
         
        // compute the size of the calling parameters
        Type[] types = methgen.getArgumentTypes();
        int slot = methgen.isStatic() ? 0 : 1;
        for (int i = 0; i < types.length; i++) {
            slot += types[i].getSize();
        }
         
        // save time prior to invocation
        ilist.append(ifact.createInvoke("java.lang.System",
            "currentTimeMillis", Type.LONG, Type.NO_ARGS, 
            Constants.INVOKESTATIC));
        ilist.append(InstructionFactory.
            createStore(Type.LONG, slot));
         
        // call the wrapped method
        int offset = 0;
        short invoke = Constants.INVOKESTATIC;
        if (!methgen.isStatic()) {
            ilist.append(InstructionFactory.
                createLoad(Type.OBJECT, 0));
            offset = 1;
            invoke = Constants.INVOKEVIRTUAL;
        }
        for (int i = 0; i < types.length; i++) {
            Type type = types[i];
            ilist.append(InstructionFactory.
                createLoad(type, offset));
            offset += type.getSize();
        }
        ilist.append(ifact.createInvoke(cname, 
            iname, result, types, invoke));
         
        // store result for return later
        if (result != Type.VOID) {
            ilist.append(InstructionFactory.
                createStore(result, slot+2));
        }
         
        // print time required for method call
        ilist.append(ifact.createFieldAccess("java.lang.System",
            "out",  new ObjectType("java.io.PrintStream"),
            Constants.GETSTATIC));
        ilist.append(InstructionConstants.DUP);
        ilist.append(InstructionConstants.DUP);
        String text = "Call to method " + methgen.getName() +
            " took ";
        ilist.append(new PUSH(pgen, text));
        ilist.append(ifact.createInvoke("java.io.PrintStream",
            "print", Type.VOID, new Type[] { Type.STRING },
            Constants.INVOKEVIRTUAL));
        ilist.append(ifact.createInvoke("java.lang.System", 
            "currentTimeMillis", Type.LONG, Type.NO_ARGS, 
            Constants.INVOKESTATIC));
        ilist.append(InstructionFactory.
            createLoad(Type.LONG, slot));
        ilist.append(InstructionConstants.LSUB);
        ilist.append(ifact.createInvoke("java.io.PrintStream",
            "print", Type.VOID, new Type[] { Type.LONG },
            Constants.INVOKEVIRTUAL));
        ilist.append(new PUSH(pgen, " ms."));
        ilist.append(ifact.createInvoke("java.io.PrintStream",
            "println", Type.VOID, new Type[] { Type.STRING },
            Constants.INVOKEVIRTUAL));
             
        // return result from wrapped method call
        if (result != Type.VOID) {
            ilist.append(InstructionFactory.
                createLoad(result, slot+2));
        }
        ilist.append(InstructionFactory.createReturn(result));
         
        // finalize the constructed method
        wrapgen.stripAttributes(true);
        wrapgen.setMaxStack();
        wrapgen.setMaxLocals();
        cgen.addMethod(wrapgen.getMethod());
        ilist.dispose();
    }
     
    public static void main(String[] argv) {
        if (argv.length == 2 && argv[0].endsWith(".class")) {
            try {
             
                JavaClass jclas = new ClassParser(argv[0]).parse();
                ClassGen cgen = new ClassGen(jclas);
                Method[] methods = jclas.getMethods();
                int index;
                for (index = 0; index < methods.length; index++) {
                    if (methods[index].getName().equals(argv[1])) {
                        break;
                    }
                }
                if (index < methods.length) {
                    addWrapper(cgen, methods[index]);
                    FileOutputStream fos =
                        new FileOutputStream(argv[0]);
                    cgen.getJavaClass().dump(fos);
                    fos.close();
                } else {
                    System.err.println("Method " + argv[1] + 
                        " not found in " + argv[0]);
                }
            } catch (IOException ex) {
                ex.printStackTrace(System.err);
            }
             
        } else {
            System.out.println
                ("Usage: BCELTiming class-file method-name");
        }
    }
}

试一试

清单 9 显示了以未修改形式第一次运行 StringBuilder 程序的结果,然后运行 BCELTiming 程序以加入计时信息,最后运行修改后的 StringBuilder 程序。可以看到 StringBuilder 在修改后是如何开始报告执行时间的,以及时间为何比构建的字符串长度增加更快,这是由于字符串构建代码的效率不高所致。

清单 9. 运行这个程序

[dennis]$ java StringBuilder 1000 2000 4000 8000 16000
Constructed string of length 1000
Constructed string of length 2000
Constructed string of length 4000
Constructed string of length 8000
Constructed string of length 16000
[dennis]$ java -cp bcel.jar:. BCELTiming StringBuilder.class buildString
[dennis]$ java StringBuilder 1000 2000 4000 8000 16000
Call to method buildString$impl took 20 ms.
Constructed string of length 1000
Call to method buildString$impl took 79 ms.
Constructed string of length 2000
Call to method buildString$impl took 250 ms.
Constructed string of length 4000
Call to method buildString$impl took 879 ms.
Constructed string of length 8000
Call to method buildString$impl took 3875 ms.
Constructed string of length 16000

包装 BCEL

BCEL 有比我在本文中所介绍的基本类操作支持更多的功能。它还包括完整的验证器实现以保证二进制类对于 JVM 规范是有效的(参见 org.apache.bcel.verifier.VerifierFactory ),一个生成很好地分帧并链接的 JVM 级二进制类视图的反汇编程序,甚至一个 BCEL 程序生成器,它输出源代码以让 BCEL 程序编译所提供的类。( org.apache.bcel.util.BCELifier 类没有包括在 Javadocs 中,所以其用法要看源代码。这个功能很吸引人,但是输出对大多数开发人员来说可能人过于隐晦了)。

我自己使用 BCEL 时,发现 HTML 反汇编程序特别有用。要想试用它,只要执行 BCEL JAR 中的 org.apache.bcel.util.Class2HTML 类,用要反汇编的类文件的路径作为命令行参数。它会在当前目录中生成 HTML 文件。例如,下面我将反汇编在计时例子中使用的 StringBuilder 类:

[dennis]$ java -cp bcel.jar org.apache.bcel.util.Class2HTML StringBuilder.class
Processing StringBuilder.class...Done.

图 1 是反汇编程序生成的分帧输出的屏幕快照。在这个快照中,右上角的大帧显示了添加到 StringBuilder 类中的计时包装器方法的分解。在下载文件中有完整的 HTML 输出——如果要实际观看它,只需在浏览器窗口中打开 StringBuilder.html 文件。

图 1. 反汇编 StringBuilder

《用 BCEL 设计字节码》

当前,BCEL 可能是 Java 类操作使用最多的框架。在 Web 网站上列出了一些使用 BCEL 的其他项目,包括 Xalan XSLT 编译器、Java 编程语言的 AspectJ 扩展和几个 JDO 实现。许多其他未列出的项目也使用 BCEL,包括我自己的 JiBX XML 数据绑定项目。不过,BCEL 列出的几个项目已经转而使用其他库,所以不要将这个列表作为 BCEL 大众化程度的绝对依据。

BCEL 最大的好处是它的商业友好的 Apache 许可证及其丰富的 JVM 指令级支持。这些功能结合其稳定性和长寿性,使它成为类操作应用程序的非常流行的选择。不过,BCEL 看来没有设计为具有很好的速度或者容易使用。在大多数情况下,Javassist 提供了更友好的 API,并有相近的速度(甚至更快),至少在我的简单测试中是这样。如果您的项目可以使用 Mozilla Public License (MPL) 或者 GNU Lesser General Public License (LGPL),那么 Javassist 可能是更好的选择(它在这两种许可证下都可以用)。

下一篇

我已经介绍了 Javassist 和 BCEL,本系列的下一篇文章将深入比我们目前已经介绍的用途更大的类操作应用程序。在 第 2 部分,我展示了方法调用反射比直接调用慢得多。在第 8 部分中,我将显示如何使用 Javassist 和 BCEL,以便用运行时动态生成的代码替换反射调用,从而极大地提高性能。下个月请回来看另一篇 Java 编程的动态性以了解详情。

相关主题

    原文作者:JVM
    原文地址: https://juejin.im/entry/59c66364f265da06624e60d5
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞