摘要:
本文介紹了如何在普通Java程式中應用代碼動態產生技術,並測試、比較了各種實現方法的效能。
提綱:
一、概述 / 二、運算式計算機 / 三、解釋法
四、解析法 / 五、編譯法 / 六、產生法 / 七、效能和應用
本文:
一、概述
經常有人批評Java的效能,認為Java程式無法與C或C++程式相提並論。為此,Java一直在效能最佳化上進行著不懈的努力,特別是運行時的效能最佳化機制,平息了許多責難。但是,不管Java把效能提高到了什麼程度,人們對代碼效能的渴求是沒有止境的。
顯然,Java在某些操作上的效能確實無法與C/C++相比,這是由Java語言的特點所決定的,例如為了跨平台而採用了中繼語言(位元組碼)機制。另一方面,由於Java有著許多獨特的特性,它可以利用許多其他語言很難採用的最佳化技術,動態代碼產生就是其中之一。
所謂動態代碼產生,就是一種在運行時由程式動態產生代碼的過程。動態產生的程式碼和產生它的程式在同一個JVM中運行,且訪問方式也相似。當然,和其他最佳化技術相似,動態代碼產生只適用於某些特定類型的任務。
JSP或許就是人們最熟悉的動態代碼產生的例子。Servlet引擎能夠把客戶的請求分發給Servlet處理,但Servlet天生是一種靜態結構。在啟動伺服器之前,Servlet一般必須先編譯和配置好。雖然Servlet有著許多優點,但在靈活性方面,Servlet略遜一籌。JSP技術突破了Servlet的限制,允許在運行時以JSP檔案為基礎動態建立Servlet。
當客戶程式發出了對JSP檔案的請求,Servlet引擎向JSP引擎發出請求,JSP引擎處理JSP檔案並返回結果。JSP檔案是一系列動作的文本描述,這一系列動作的執行結果就是返回給使用者的頁面。顯然,如果每一個使用者的請求到達時都通過解釋的方式執行JSP頁面,開銷肯定比較大。所以,JSP引擎編譯JSP頁面動態建立Servlet。一旦JSP頁面被改變,JSP引擎就會動態地建立新的Servlet。
在這裡,動態代碼產生技術的優勢非常明顯——既滿足了靈活性的要求,又不致於對效能產生太大的影響。在編譯Servlet甚至啟動伺服器時,系統的行為方式不必完全固定;同時,由於不必在應答每一個請求時解釋執行JSP檔案,所以也就減少了回應時間。
二、運算式計算機
下面我們來看看如何在普通Java程式中使用動態代碼產生技術。本文的例子是一個簡單的四則運算運算式計算機,它能夠計算形如“4 $0 + $1 *”的尾碼運算式,其中$0和$1分別表示變數0、變數1。可能出現在運算式中的符號有三種:變數,常量,操作符。
尾碼運算式是一種基於堆棧的計算運算式,處理過程從左至右依次進行,仍以前面的運算式為例:先把4和變數0壓入堆棧,下一個字元是操作符“+”,所以把當時棧頂的兩個值(4和變數0)相加,然後用加法結果取代棧頂的兩個值。接著,再把1壓入堆棧,由於接下來的是操作符“*”,所以對這時棧頂的兩個值執行乘法操作。如果把這個運算式轉換成通常的代數運算式(即中綴運算式),它就是“(4 + $0) * $1”。如果兩個變數分別是“[3,6]”,則運算式的計算結果是(4+3)*6=42。
為了比較代碼動態產生和常規編程方式的效能差異,我們將以各種不同的方式實現運算式計算機,然後測試各個計算機的效能。
本文的所有運算式計算機都實現(或隱含地實現)calculator介面。calculator介面只有一個evaluate方法,它的輸入參數是一個整數數組,傳回值是一個表示計算結果的整數。
//Calculator.javapublic interface Calculator { int evaluate(int[] arguments);}
三、解釋法
首先我們來看一個簡單但效率不高的運算式計算機,它利用Stack對象計算表達。每次計算,運算式都要重新分析一次,因此可以稱為解釋法。
不過,運算式的符號分析只在對象建立時執行一次,避免StringTokenizer類帶來太大的開銷。
//SimpleCalculator.javaimport java.util.ArrayList;import java.util.Stack;import java.util.StringTokenizer;public class SimpleCalculator implements Calculator { String[] _toks; // 符號列表 public SimpleCalculator(String expression) { // 構造符號列表 ArrayList list = new ArrayList(); StringTokenizer tokenizer = new StringTokenizer(expression); while (tokenizer.hasMoreTokens()) { list.add(tokenizer.nextToken()); } _toks = (String[]) list.toArray(new String[list.size()]); } // 將變數值代入運算式中的變數, // 然後返回運算式的計算結果 public int evaluate(int[] args) { Stack stack = new Stack(); for (int i = 0; i < _toks.length; i++) { String tok = _toks[i]; // 以‘$’開頭的是變數 if (tok.startsWith("$")) { int varnum = Integer.parseInt(tok.substring(1)); stack.push(new Integer(args[varnum])); } else { char opchar = tok.charAt(0); int op = "+-*/".indexOf(opchar); if (op == -1) { // 常量 stack.push(Integer.valueOf(tok)); } else { // 操作符 int arg2 = ((Integer) stack.pop()).intValue(); int arg1 = ((Integer) stack.pop()).intValue(); switch (op) { // 對棧頂的兩個值執行指定的操作 case 0: stack.push(new Integer(arg1 + arg2)); break; case 1: stack.push(new Integer(arg1 - arg2)); break; case 2: stack.push(new Integer(arg1 * arg2)); break; case 3: stack.push(new Integer(arg1 / arg2)); break; default: throw new RuntimeException ("操作符不合法: " + tok); } } } } return ((Integer) stack.pop()).intValue(); }}從本文後面的效能測試資料可以看出,這種運算式計算方式的效率相當低。對於偶爾需要計算運算式的場合,它也許適用,但我們還有更好的
處理方式。
四、解析法
如果經常要計算運算式的值,一種更好的辦法是先解析運算式,應用Composite設計模式,構造一棵運算式樹狀架構。我們稱這種運算式計算方式為
解析法。如下面的代碼所示,樹的內部結構代表了運算式的計算邏輯,因而避免了每次計算運算式時重複分析計算邏輯。
//CalculatorParser.javaimport java.util.Stack;import java.util.StringTokenizer;public class CalculatorParser { public Calculator parse(String expression) { // 分析運算式,構造由運算式各個符號構成的 // 樹形結構。 Stack stack = new Stack(); StringTokenizer toks = new StringTokenizer(expression); while (toks.hasMoreTokens()) { String tok = toks.nextToken(); if (tok.startsWith("$")) { // 以‘$’開頭的是變數 int varnum = Integer.parseInt(tok.substring(1)); stack.push(new VariableValue(varnum)); } else { int op = "+-*/".indexOf(tok.charAt(0)); if (op == -1) { //常量 int val = Integer.parseInt(tok); stack.push(new ConstantValue(val)); } else { //操作符 Calculator node2 = (Calculator) stack.pop(); Calculator node1 = (Calculator) stack.pop(); stack.push( new Operation(tok.charAt(0), node1, node2)); } } } return (Calculator) stack.pop(); } // 常量 static class ConstantValue implements Calculator { private int _value; ConstantValue(int value) { _value = value; } public int evaluate(int[] args) { return _value; } } // 變數 static class VariableValue implements Calculator { private int _varnum; VariableValue(int varnum) { _varnum = varnum; } public int evaluate(int[] args) { return args[_varnum]; } } // 操作符 static class Operation implements Calculator { char _op; Calculator _arg1; Calculator _arg2; Operation(char op, Calculator arg1, Calculator arg2) { _op = op; _arg1 = arg1; _arg2 = arg2; } public int evaluate(int args[]) { int val1 = _arg1.evaluate(args); int val2 = _arg2.evaluate(args); if (_op == '+') { return val1 + val2; } else if (_op == '-') { return val1 - val2; } else if (_op == '*') { return val1 * val2; } else if (_op == '/') { return val1 / val2; } else { throw new RuntimeException("操作符不合法: " + _op); } } }}由於運算式的計算邏輯已經事先解析好,CalculatorParser的效能明顯高於第一個通過解釋方式執行的計算機。儘管如此,我們還可以通過
代碼動態產生技術進一步最佳化代碼。
五、編譯法
為了進一步最佳化運算式計算機的效能,我們要直接編譯運算式——先根據運算式的邏輯動態產生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.javaimport 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.javaimport 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位元組碼指令。編譯器在產生代碼的過程中實際上完成了許多表面上看不到的工作,手工編寫的位元組碼不一定能夠達到編譯器自動編譯的
效果。如果要產生的程式碼比較複雜,在選擇使用產生法之前,務必仔細斟酌。