Class檔案格式實戰:使用ASM動態生成class檔案
概述
本專欄前面的文章,主要詳細講解了Class檔案的格式,並且在上一篇文章中做了總結。 眾所周知, JVM在執行時, 載入並執行class檔案, 這個class檔案基本上都是由我們所寫的java原始檔通過javac編譯而得到的。 但是, 我們有時候會遇到這種情況:在前期(編寫程式時)不知道要寫什麼類, 只有到執行時, 才能根據當時的程式執行狀態知道要使用什麼類。 舉一個常見的例子就是JDK中的動態代理。這個代理能夠使用一套API代理所有的符合要求的類, 那麼這個代理就不可能在JDK編寫的時候寫出來, 因為當時還不知道使用者要代理什麼類。
當遇到上述情況時, 就要考慮這種機制:在執行時動態生成class檔案。 也就是說, 這個class檔案已經不是由你的Java原始碼編譯而來,而是由程式動態生成。 能夠做這件事的,有JDK中的動態代理API, 還有一個叫做cglib的開源庫。 這兩個庫都是偏重於動態代理的, 也就是以動態生成class的方式來支援代理的動態建立。 除此之外, 還有一個叫做ASM的庫, 能夠直接生成class檔案,它的api對於動態代理的API來說更加原生, 每個api都和class檔案格式中的特定部分相吻合, 也就是說, 如果對class檔案的格式比較熟練, 使用這套API就會相對簡單。 下面我們通過一個例項來講解ASM的使用, 並且在使用的過程中, 會對應class檔案中的各個部分來說明。
ASM示例:HelloWorld
ASM的實現基於一套Java API, 所以我們首先得到ASM庫, 在這個我使用的是ASM 4.0的jar包 。
首先以ASM中的HelloWorld例項來講解, 比如我們要生成以下程式碼對應的class檔案:
public class Example { public static void main (String[] args) { System.out.println("Hello world!"); }
但是這個class檔案不能在開發時通過上面的原始碼來編譯成, 而是要動態生成。 下面我們介紹如何使用ASM動態生成上述原始碼對應的位元組碼。
下面是程式碼示例(該例項來自於ASM官方的sample):
import java.io.FileOutputStream; import org.objectweb.asm.ClassWriter; import org.objectweb.asm.MethodVisitor; import org.objectweb.asm.Opcodes; public class Helloworld extends ClassLoader implements Opcodes { public static void main(final String args[]) throws Exception { //定義一個叫做Example的類 ClassWriter cw = new ClassWriter(0); cw.visit(V1_1, ACC_PUBLIC, "Example", null, "java/lang/Object", null); //生成預設的構造方法 MethodVisitor mw = cw.visitMethod(ACC_PUBLIC, "<init>", "()V", null, null); //生成構造方法的位元組碼指令 mw.visitVarInsn(ALOAD, 0); mw.visitMethodInsn(INVOKESPECIAL, "java/lang/Object", "<init>", "()V"); mw.visitInsn(RETURN); mw.visitMaxs(1, 1); mw.visitEnd(); //生成main方法 mw = cw.visitMethod(ACC_PUBLIC + ACC_STATIC, "main", "([Ljava/lang/String;)V", null, null); //生成main方法中的位元組碼指令 mw.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;"); mw.visitLdcInsn("Hello world!"); mw.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V"); mw.visitInsn(RETURN); mw.visitMaxs(2, 2); //位元組碼生成完成 mw.visitEnd(); // 獲取生成的class檔案對應的二進位制流 byte[] code = cw.toByteArray(); //將二進位制流寫到本地磁碟上 FileOutputStream fos = new FileOutputStream("Example.class"); fos.write(code); fos.close(); //直接將二進位制流載入到記憶體中 Helloworld loader = new Helloworld(); Class<?> exampleClass = loader.defineClass("Example", code, 0, code.length); //通過反射呼叫main方法 exampleClass.getMethods()[0].invoke(null, new Object[] { null }); } }
下面詳細介紹生成class的過程:
1 首先定義一個類
相關程式碼片段如下:
//定義一個叫做Example的類 ClassWriter cw = new ClassWriter(0); cw.visit(V1_1, ACC_PUBLIC, "Example", null, "java/lang/Object", null);
ClassWriter類是ASM中的核心API , 用於生成一個類的位元組碼。 ClassWriter的visit方法定義一個類。
第一個引數V1_1是生成的class的版本號, 對應class檔案中的主版本號和次版本號, 即minor_version和major_version 。
第二個引數ACC_PUBLIC表示該類的訪問標識。這是一個public的類。 對應class檔案中的access_flags 。
第三個引數是生成的類的類名。 需要注意,這裡是類的全限定名。 如果生成的class帶有包名, 如com.jg.zhang.Example, 那麼這裡傳入的引數必須是com/jg/zhang/Example 。對應class檔案中的this_class 。
第四個引數是和泛型相關的, 這裡我們不關新, 傳入null表示這不是一個泛型類。這個引數對應class檔案中的Signature屬性(attribute) 。
第五個引數是當前類的父類的全限定名。 該類直接繼承Object。 這個引數對應class檔案中的super_class 。
第六個引數是String[]型別的, 傳入當前要生成的類的直接實現的介面。 這裡這個類沒實現任何介面, 所以傳入null 。 這個引數對應class檔案中的interfaces 。
2 定義預設構造方法, 並生成預設構造方法的位元組碼指令
相關程式碼片段如下:
//生成預設的構造方法 MethodVisitor mw = cw.visitMethod(ACC_PUBLIC, "<init>", "()V", null, null); //生成構造方法的位元組碼指令 mw.visitVarInsn(ALOAD, 0); mw.visitMethodInsn(INVOKESPECIAL, "java/lang/Object", "<init>", "()V"); mw.visitInsn(RETURN); mw.visitMaxs(1, 1); mw.visitEnd();
使用上面建立的ClassWriter物件, 呼叫該物件的visitMethod方法, 得到一個MethodVisitor物件, 這個物件定義一個方法。 對應class檔案中的一個method_info 。
第一個引數是 ACC_PUBLIC , 指定要生成的方法的訪問標誌。 這個引數對應method_info 中的access_flags 。
第二個引數是方法的方法名。 對於構造方法來說, 方法名為<init> 。 這個引數對應method_info 中的name_index , name_index引用常量池中的方法名字串。
第三個引數是方法描述符, 在這裡要生成的構造方法無引數, 無返回值, 所以方法描述符為 ()V 。 這個引數對應method_info 中的descriptor_index 。
第四個引數是和泛型相關的, 這裡傳入null表示該方法不是泛型方法。這個引數對應method_info 中的Signature屬性。
第五個引數指定方法宣告可能丟擲的異常。 這裡無異常宣告丟擲, 傳入null 。 這個引數對應method_info 中的Exceptions屬性。
接下來呼叫MethodVisitor中的多個方法, 生成當前構造方法的位元組碼。 對應method_info 中的Code屬性。
1 呼叫visitVarInsn方法,生成aload指令, 將第0個本地變數(也就是this)壓入運算元棧。
2 呼叫visitMethodInsn方法, 生成invokespecial指令, 呼叫父類(也就是Object)的構造方法。
3 呼叫visitInsn方法,生成return指令, 方法返回。
4 呼叫visitMaxs方法, 指定當前要生成的方法的最大區域性變數和最大運算元棧。 對應Code屬性中的max_stack和max_locals 。
5 最後呼叫visitEnd方法, 表示當前要生成的構造方法已經建立完成。
3 定義main方法, 並生成main方法中的位元組碼指令
對應的程式碼片段如下:
mw = cw.visitMethod(ACC_PUBLIC + ACC_STATIC, "main", "([Ljava/lang/String;)V", null, null); //生成main方法中的位元組碼指令 mw.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;"); mw.visitLdcInsn("Hello world!"); mw.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V"); mw.visitInsn(RETURN); mw.visitMaxs(2, 2); mw.visitEnd();
這個過程和上面的生成預設構造方法的過程是一致的。 讀者可對比上一步執行分析。
4 生成class資料, 儲存到磁碟中, 載入class資料
對應程式碼片段如下:
// 獲取生成的class檔案對應的二進位制流 byte[] code = cw.toByteArray(); //將二進位制流寫到本地磁碟上 FileOutputStream fos = new FileOutputStream("Example.class"); fos.write(code); fos.close(); //直接將二進位制流載入到記憶體中 Helloworld loader = new Helloworld(); Class<?> exampleClass = loader.defineClass("Example", code, 0, code.length); //通過反射呼叫main方法 exampleClass.getMethods()[0].invoke(null, new Object[] { null });
這段程式碼首先獲取生成的class檔案的位元組流, 把它寫在本地磁碟的Example.class檔案中。 然後載入class位元組流, 並通過反射呼叫main方法。
這段程式碼執行完, 可以看到控制檯有以下輸出:
Hello world!
然後在當前測試工程的根目錄下, 生成一個Example.class檔案檔案。
下面我們使用javap反編譯這個class檔案:
javap -c -v -classpath . -private Example
輸出的完整資訊如下:
Classfile /C:/Users/紀剛/Desktop/生成位元組碼/AsmJavaTest/Example.class Last modified 2014-4-5; size 338 bytes MD5 checksum 281abde0e2012db8ad462279a1fbb6a4 public class Example minor version: 3 major version: 45 flags: ACC_PUBLIC Constant pool: #1 = Utf8 Example #2 = Class #1 // Example #3 = Utf8 java/lang/Object #4 = Class #3 // java/lang/Object #5 = Utf8 <init> #6 = Utf8 ()V #7 = NameAndType #5:#6 // "<init>":()V #8 = Methodref #4.#7 // java/lang/Object."<init>":()V #9 = Utf8 main #10 = Utf8 ([Ljava/lang/String;)V #11 = Utf8 java/lang/System #12 = Class #11 // java/lang/System #13 = Utf8 out #14 = Utf8 Ljava/io/PrintStream; #15 = NameAndType #13:#14 // out:Ljava/io/PrintStream; #16 = Fieldref #12.#15 // java/lang/System.out:Ljava/io/PrintStream; #17 = Utf8 Hello world! #18 = String #17 // Hello world! #19 = Utf8 java/io/PrintStream #20 = Class #19 // java/io/PrintStream #21 = Utf8 println #22 = Utf8 (Ljava/lang/String;)V #23 = NameAndType #21:#22 // println:(Ljava/lang/String;)V #24 = Methodref #20.#23 // java/io/PrintStream.println:(Ljava/lang/String;)V #25 = Utf8 Code { public Example(); flags: ACC_PUBLIC Code: stack=1, locals=1, args_size=1 0: aload_0 1: invokespecial #8 // Method java/lang/Object."<init>":()V 4: return public static void main(java.lang.String[]); flags: ACC_PUBLIC, ACC_STATIC Code: stack=2, locals=2, args_size=1 0: getstatic #16 // Field java/lang/System.out:Ljava/io/PrintStream; 3: ldc #18 // String Hello world! 5: invokevirtual #24 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 8: return }
正是一個標準的class格式的檔案, 它和以下原始碼是對應的:
-
public class Example {
-
public static void main (String[] args) {
-
System.out.println("Hello world!");
-
}
只是, 上面的class檔案不是由這段原始碼生成的, 而是使用ASM動態建立的。
ASM示例二: 生成欄位, 並給欄位加註解
上面的HelloWorld示例演示瞭如何生成類和方法, 該示例演示如何生成欄位, 並給欄位加註解。
public class BeanTest extends ClassLoader implements Opcodes { /* * 生成以下類的位元組碼 * * public class Person { * * @NotNull * public String name; * * } */ public static void main(String[] args) throws Exception { /********************************class***********************************************/ // 建立一個ClassWriter, 以生成一個新的類 ClassWriter cw = new ClassWriter(0); cw.visit(V1_6, ACC_PUBLIC, "com/pansoft/espdb/bean/Person", null, "java/lang/Object", null); /*********************************constructor**********************************************/ MethodVisitor mw = cw.visitMethod(ACC_PUBLIC, "<init>", "()V", null, null); mw.visitVarInsn(ALOAD, 0); mw.visitMethodInsn(INVOKESPECIAL, "java/lang/Object", "<init>", "()V"); mw.visitInsn(RETURN); mw.visitMaxs(1, 1); mw.visitEnd(); /*************************************field******************************************/ //生成String name欄位 FieldVisitor fv = cw.visitField(ACC_PUBLIC, "name", "Ljava/lang/String;", null, null); AnnotationVisitor av = fv.visitAnnotation("LNotNull;", true); av.visit("value", "abc"); av.visitEnd(); fv.visitEnd(); /***********************************generate and load********************************************/ byte[] code = cw.toByteArray(); BeanTest loader = new BeanTest(); Class<?> clazz = loader.defineClass(null, code, 0, code.length); /***********************************test********************************************/ Object beanObj = clazz.getConstructor().newInstance(); clazz.getField("name").set(beanObj, "zhangjg"); String nameString = (String) clazz.getField("name").get(beanObj); System.out.println("filed value : " + nameString); String annoVal = clazz.getField("name").getAnnotation(NotNull.class).value(); System.out.println("annotation value: " + annoVal); } }
上面程式碼是完整的程式碼, 用於生成一個和以下程式碼相對應的class:
-
public class Person {
-
@NotNull
-
public String name;
-
}
生成類和構造方法的部分就略過了, 和上面的示例是一樣的。 下面看看欄位和欄位的註解是如何生成的。 相關邏輯如下:
FieldVisitor fv = cw.visitField(ACC_PUBLIC, "name", "Ljava/lang/String;", null, null); AnnotationVisitor av = fv.visitAnnotation("LNotNull;", true); av.visit("value", "abc"); av.visitEnd(); fv.visitEnd();
ClassWriter的visitField方法, 用於定義一個欄位。 對應class檔案中的一個filed_info 。
第一個引數是欄位的訪問修飾符, 這裡傳入ACC_PUBLIC表示是一個public的屬性。 這個引數和filed_info 中的access_flags相對應。
第二個引數是欄位的欄位名。 這個引數和filed_info 中的name_index相對應。
第三個引數是欄位的描述符, 這個欄位是String型別的,它的欄位描述符為 "Ljava/lang/String;" 。 這個引數和filed_info 中的descriptor_index相對應。
第四個引數和泛型相關的, 這裡傳入null, 表示該欄位不是泛型的。 這個引數和filed_info 中的Signature屬性相對應。
第五個引數是欄位的值, 只適用於靜態欄位,當前要生成的欄位不是靜態的, 所以傳入null 。 這個引數和filed_info 中的ConstantValue屬性相對應。
使用visitField方法定義完當前欄位, 返回一個FieldVisitor物件。 下面呼叫這個物件的visitAnnotation方法, 為該欄位生成註解資訊。 visitAnnotation的兩個引數如下:
第一個引數是要生成的註解的描述符, 傳入"LNotNull;" 。
第二個引數表示該註解是否執行時可見。 如果傳入true, 表示執行時可見, 這個註解資訊就會生成filed_info 中的一個RuntimeVisibleAnnotation屬性。 傳入false, 表示執行時不可見,個註解資訊就會生成filed_info 中的一個RuntimeInvisibleAnnotation屬性 。
接下來呼叫上一步返回的AnnotationVisitor物件的visit方法, 來生成註解的值資訊。
ClassWriter的其他重要方法
ClassWriter中還有其他一些重要方法, 這些方法能夠生成class檔案中的所有相關資訊。 這些方法, 以及物件生成class檔案中的什麼資訊, 都列在下面:
//定義一個類 public void visit( int version, int access, String name, String signature, String superName, String[] interfaces) //定義原始檔相關的資訊,對應class檔案中的Source屬性 public void visitSource(String source, String debug) //以下兩個方法定義內部類和外部類相關的資訊, 對應class檔案中的InnerClasses屬性 public void visitOuterClass(String owner, String name, String desc) public void visitInnerClass( String name, String outerName, String innerName, int access) //定義class檔案中的註解資訊, 對應class檔案中的RuntimeVisibleAnnotations屬性或者RuntimeInvisibleAnnotations屬性 public AnnotationVisitor visitAnnotation(String desc, boolean visible) //定義其他非標準屬性 public void visitAttribute(Attribute attr) //定義一個欄位, 返回的FieldVisitor用於生成欄位相關的資訊 public FieldVisitor visitField( int access, String name, String desc, String signature, Object value) //定義一個方法, 返回的MethodVisitor用於生成方法相關的資訊 public MethodVisitor visitMethod( int access, String name, String desc, String signature, String[] exceptions)
每個方法都是和class檔案中的某部分資料相對應的, 如果對class檔案的格式比較熟悉的話, 使用ASM生成一個簡單的類, 還是很容易的。
總結
在本文中, 通過使用開源的ASM庫, 動態生成了兩個類。 通過講解這兩個類的生成過程, 可以加深對class檔案格式的理解。 因為ASM庫中的每個API都是對應class檔案中的某部分資訊的。 如果對class檔案格式不熟悉, 可以參考本專欄之前的講解class檔案格式的一系列部落格。
本文使用的兩個示例都放在了一個單獨的, 可直接執行的工程中, 該工程已經上傳到我的百度網盤, 這個工程的lib目錄中, 有ASM 4.0的jar包。 和該工程一起打包的, 還有ASM 4.0的原始碼和示例程式。