天天看點

JVM之——位元組代碼的操縱

在一般的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類的代碼是儲存在一個字元串中的。

package com.lyz.jvm.compiler;

import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Arrays;

import javax.tools.JavaCompiler;
import javax.tools.JavaCompiler.CompilationTask;
import javax.tools.JavaFileObject;
import javax.tools.SimpleJavaFileObject;
import javax.tools.StandardJavaFileManager;
import javax.tools.ToolProvider;

/**
 * 動态編譯測試
 * @author liuyazhuang
 *
 */
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 sourceJavaObject = new StringSourceJavaObject("Main", source);
    Iterable<? extends JavaFileObject> fileObjects = Arrays.asList(sourceJavaObject);
    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;
    }
  }
}      
public static void premain(String args, Instrumentation inst) {
  inst.addTransformer(new MethodEntryTransformer());
}