在一般的Java應用開發過程中,開發人員使用Java的方式比較簡單。開啟慣用的IDE,編寫Java原始碼,再利用IDE提供的功能直接運行Java 程式就可以了。這種開發模式背後的過程是:開發人員編寫的是Java原始碼檔案(.java),IDE會負責調用Java的編譯器把Java原始碼編譯成平台無關的位元組代碼(byte code),以類檔案的形式儲存在磁碟上(.class)。Java虛擬機器(JVM)會負責把Java位元組代碼載入並執行。Java通過這種方式來實現其“編寫一次,到處運行(Write once, run anywhere)” 的目標。Java類檔案中包含的位元組代碼可以被不同平台上的JVM所使用。Java位元組代碼不僅可以以檔案形式存在於磁碟上,也可以通過網路方式來下載,還可以只存在於記憶體中。JVM中的類載入器會負責從包含位元組代碼的位元組數組(byte[])中定義出Java類。在某些情況下,可能會需要動態產生 Java位元組代碼,或是對已有的Java位元組代碼進行修改。這個時候就需要用到本文中將要介紹的相關技術。首先介紹一下如何動態編譯Java源檔案。
動態編譯Java源檔案
在一般情況下,開發人員都是在程式運行之前就編寫完成了全部的Java原始碼並且成功編譯。對有些應用來說,Java原始碼的內容在運行時刻才能確定。這個時候就需要動態編譯原始碼來產生Java位元組代碼,再由JVM來載入執行。典型的情境是很多演算法競賽的線上評測系統(如PKU JudgeOnline),允許使用者上傳Java代碼,由系統在背景編譯、運行並進行判定。在動態編譯Java源檔案時,使用的做法是直接在程式中調用Java編譯器。
JSR 199引入了Java編譯器API。如果使用JDK 6的話,可以通過此API來動態編譯Java代碼。比如下面的代碼用來動態編譯最簡單的Hello World類。該Java類的代碼是儲存在一個字串中的。
public class CompilerTest { public static void main(String[] args) throws Exception { String source = "public class Main { public static void main(String[] args) {System.out.println(/"Hello World!/");} }"; JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); StandardJavaFileManager fileManager = compiler.getStandardFileManager(null, null, null); StringSourceJavaObject sourceObject = new CompilerTest.StringSourceJavaObject("Main", source); Iterable< extends JavaFileObject> fileObjects = Arrays.asList(sourceObject); CompilationTask task = compiler.getTask(null, fileManager, null, null, null, fileObjects); boolean result = task.call(); if (result) { System.out.println("編譯成功。"); } } static class StringSourceJavaObject extends SimpleJavaFileObject { private String content = null; public StringSourceJavaObject(String name, String content) ??throws URISyntaxException { super(URI.create("string:///" + name.replace('.','/') + Kind.SOURCE.extension), Kind.SOURCE); this.content = content; } public CharSequence getCharContent(boolean ignoreEncodingErrors) ??throws IOException { return content; } }}
如果不能使用JDK 6提供的Java編譯器API的話,可以使用JDK中的工具類com.sun.tools.javac.Main,不過該工具類只能編譯存放在磁碟上的檔案,類似於直接使用javac命令。
另外一個可用的工具是Eclipse JDT Core提供的編譯器。這是Eclipse Java開發環境使用的增量式Java編譯器,支援運行和調試有錯誤的代碼。該編譯器也可以單獨使用。Play架構在內部使用了JDT的編譯器來動態編譯Java原始碼。在開發模式下,Play架構會定期掃描項目中的Java原始碼檔案,一旦發現有修改,會自動編譯 Java原始碼。因此在修改代碼之後,重新整理頁面就可以看到變化。使用這些動態編譯的方式的時候,需要確保JDK中的tools.jar在應用的 CLASSPATH中。
下面介紹一個例子,是關於如何在Java裡面做四則運算,比如求出來(3+4)*7-10的值。一般的做法是分析輸入的運算運算式,自己來類比計算過程。考慮到括弧的存在和運算子的優先順序等問題,這樣的計算過程會比較複雜,而且容易出錯。另外一種做法是可以用JSR 223引入的指令碼語言支援,直接把輸入的運算式當做JavaScript或是JavaFX指令碼來執行,得到結果。下面的代碼使用的做法是動態產生Java原始碼並編譯,接著載入Java類來執行並擷取結果。這種做法完全使用Java來實現。
private static double calculate(String expr) throws CalculationException { String className = "CalculatorMain"; String methodName = "calculate"; String source = "public class " + className + " { public static double " + methodName + "() { return " + expr + "; } }"; //省略動態編譯Java原始碼的相關代碼,參見上一節 boolean result = task.call(); if (result) { ClassLoader loader = Calculator.class.getClassLoader(); try { Class<?> clazz = loader.loadClass(className); Method method = clazz.getMethod(methodName, new Class<?>[] {}); Object value = method.invoke(null, new Object[] {}); return (Double) value; } catch (Exception e) { throw new CalculationException("內部錯誤。"); } } else { throw new CalculationException("錯誤的運算式。"); }}
上面的代碼給出了使用動態產生的Java位元組代碼的基本模式,即通過類載入器來載入位元組代碼,建立Java類的對象的執行個體,再通過Java反射API來調用對象中的方法。
Java位元組代碼增強
Java 位元組代碼增強指的是在Java位元組代碼產生之後,對其進行修改,增強其功能。這種做法相當於對應用程式的二進位檔案進行修改。在很多Java架構中都可以見到這種實現方式。Java位元組代碼增強通常與Java源檔案中的註解(annotation)一塊使用。註解在Java原始碼中聲明了需要增強行為及相關的中繼資料,由架構在運行時刻完成對位元組代碼的增強。Java位元組代碼增強應用的情境比較多,一般都集中在減少冗餘代碼和對開發人員屏蔽底層的實現細節上。用過JavaBeans的人可能對其中那些必須添加的getter/setter方法感到很繁瑣,並且難以維護。而通過位元組代碼增強,開發人員只需要聲明Bean中的屬性即可,getter/setter方法可以通過修改位元組代碼來自動添加。用過JPA的人,在偵錯工具的時候,會發現實體類中被添加了一些額外的 域和方法。這些域和方法是在運行時刻由JPA的實現動態添加的。位元組代碼增強在面向方面編程(AOP)的一些實現中也有使用。
在討論如何進行位元組代碼增強之前,首先介紹一下表示一個Java類或介面的位元組代碼的組織形式。
類檔案 { 0xCAFEBABE,小版本號碼,大版本號碼,常量池大小,常量池數組, 存取控制標記,當前類資訊,父類資訊,實現的介面個數,實現的介面資訊數組,域個數, 域資訊數組,方法個數,方法資訊數組,屬性個數,屬性資訊數組}
如上所示,一個類或介面的位元組代碼使用的是一種鬆散的組織圖,其中所包含的內容依次排列。對於可能包含多個條目的內容,如所實現的介面、域、方法和屬性等,是以數組來表示的。而在數組之前的是該數組中條目的個數。不同的內容類型,有其不同的內部結構。對於開發人員來說,直接操縱包含位元組代碼的位元組數組的話,開發效率比較低,而且容易出錯。已經有不少的開源庫可以對位元組代碼進行修改或是從頭開始建立新的Java類的位元組代碼內容。這些類庫包括ASM、cglib、serp和BCEL等。使用這些類庫可以在一定程度上降低增強位元組代碼的複雜度。比如考慮下面一個簡單的需求,在一個Java類的所有方法執行之前輸出相應的日誌。熟悉AOP的人都知道,可以用一個前增強(before advice)來解決這個問題。如果使用ASM的話,相關的代碼如下:
ClassReader cr = new ClassReader(is);ClassNode cn = new ClassNode();cr.accept(cn, 0);for (Object object : cn.methods) { MethodNode mn = (MethodNode) object; if ("<init>".equals(mn.name) || "<clinit>".equals(mn.name)) { continue; } InsnList insns = mn.instructions; InsnList il = new InsnList(); il.add(new FieldInsnNode(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;")); il.add(new LdcInsnNode("Enter method -> " + mn.name)); il.add(new MethodInsnNode(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V")); insns.insert(il); mn.maxStack += 3;}ClassWriter cw = new ClassWriter(0);cn.accept(cw);byte[] b = cw.toByteArray();
從ClassWriter就可以擷取到包含增強之後的位元組代碼的位元組數組,可以把位元組代碼寫回磁碟或是由類載入器直接使用。上述樣本中,增強部分的邏輯比較簡單,只是遍曆Java類中的所有方法並添加對System.out.println方法的調用。在位元組代碼中,Java方法體是由一系列的指令組成的。而要做的是產生調用System.out.println方法的指令,並把這些指令插入到指令集合的最前面。ASM對這些指令做了抽象,不過熟悉全部的指令比較困難。ASM提供了一個工具類ASMifierClassVisitor,可以列印出Java類的位元組代碼的結構資訊。當需要增強某個類的時候,可以先在原始碼上做出修改,再通過此工具類來比較修改前後的位元組代碼的差異,從而確定該如何編寫增強代碼。
對類檔案進行增強時機是需要在Java原始碼編譯之後,在JVM執行之前。比較常見的做法有:
- 由IDE在完成編譯操作之後執行。如Google App Engine的Eclipse外掛程式會在編譯之後運行DataNucleus來對實體類進行增強。
- 在構建過程中完成,比如通過Ant或Maven來執行相關的操作。
- 實現自己的Java類載入器。當擷取到Java類的位元組代碼之後,先進行增強處理,再從修改過的位元組代碼中定義出Java類。
- 通過JDK 5引入的java.lang.instrument包來完成。
java.lang.instrument
由於存在著大量對Java位元組代碼進行修改的需求,JDK 5引入了java.lang.instrument包並在JDK 6中得到了進一步的增強。基本的思路是在JVM啟動的時候添加一些代理(agent)。每個代理是一個jar包,其清單(manifest)檔案中會指定一個代理類。這個類會包含一個premain方法。JVM在啟動的時候會首先執行代理類的premain方法,再執行Java程式本身的main方法。在 premain方法中就可以對程式本身的位元組代碼進行修改。JDK 6中還允許在JVM啟動之後動態添加代理。java.lang.instrument包支援兩種修改的情境,一種是重定義一個Java類,即完全替換一個 Java類的位元組代碼;另外一種是轉換已有的Java類,相當於前面提到的類位元組代碼增強。還是以前面提到的輸出方法執行日誌的情境為例,首先需要實現java.lang.instrument.ClassFileTransformer介面來完成對已有Java類的轉換。
static class MethodEntryTransformer implements ClassFileTransformer { public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ?ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException { try { ClassReader cr = new ClassReader(classfileBuffer); ClassNode cn = new ClassNode(); //省略使用ASM進行位元組代碼轉換的代碼 ClassWriter cw = new ClassWriter(0); cn.accept(cw); return cw.toByteArray(); } catch (Exception e){ return null; } }}
有了這個轉換類之後,就可以在代理的premain方法中使用它。
public static void premain(String args, Instrumentation inst) { inst.addTransformer(new MethodEntryTransformer());}
把該代理類打成一個jar包,並在jar包的資訊清單檔中通過Premain-Class聲明代理類的名稱。運行Java程式的時候,添加JVM啟動參數-javaagent:myagent.jar。這樣的話,JVM會在載入Java類的位元組代碼之前,完成相關的轉換操作。
總結
操縱Java位元組代碼是一件很有趣的事情。通過它,可以很容易的對二進位分發的Java程式進行修改,非常適合於效能分析、調試跟蹤和日誌記錄等任務。另外一個非常重要的作用是把開發人員從繁瑣的Java文法中解放出來。開發人員應該只需要負責編寫與商務邏輯相關的重要代碼。對於那些只是因為文法要求而添加的,或是模式固定的代碼,完全可以將其位元組代碼動態產生出來。位元組代碼增強和原始碼產生是不同的概念。原始碼產生之後,就已經成為了程式的一部分,開發人員需要去維護它:要麼手工修改產生出來的原始碼,要麼重建。而位元組代碼的增強過程,對於開發人員是完全透明的。妥善使用Java位元組代碼的操縱技術,可以更好的解決某一類開發問題。
轉自:http://www.infoq.com/cn/articles/cf-java-byte-code 作者 成富