Java深度理解——Java字節代碼的操縱


導讀:Java做爲業界應用最爲普遍的語言之一,深得衆多軟件廠商和開發者的推崇,更是被包括Oracle在內的衆多JCP成員積極地推進發展。可是對於
Java語言的深度理解和運用,畢竟是不多會有人涉及的話題。InfoQ中文站特意邀請IBM高級工程師成富爲你們撰寫這個《Java深度歷險》專欄,旨在就Java的一些深度和高級特性分享他的經驗。 java


在通常的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 {     ide


      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); this


     
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字節代碼的操縱技術,能夠更好的解決某一類開發問題。

相關文章
相關標籤/搜索