講過了 Java 類格式和利用反射進行的運行時訪問後,本系列到了進入更高級主題的時候了。本月我將開始本系列的第二部分,在這裏 Java 類信息只不過是由應用程序操縱的另外一種形式的數據結構而已。我將這個主題的整個內容稱爲 classworking。 html
我將以 Javassist 字節碼操做庫做爲對 classworking 的討論的開始。Javassist 不只是一個處理字節碼的庫,並且更由於它的另外一項功能使得它成爲試驗 classworking 的很好的起點。這一項功能就是:能夠用 Javassist 改變 Java 類的字節碼,而無需真正瞭解關於字節碼或者 Java 虛擬機(Java virtual machine JVM)結構的任何內容。從某方面將這一功能有好處也有壞處 -- 我通常不提倡隨便使用不了解的技術 -- 可是比起在單條指令水平上工做的框架,它確實使字節碼操做更可具備可行性了。 java
Javassist 使您能夠檢查、編輯以及建立 Java 二進制類。檢查方面基本上與經過 Reflection API 直接在 Java 中進行的同樣,可是當想要修改類而不僅是執行它們時,則另外一種訪問這些信息的方法就頗有用了。這是由於 JVM 設計上並無提供在類裝載到 JVM 中後訪問原始類數據的任何方法,這項工做須要在 JVM 以外完成。 數組
Javassist 使用javassist.ClassPool類跟蹤和控制所操做的類。這個類的工做方式與 JVM 類裝載器很是類似,可是有一個重要的區別是它不是將裝載的、要執行的類做爲應用程序的一部分連接,類池使所裝載的類能夠經過 Javassist API 做爲數據使用。可使用默認的類池,它是從 JVM 搜索路徑中裝載的,也能夠定義一個搜索您本身的路徑列表的類池。甚至能夠直接從字節數組或者流中裝載二進制類,以及從頭開始建立新類。 框架
裝載到類池中的類由javassist.CtClass實例表示。與標準的 Javajava.lang.Class類同樣,CtClass提供了檢查類數據(如字段和方法)的方法。不過,這只是CtClass的部份內容,它還定義了在類中添加新字段、方法和構造函數、以及改變類、父類和接口的方法。奇怪的是,Javassist 沒有提供刪除一個類中字段、方法或者構造函數的任何方法。 函數
字段、方法和構造函數分別由javassist.CtField、javassist.CtMethod和javassist.CtConstructor的實例表示。這些類定義了修改由它們所表示的對象的全部方法的方法,包括方法或者構造函數中的實際字節碼內容。 性能
全部字節碼的源代碼
Javassist 讓您能夠徹底替換一個方法或者構造函數的字節碼正文,或者在現有正文的開始或者結束位置選擇性地添加字節碼(以及在構造函數中添加其餘一些變量)。不論是哪一種狀況,新的字節碼都做爲類 Java 的源代碼聲明或者String中的塊傳遞。Javassist 方法將您提供的源代碼高效地編譯爲 Java 字節碼,而後將它們插入到目標方法或者構造函數的正文中。 優化
Javassist 接受的源代碼與 Java 語言的並不徹底一致,不過主要的區別只是增長了一些特殊的標識符,用於表示方法或者構造函數參數、方法返回值和其餘在插入的代碼中可能用到的內容。這些特殊標識符以符號$開頭,因此它們不會干擾代碼中的其餘內容。
對於在傳遞給 Javassist 的源代碼中能夠作的事情有一些限制。第一項限制是使用的格式,它必須是單條語句或者塊。在大多數狀況下這算不上是限制,由於能夠將所須要的任何語句序列放到塊中。下面是一個使用特殊 Javassist 標識符表示方法中前兩個參數的例子,這個例子用來展現其使用方法:
{ System.out.println("Argument 1: " + $1); System.out.println("Argument 2: " + $2); }
對於源代碼的一項更實質性的限制是不能引用在所添加的聲明或者塊外聲明的局部變量。這意味着若是在方法開始和結尾處都添加了代碼,那麼通常不能將在開始處添加的代碼中的信息傳遞給在結尾處添加的代碼。有可能繞過這項限制,可是繞過是很複雜的 -- 一般須要設法將分別插入的代碼合併爲一個塊。
做爲使用 Javassist 的一個例子,我將使用一個一般直接在源代碼中處理的任務:測量執行一個方法所花費的時間。這在源代碼中能夠容易地完成,只要在方法開始時記錄當前時間、以後在方法結束時再次檢查當前時間並計算兩個值的差。若是沒有源代碼,那麼獲得這種計時信息就要困可貴多。這就是 classworking 方便的地方 -- 它讓您對任何方法均可以做這種改變,而且不須要有源代碼。
清單 1 顯示了一個(很差的)示例方法,我用它做爲個人計時試驗的實驗品:StringBuilder類的buildString方法。這個方法使用一種全部 Java 性能優化的高手都會叫您 不要使用的方法構造一個具備任意長度的String-- 它經過反覆向字符串的結尾附加單個字符來產生更長的字符串。由於字符串是不可變的,因此這種方法意味着每次新的字符串都要經過一個循環來構造:使用從老的字符串中拷貝的數據並在結尾添加新的字符。最終的效果是用這個方法產生更長的字符串時,它的開銷愈來愈大。
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()); } } }
由於有這個方法的源代碼,因此我將爲您展現如何直接添加計時信息。它也做爲使用 Javassist 時的一個模型。清單 2 只展現了buildString()方法,其中添加了計時功能。這裏沒有多少變化。添加的代碼只是將開始時間保存爲局部變量,而後在方法結束時計算持續時間並打印到控制檯。
private String buildString(int length) { long start = System.currentTimeMillis(); String result = ""; for (int i = 0; i < length; i++) { result += (char)(i%26 + 'a'); } System.out.println("Call to buildString took " + (System.currentTimeMillis()-start) + " ms."); return result; }
來作 使用 Javassist 操做類字節碼以獲得一樣的效果看起來應該不難。Javassist 提供了在方法的開始和結束位置添加代碼的方法,別忘了,我在爲該方法中加入計時信息就是這麼作的。
不過,仍是有障礙。在描述 Javassist 是如何讓您添加代碼時,我提到添加的代碼不能引用在方法中其餘地方定義的局部變量。這種限制使我不能在 Javassist 中使用在源代碼中使用的一樣方法實現計時代碼,在這種狀況下,我在開始時添加的代碼中定義了一個新的局部變量,並在結束處添加的代碼中引用這個變量。
那麼還有其餘方法能夠獲得一樣的效果嗎?是的,我 能夠在類中添加一個新的成員字段,並使用這個字段而不是局部變量。不過,這是一種糟糕的解決方案,在通常性的使用中有一些限制。例如,考慮在一個遞歸方法中會發生的事情。每次方法調用自身時,上次保存的開始時間值就會被覆蓋而且丟失。
幸運的是有一種更簡潔的解決方案。我能夠保持原來方法的代碼不變,只改變方法名,而後用原來的方法名增長一個新方法。這個 攔截器(interceptor)方法可使用與原來方法一樣的簽名,包括返回一樣的值。清單 3 展現了經過這種方法改編後源代碼看上去的樣子:
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 took " + (System.currentTimeMillis()-start) + " ms."); return result; }
經過 Javassist 能夠很好地利用這種使用攔截器方法的方法。由於整個方法是一個塊,因此我能夠毫無問題地在正文中定義而且使用局部變量。爲攔截器方法生成源代碼也很容易 -- 對於任何可能的方法,只須要幾個替換。
實現添加方法計時的代碼要用到在 Javassist 基礎中描述的一些 Javassist API。清單 4 展現了該代碼,它是一個帶有兩個命令行參數的應用程序,這兩個參數分別給出類名和要計時的方法名。main()方法的正文只給出類信息,而後將它傳遞給addTiming()方法以處理實際的修改。addTiming()方法首先經過在名字後面附加「$impl」重命名現有的方法,接着用原來的方法名建立該方法的一個拷貝。而後它用含有對通過重命名的原方法的調用的計時代碼替換拷貝方法的正文。
public class JassistTiming { public static void main(String[] argv) { if (argv.length == 2) { try { // start by getting the class file and method CtClass clas = ClassPool.getDefault().get(argv[0]); if (clas == null) { System.err.println("Class " + argv[0] + " not found"); } else { // add timing interceptor to the class addTiming(clas, argv[1]); clas.writeFile(); System.out.println("Added timing to method " + argv[0] + "." + argv[1]); } } catch (CannotCompileException ex) { ex.printStackTrace(); } catch (NotFoundException ex) { ex.printStackTrace(); } catch (IOException ex) { ex.printStackTrace(); } } else { System.out.println("Usage: JassistTiming class method-name"); } } private static void addTiming(CtClass clas, String mname) throws NotFoundException, CannotCompileException { // get the method information (throws exception if method with // given name is not declared directly by this class, returns // arbitrary choice if more than one with the given name) CtMethod mold = clas.getDeclaredMethod(mname); // rename old method to synthetic name, then duplicate the // method with original name for use as interceptor String nname = mname+"$impl"; mold.setName(nname); CtMethod mnew = CtNewMethod.copy(mold, mname, clas, null); // start the body text generation by saving the start time // to a local variable, then call the timed method; the // actual code generated needs to depend on whether the // timed method returns a value String type = mold.getReturnType().getName(); StringBuffer body = new StringBuffer(); body.append("{\nlong start = System.currentTimeMillis();\n"); if (!"void".equals(type)) { body.append(type + " result = "); } body.append(nname + "($$);\n"); // finish body text generation with call to print the timing // information, and return saved value (if not void) body.append("System.out.println(\"Call to method " + mname + " took \" +\n (System.currentTimeMillis()-start) + " + "\" ms.\");\n"); if (!"void".equals(type)) { body.append("return result;\n"); } body.append("}"); // replace the body of the interceptor method with generated // code block and add it to class mnew.setBody(body.toString()); clas.addMethod(mnew); // print the generated code block just to show what was done System.out.println("Interceptor method body:"); System.out.println(body.toString()); } }
構造攔截器方法的正文時使用一個java.lang.StringBuffer來累積正文文本(這顯示了處理String的構造的正確方法,與在StringBuilder的構造中使用的方法是相對的)。這種變化取決於原來的方法是否有返回值。若是它 有返回值,那麼構造的代碼就將這個值保存在局部變量中,這樣在攔截器方法結束時就能夠返回它。若是原來的方法類型爲void,那麼就什麼也不須要保存,也不用在攔截器方法中返回任何內容。
除了對(重命名的)原來方法的調用,實際的正文內容看起來就像標準的 Java 代碼。它是代碼中的body.append(nname + "($$);\n")這一行,其中nname是原來方法修改後的名字。在調用中使用的$$標識符是 Javassist 表示正在構造的方法的一系列參數的方式。經過在對原來方法的調用中使用這個標識符,在調用攔截器方法時提供的參數就能夠傳遞給原來的方法。
清單 5 展現了首先運行未修改過的StringBuilder程序、而後運行JassistTiming程序以添加計時信息、最後運行修改後的StringBuilder程序的結果。能夠看到修改後的StringBuilder運行時會報告執行的時間,還能夠看到由於字符串構造代碼效率低下而致使的時間增長遠遠快於由於構造的字符串長度的增長而致使的時間增長。
[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 javassist.jar:. JassistTiming StringBuilder buildString Interceptor method body: { long start = System.currentTimeMillis(); java.lang.String result = buildString$impl($$); System.out.println("Call to method buildString took " + (System.currentTimeMillis()-start) + " ms."); return result; } Added timing to method StringBuilder.buildString [dennis]$ java StringBuilder 1000 2000 4000 8000 16000 Call to method buildString took 37 ms. Constructed string of length 1000 Call to method buildString took 59 ms. Constructed string of length 2000 Call to method buildString took 181 ms. Constructed string of length 4000 Call to method buildString took 863 ms. Constructed string of length 8000 Call to method buildString took 4154 ms. Constructed string of length 16000
Javassist 經過讓您處理源代碼而不是實際的字節碼指令清單而使 classworking 變得容易。可是這種方便性也有一個缺點。正如我在 全部字節碼的源代碼中提到的,Javassist 所使用的源代碼與 Java 語言並不徹底同樣。除了在代碼中識別特殊的標識符外,Javassist 還實現了比 Java 語言規範所要求的更寬鬆的編譯時代碼檢查。所以,若是不當心,就會從源代碼中生成可能會產生使人感到意外的結果的字節碼。
做爲一個例子,清單 6 展現了在將方法開始時的攔截器代碼所使用的局部變量的類型從long變爲int時的狀況。Javassist 會接受這個源代碼並將它轉換爲有效的字節碼,可是獲得的時間是毫無心義的。若是試着直接在 Java 程序中編譯這個賦值,您就會獲得一個編譯錯誤,由於它違反了 Java 語言的一個規則:一個窄化的賦值須要一個類型覆蓋。
[dennis]$ java -cp javassist.jar:. JassistTiming StringBuilder buildString Interceptor method body: { int start = System.currentTimeMillis(); java.lang.String result = buildString$impl($$); System.out.println("Call to method buildString took " + (System.currentTimeMillis()-start) + " ms."); return result; } Added timing to method StringBuilder.buildString [dennis]$ java StringBuilder 1000 2000 4000 8000 16000 Call to method buildString took 1060856922184 ms. Constructed string of length 1000 Call to method buildString took 1060856922172 ms. Constructed string of length 2000 Call to method buildString took 1060856922382 ms. Constructed string of length 4000 Call to method buildString took 1060856922809 ms. Constructed string of length 8000 Call to method buildString took 1060856926253 ms. Constructed string of length 16000
取決於源代碼中的內容,甚至可讓 Javassist 生成無效的字節碼。清單7展現了這樣的一個例子,其中我將JassistTiming代碼修改成老是認爲計時的方法返回一個int值。Javassist 一樣會毫無問題地接受這個源代碼,可是在我試圖執行所生成的字節碼時,它不能經過驗證。
[dennis]$ java -cp javassist.jar:. JassistTiming StringBuilder buildString Interceptor method body: { long start = System.currentTimeMillis(); int result = buildString$impl($$); System.out.println("Call to method buildString took " + (System.currentTimeMillis()-start) + " ms."); return result; } Added timing to method StringBuilder.buildString [dennis]$ java StringBuilder 1000 2000 4000 8000 16000 Exception in thread "main" java.lang.VerifyError: (class: StringBuilder, method: buildString signature: (I)Ljava/lang/String;) Expecting to find integer on stack
只要對提供給 Javassist 的源代碼加以當心,這就不算是個問題。不過,重要的是要認識到 Javassist 沒有捕獲代碼中的全部錯誤,因此有可能會出現沒有預見到的錯誤結果。