为了进一步优化表达式计算器的性能,我们要直接编译表达式——先根据表达式的逻辑动态生成Java代码,然后执行动态生成的Java代码,这种方法可以称之为编译法。
把后缀表达式翻译成Java表达式很简单,例如“$0 $1 $2 * +”可以由Java表达式“args[0] + (args[1] * args[2]”表示。我们要为动态生成的Java类选择一个唯一的名字,然后把代码写入临时文件。动态生成的Java类具有如下形式:
public class [类的名称] implements Calculator{ public int evaluate(int[] args) { return args[0] + (args[1] * args[2]); } } |
下面是编译法计算器的完整代码。
//CalculatorCompiler.java import java.util.Stack; import java.util.StringTokenizer; import java.io.*; //定制的类装入器 public class CalculatorCompiler extends ClassLoader { String _compiler; String _classpath; public CalculatorCompiler() { super(ClassLoader.getSystemClassLoader()); //编译器类型 _compiler = System.getProperty("calc.compiler"); //默认编译器 if (_compiler == null) _compiler = "javac"; _classpath = "."; String extraclasspath = System.getProperty("calc.classpath"); if (extraclasspath != null) { _classpath = _classpath + System.getProperty("path.separator") + extraclasspath; } } public Calculator compile(String expression) { // A3 String jtext = javaExpression(expression); String filename = ""; String classname = ""; try { //创建临时文件 File javafile = File.createTempFile( "compiled_", ".java", new File(".")); filename = javafile.getName(); classname = filename.substring( 0, filename.lastIndexOf(".")); generateJavaFile(javafile, classname, expression); //编译文件 invokeCompiler(javafile); //创建java类 byte[] buf = readBytes(classname + ".class"); Class c = defineClass(buf, 0, buf.length); try { // 创建并返回类的实例 return (Calculator) c.newInstance(); } catch (IllegalAccessException e) { throw new RuntimeException(e.getMessage()); } catch (InstantiationException e) { throw new RuntimeException(e.getMessage()); } } catch (IOException e) { throw new RuntimeException(e.getMessage()); } } //生成java文件 void generateJavaFile( File javafile, String classname, String expression) throws IOException { FileOutputStream out = new FileOutputStream(javafile); String text = "public class " + classname + " implements Calculator {" + " public int evaluate(int[] args) {" + " " + javaExpression(expression) + " }" + "}"; out.write(text.getBytes()); out.close(); } //编译java文件 void invokeCompiler(File javafile) throws IOException { String[] cmd = {_compiler, "-classpath", _classpath, javafile.getName()}; //执行编译命令 //A1: Process process = Runtime.getRuntime().exec(cmd); try { //等待编译器结束 process.waitFor(); } catch (InterruptedException e) { } int val = process.exitValue(); if (val != 0) { throw new RuntimeException( "编译错误:" + "错误代码" + val); } } //以byte数组形式读入类文件 byte[] readBytes(String filename) throws IOException { // A2 File classfile = new File(filename); byte[] buf = new byte[(int) classfile.length()]; FileInputStream in = new FileInputStream(classfile); in.read(buf); in.close(); return buf; } String javaExpression(String expression) { Stack stack = new Stack(); StringTokenizer toks = new StringTokenizer(expression); while (toks.hasMoreTokens()) { String tok = toks.nextToken(); if (tok.startsWith("$")) { stack.push("args[ " + Integer.parseInt(tok.substring(1)) + "]"); } else { int op = "+-*/".indexOf(tok.charAt(0)); if (op == -1) { stack.push(tok); } else { String arg2 = (String) stack.pop(); String arg1 = (String) stack.pop(); stack.push("( " + arg1 + " " + tok.charAt(0) + " " + arg2 + ")"); } } } return "return " + (String) stack.pop() + ";"; } } |
有了动态生成的代码之后,还要编译这些代码。我们假定系统使用的是javac编译器,且系统的PATH环境变量包含了javac编译器的路径。如果javac不在PATH环境变量中,或者要使用其他的编译器,则可以通过compiler属性指定,例如“-Dcalc.compiler=jikes”。如果编译器不是javac,一般还要把Java运行时JAR文件(jre/lib目录下的rt.jar)放入编译器的CLASSPATH。我们通过classpath属性为编译器指示额外的CLASSPATH成员。例如“-Dcalc.classpath=c:/java/jre/lib/rt.jar”。
编译器可以通过Runtime.exec(String[] cmd)作为一个外部进程调用,Runtime.exec的执行结果是一个Process对象(参见注释为“A1”的代码,下文以相似的方式引用代码的特定部分)。cmd数组包含了要执行的系统命令,其中第一个元素必须是待执行程序的名称,其余元素是传递给执行程序的各个参数。启动编译进程后,我们要等待编译进程运行结束,然后获取编译器的返回值。编译进程返回0表示编译成功。
最后一个与编译器有关的问题是,由于编译器作为外部进程运行,所以最好能够读取编译器的输出和错误报告。如果编译器遇到了大量的错误,编译过程可能处于阻塞状态(等待读取)。本文的例子只是为了测试性能,为简单计,不处理该问题。但是,在正式的Java工程中,这个问题是必须处理的。
编译成功之后,当前目录下就会有一个class文件,我们要用ClassLoader装入它(注释“A2”)。ClassLoader读取的是byte数组,所以我们先把class文件的内容读入byte数组,然后创建一个类。这里的类装入器属于最简单的定制类装入器,不过它已经足以完成我们这里的任务。成功装入类之后,创建该类的实例,然后返回这个实例(注释“A3”)。
从测试结果可以看出,编译法计算器的性能有了显著的提高。同样是1000000次计算,现在只需要100-200ms,而不是原来的1-2秒。不过,编译操作也带来了很大的时间开销,调用javac编译器编译代码大约需要1-2秒,抵消了计算器本身性能的提升。不过,javac并不是一个高性能的编译器,如果我们改用jikes之类的高速编译器,编译时间大大改善,降低到了100-200ms。
六、生成法
最理想的方案当然是既有编译法的运行时性能优势,又避免调用外部编译器的开销。下面我们要通过在内存中直接生成Java字节码避免调用外部编译器的开销,称之为生成法。
Java class文件的格式比较复杂,所以我们要用一个第三方的字节码代码库来生成文件。本例使用的是BCEL,即Bytecode Engineering Library。BCEL是一个源代码开放的免费代码库(http://sourceforge.net/projects/bcel/),可以帮助我们分析、创建、处理二进制的Java字节码。先来看看用BCEL直接生成字节码的计算器代码清单。
//CalculatorGenerator.java import java.io.*; import java.util.Stack; import java.util.StringTokenizer; //从sourceforge.net/projects/bcel/下载BCEL代码库 import de.fub.bytecode.classfile.*; import de.fub.bytecode.generic.*; import de.fub.bytecode.Constants; public class CalculatorGenerator extends ClassLoader { public Calculator generate(String expression) { String classname = "Calc_" + System.currentTimeMillis(); // 声明类 // B1 ClassGen classgen = new ClassGen(classname, "java.lang.Object", "", Constants.ACC_PUBLIC | Constants.ACC_SUPER, new String[]{"Calculator"}); // 构造函数 // B2 classgen.addEmptyConstructor(Constants.ACC_PUBLIC); // 加入计算表达式的方法 // B3 addEvalMethod(classgen, expression); byte[] data = classgen.getJavaClass().getBytes(); Class c = defineClass(data, 0, data.length); try { return (Calculator) c.newInstance(); } catch (IllegalAccessException e) { throw new RuntimeException(e.getMessage()); } catch (InstantiationException e) { throw new RuntimeException(e.getMessage()); } } private void addEvalMethod( ClassGen classgen, String expression) { // B4 ConstantPoolGen cp = classgen.getConstantPool(); InstructionList il = new InstructionList(); StringTokenizer toks = new StringTokenizer(expression); int stacksize = 0; int maxstack = 0; while (toks.hasMoreTokens()) { String tok = toks.nextToken(); if (tok.startsWith("$")) { int varnum = Integer.parseInt(tok.substring(1)); // 数组引用 il.append(InstructionConstants.ALOAD_1); // 数组序号 il.append(new PUSH(cp, varnum)); il.append(InstructionConstants.IALOAD); } else { int op = "+-*/".indexOf(tok.charAt(0)); // 根据操作符生成操作指令 switch (op) { case -1: int val = Integer.parseInt(tok); il.append(new PUSH(cp, val)); break; case 0: il.append(InstructionConstants.IADD); break; case 1: il.append(InstructionConstants.ISUB); break; case 2: il.append(InstructionConstants.IMUL); break; case 3: il.append(InstructionConstants.IDIV); break; default: throw new RuntimeException("操作符非法"); } } } il.append(InstructionConstants.IRETURN); // 创建方法 // B5 MethodGen method = new MethodGen(Constants.ACC_PUBLIC, Type.INT, new Type[] { Type.getType("[I")}, new String[]{"args"}, "evaluate", classgen.getClassName(), il, cp); // B6 method.setMaxStack(); method.setMaxLocals(); // 将方法加入到类 classgen.addMethod(method.getMethod()); } } |
使用BCEL时,首先要创建一个代表Java类的ClassGen对象(注释“B1”)。就象前面的编译法一样,我们要定义一个唯一的类名字。与普通Java代码不同的是,现在我们要明确声明超类java.lang.Object。ACC_PUBLIC声明该类是public类型。所有Java 1.0.2或更高版本的Java类都必须声明ACC_SUPER访问标记。最后,我们指定了该类实现Calculator接口。
其次,我们要保证类有一个默认的构造函数(注释“B2”)。对于一般的Java编译器,如果Java类没有定义构造函数,则Java编译器会自动插入一个默认的构造函数。现在我们用BCEL直接生成字节码,必须显式声明构造函数。用BCEL生成默认构造函数的办法很简单,只须调用ClassGen.addEmptyConstructor即可。
最后,我们要生成计算表达式的evaluate(int[] arguments)方法(注释“B3”和“B4”)。JVM本身就是以堆栈为基础,所以把表达式转换成字节码的过程很简单,基于堆栈的计算器几乎可以直接转换成字节码。指令按照执行次序收集到一个InstructionList。另外,我们还要一个指向常量池的引用ConstantPoolGen。
准备好InstructionList之后,接着我们就可以创建MethodGen对象(注释“B5”)。我们要创建的是一个public类型的方法,它的返回值是int,输入参数是一个整数数组(注意,这里我们用到了整数数组的内部表示法“[I”)。另外,我们还提供了参数的名字,不过这不是必需的。在这里,参数的名字是args,方法的名字是evaluate,最后几个参数包括一个类的名字,一个InstructionList和一个常量池。
在BCEL中定义Java方法的限制比较严格(注释“B6”)。例如,Java方法必须声明它需要多少大的操作符栈空间和为局部变量分配的空间。如果这些值错误,JVM将拒绝执行方法。对于本例来说,手工计算这些值也不是很麻烦,但BCEL提供了几个能够分析字节码的方法,我们只需简单地调用setMaxStack()和setMaxLocals()方法即可。
至此为止,整个类已经构造完毕。剩下的任务就是将类装入JVM,只要内存中有了byte数组形式的类,我们就可以象在编译法中那样调用类装入器。
直接生成的代码和编译法生成的代码执行起来一样快,但初始的对象创建时间却大大减少了。如果调用外部编译器,最好的情况下也需要100ms以上,利用BCEL创建类平均只需4ms。
七、性能和应用
表一显示了四种方法的平均对象创建时间,其中编译法分两种编译器分别测试。表二是5个测试用的表达式,表三是计算这些表达式1000000次所需时间。
显然,本文的例子完全是出于测试性能的目的,在实际应用中,要计算一个表达式1000000次的情形是非常罕见的。然而,需要在运行时解析数据(XML、脚本语言、查询语句,等等)却是经常会遇到的情形。动态代码生成不一定适用于每一类任务,但在下面这类场合应该比较有用:
·处理过程主要由运行时才有效的定义信息决定。
·处理过程需要多次重复执行。
·如果每次执行处理过程时都重新解析定义信息,需要付出较大的开销。
如果某个问题适合于使用代码动态生成技术,接下来还有一个问题:应该使用编译法,还是使用生成法?一般而言,首先生成Java代码然后调用外部编译器的方式比较简单。与JVM指令相比,大多数人更熟悉Java代码;调试有源代码的程序也比直接调试字节码来得方便。另外,好的编译器会在编译过程中优化代码,而这类优化操作在手工编码时一般是难以顾及的。另一方面,调用外部编译器是一个开销很大的过程,配置编译器和CLASSPATH也增加了维护应用的复杂程度。
生成法的性能优势非常明显。但是,它要求开发者深入了解class文件的格式和JVM字节码指令。编译器在生成代码的过程中实际上完成了许多表面上看不到的工作,手工编写的字节码不一定能够达到编译器自动编译的效果。如果要生成的代码比较复杂,在选择使用生成法之前,务必仔细斟酌。