通過 ASM 庫生成和修改 class 文件
在 JVM中 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 庫的介紹和使用
ASM 庫是一款基於 Java 位元組碼層面的程式碼分析和修改工具,那 ASM 和訪問者模式有什麼關係呢?訪問者模式主要用於修改和操作一些數據結構比較穩定的數據,通過前面的學習,我們知道 .class 文件的結構是固定的,主要有常量池、欄位表、方法表、屬性表等內容,通過使用訪問者模式在掃描 .class 文件中各個表的內容時,就可以修改這些內容了。在學習 ASM 之前,可以通過深入淺出訪問者模式 這篇文章學習一下訪問者模式。
ASM 可以直接生產二進位的 .class 文件,也可以在類被載入入 JVM 之前動態修改類行為。下文將通過兩個例子,分別介紹如何生成一個 class 文件和修改 Java 類中方法的位元組碼。
在剛開始使用的時候,可能對位元組碼的執行不是很清楚,使用 ASM 會比較困難,ASM 官方也提供了一個幫助工具 ASMifier,我們可以先寫出目標程式碼,然後通過 javac 編譯成 .class 文件,然後通過 ASMifier 分析此 .class 文件就可以得到需要插入的程式碼對應的 ASM 程式碼了。
ASM 生成 class 文件
下面簡單看一個 java 類:
package work; public class Example {public static void main(String[] var0) { System.out.println("createExampleClass"); } }
這個 Example 類很簡單,只有簡單的包名,加上一個靜態 main 方法,列印輸出 createExampleClass 。
現在問題來了,你如何生成這個 Example.java 的 class 文件,不能在開發時通過上面的源碼來編譯成, 而是要動態生成。
下面開始介紹如何使用 ASM 動態生成上述源碼對應的位元組碼。
程式碼示例
public class Main extends ClassLoader { // 此處記得替換成自己的文件地址 public static final String PATH = "/Users/xxx/IdeaProjects/untitled/src/work/"; public static void main(String[] args) { createExampleClass(); } private static void createExampleClass() { ClassWriter cw = new ClassWriter(0); // 定義一個叫做Example的類,並且這個類是在 work 目錄下面 cw.visit(V1_8, ACC_PUBLIC + ACC_SUPER, "work/Example", null, "java/lang/Object", null); // 生成默認的構造方法 MethodVisitor mv = cw.visitMethod(ACC_PUBLIC, "<init>", "()V", null, null); // 生成構造方法的位元組碼指令 mv.visitVarInsn(ALOAD, 0); mv.visitMethodInsn(INVOKESPECIAL, "java/lang/Object", "<init>", "()V", false); mv.visitInsn(RETURN); mv.visitMaxs(1, 1); // 構造函數訪問結束 mv.visitEnd(); // 生成main方法中的位元組碼指令 mv = cw.visitMethod(ACC_PUBLIC + ACC_STATIC, "main", "([Ljava/lang/String;)V", null, null); mv.visitCode(); // 獲取該方法 mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;"); // 載入字元串參數 mv.visitLdcInsn("createExampleClass"); // 調用該方法 mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false); mv.visitInsn(RETURN); mv.visitMaxs(2, 1); mv.visitEnd(); // 獲取生成的class文件對應的二進位流 byte[] code = cw.toByteArray(); // 將二進位流寫到本地磁碟上 FileOutputStream fos = null; try { fos = new FileOutputStream(PATH + "Example.class"); fos.write(code); System.out.println(fos.getFD()); fos.close(); } catch (Exception e) { System.out.print(" FileOutputStream error " + e.getMessage()); e.printStackTrace(); } loadclass("Example.class", "work.Example"); } private static void loadclass(String className, String packageNamePath) { //通過反射調用main方法 MyClassLoader myClassLoader = new MyClassLoader(PATH + className); // 類的全稱,對應包名 try { // 載入class文件 Class<?> Log = myClassLoader.loadClass(packageNamePath); System.out.println("類載入器是:" + Log.getClassLoader()); // 利用反射獲取main方法 Method method = Log.getDeclaredMethod("main", String[].class); String[] arg = {"ad"}; method.invoke(null, (Object) arg); } catch (Exception e) { e.printStackTrace(); } } }
為了證明表示我們生成的 class 可以正常調用,還需要將其載入,然後通過反射調用該類的方法,這樣才能說明生成的 class 文件是沒有問題並且可運行的。
下面是自定義的一個 class 載入類:
public class MyClassLoader extends ClassLoader { // 指定路徑 private String path; public MyClassLoader(String classPath) { path = classPath; } /** * 重寫findClass方法 * * @param name 是我們這個類的全路徑 * @return * @throws ClassNotFoundException */ @Override protected Class<?> findClass(String name) throws ClassNotFoundException { Class log = null; // 獲取該class文件位元組碼數組 byte[] classData = getData(); if (classData != null) { // 將class的位元組碼數組轉換成Class類的實例 log = defineClass(name, classData, 0, classData.length); } return log; } /** * 將class文件轉化為位元組碼數組 * * @return */ private byte[] getData() { File file = new File(path); if (file.exists()) { FileInputStream in = null; ByteArrayOutputStream out = null; try { in = new FileInputStream(file); out = new ByteArrayOutputStream(); byte[] buffer = new byte[1024]; int size = 0; while ((size = in.read(buffer)) != -1) { out.write(buffer, 0, size); } } catch (IOException e) { e.printStackTrace(); } finally { try { in.close(); } catch (IOException e) { e.printStackTrace(); } } return out.toByteArray(); } else { return null; } } }
程式碼詳解
下面詳細介紹生成class的過程:
首先定義一個類
ClassWriter cw = new ClassWriter(0); // 定義一個叫做Example的類,並且這個類是在 work 目錄下面 cw.visit(V1_8, ACC_PUBLIC + ACC_SUPER, "work/Example", null, "java/lang/Object", null);
ClassWriter 類是 ASM 中的核心 API , 用於生成一個類的位元組碼。 ClassWriter 的 visit 方法定義一個類。
-
第一個參數 V1_8 是生成的 class 的版本號, 對應class文件中的主版本號和次版本號, 即 minor_version 和 major_version 。
-
第二個參數ACC_PUBLIC表示該類的訪問標識。這是一個public的類。 對應class文件中的access_flags 。
-
第三個參數是生成的類的類名。 需要注意,這裡是類的全限定名。 如果生成的class帶有包名, 如com.jg.xxx.Example, 那麼這裡傳入的參數必須是com/jg/xxx/Example 。對應 class 文件中的 this_class 。
-
第四個參數是和泛型相關的, 這裡我們不關新, 傳入null表示這不是一個泛型類。這個參數對應class文件中的Signature屬性(attribute) 。
-
第五個參數是當前類的父類的全限定名。 該類直接繼承Object。 這個參數對應class文件中的super_class 。
-
第六個參數是 String[] 類型的, 傳入當前要生成的類的直接實現的介面。 這裡這個類沒實現任何介面, 所以傳入null 。 這個參數對應class文件中的interfaces 。
定義默認構造方法, 並生成默認構造方法的位元組碼指令
// 生成默認的構造方法 MethodVisitor mv = cw.visitMethod(ACC_PUBLIC, "<init>", "()V", null, null); // 生成構造方法的位元組碼指令 mv.visitVarInsn(ALOAD, 0); mv.visitMethodInsn(INVOKESPECIAL, "java/lang/Object", "<init>", "()V", false); mv.visitInsn(RETURN); mv.visitMaxs(1, 1); // 構造函數訪問結束 mv.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 屬性。
-
調用 visitVarInsn 方法,生成 aload 指令, 將第 0 個本地變數(也就是 this)壓入操作數棧。
-
調用 visitMethodInsn方法, 生成 invokespecial 指令, 調用父類(也就是 Object)的構造方法。
-
調用 visitInsn 方法,生成 return 指令, 方法返回。
-
調用 visitMaxs 方法, 指定當前要生成的方法的最大局部變數和最大操作數棧。 對應 Code 屬性中的 max_stack 和 max_locals 。
-
最後調用 visitEnd 方法, 表示當前要生成的構造方法已經創建完成。
定義main方法, 並生成main方法中的位元組碼指令
這裡與構造函數一樣,就不多說了。
生成class數據, 保存到磁碟中, 載入class數據
// 獲取生成的class文件對應的二進位流 byte[] code = cw.toByteArray(); // 將二進位流寫到本地磁碟上 FileOutputStream fos = null; try { fos = new FileOutputStream(PATH + "Example.class"); fos.write(code); fos.close(); } catch (Exception e) { System.out.print(" FileOutputStream error " + e.getMessage()); e.printStackTrace(); } loadclass("Example.class", "work.Example");
這段程式碼執行完, 可以看到控制台有以下輸出:
生成 ASM 程式碼
那麼還有個問題是前面的 ASM 程式碼是如何生成的呢?
還是以前文提到的 EXample.java 為例:
javac Example.java // 生成 Example class 文件 java -classpath asm-all-6.0_ALPHA.jar org.objectweb.asm.util.ASMifier Example.class // 利用 ASMifier 將class 文件轉為 asm 程式碼
在 Terminal 窗口中輸入這兩個命令,就可以得到下面的 asm 程式碼:
import java.util.*; import org.objectweb.asm.*; public class ExampleDump implements Opcodes { public static byte[] dump () throws Exception { ClassWriter cw = new ClassWriter(0); FieldVisitor fv; MethodVisitor mv; AnnotationVisitor av0; cw.visit(V1_8, ACC_PUBLIC + ACC_SUPER, "Example", null, "java/lang/Object", null); { mv = cw.visitMethod(ACC_PUBLIC, "<init>", "()V", null, null); mv.visitCode(); mv.visitVarInsn(ALOAD, 0); mv.visitMethodInsn(INVOKESPECIAL, "java/lang/Object", "<init>", "()V", false); mv.visitInsn(RETURN); mv.visitMaxs(1, 1); mv.visitEnd(); } { mv = cw.visitMethod(ACC_PUBLIC + ACC_STATIC, "main", "([Ljava/lang/String;)V", null, null); mv.visitCode(); mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;"); mv.visitLdcInsn("createExampleClass"); mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false); mv.visitInsn(RETURN); mv.visitMaxs(2, 1); mv.visitEnd(); } cw.visitEnd(); return cw.toByteArray(); } }
可以看到輸出結果與前面的生成的 class 文件的程式碼是一樣的。
到這裡,相信你對 ASM 的使用已經有了初步的了解了,當然可能不是很熟悉,但是多寫寫練練掌握格式就好多了。
利用 ASM 修改方法
下面介紹如何修改一個 class 文件的方法。
還是在原來的程式碼基礎上,Main 類下面新增一個方法 modifyMethod 方法,具體程式碼如下:
private static void modifyMethod() { byte[] code = null; try { // 需要注意把 . 變成 /, 比如 com.example.a.class 變成 com/example/a.class InputStream inputStream = new FileInputStream(PATH + "Example.class"); ClassReader reader = new ClassReader(inputStream); // 1. 創建 ClassReader 讀入 .class 文件到記憶體中 ClassWriter writer = new ClassWriter(reader, ClassWriter.COMPUTE_MAXS); // 2. 創建 ClassWriter 對象,將操作之後的位元組碼的位元組數組回寫 ClassVisitor change = new ChangeVisitor(writer); // 3. 創建自定義的 ClassVisitor 對象 reader.accept(change, ClassReader.EXPAND_FRAMES); code = writer.toByteArray(); System.out.println(code); FileOutputStream fos = new FileOutputStream(PATH + "Example.class"); fos.write(code); fos.close(); } catch (Exception e) { System.out.println("FileInputStream " + e.getMessage()); e.printStackTrace(); } try { if (code != null) { System.out.println(code); FileOutputStream fos = new FileOutputStream(PATH + "Example.class"); fos.write(code); fos.close(); } } catch (Exception e) { System.out.println("FileOutputStream "); e.printStackTrace(); } loadclass("Example.class", "work.Example"); }
新建一個 adapter,繼承自 AdviceAdapter,AdviceAdapter 本質也是一個 MethodVisitor,但是裡面對很多對方法的操作邏輯進行了封裝,使得我們不用關心 ASM 內部的訪問邏輯,只需要在對應的方法下面添加程式碼邏輯即可。
public class ChangeAdapter extends AdviceAdapter { private String methodName = null; ChangeAdapter(int api, MethodVisitor mv, int access, String name, String desc) { super(api, mv, access, name, desc); methodName = name; } @Override protected void onMethodEnter() { super.onMethodEnter(); Label l0 = new Label(); Label l1 = new Label(); Label l2 = new Label(); mv.visitTryCatchBlock(l0, l1, l2, "java/lang/InterruptedException"); mv.visitMethodInsn(INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false); // 把當前的時間戳存起來 mv.visitVarInsn(LSTORE, 1); mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;"); mv.visitLdcInsn("ChangeAdapter onMethodEnter "); mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false); mv.visitLabel(l0); mv.visitLdcInsn(new Long(100L)); mv.visitMethodInsn(INVOKESTATIC, "java/lang/Thread", "sleep", "(J)V", false); mv.visitLabel(l1); Label l3 = new Label(); mv.visitJumpInsn(GOTO, l3); mv.visitLabel(l2); mv.visitFrame(Opcodes.F_FULL, 2, new Object[] {"[Ljava/lang/String;", Opcodes.LONG}, 1, new Object[] {"java/lang/InterruptedException"}); mv.visitVarInsn(ASTORE, 3); mv.visitVarInsn(ALOAD, 3); mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/InterruptedException", "printStackTrace", "()V", false); mv.visitLabel(l3); mv.visitFrame(Opcodes.F_SAME, 0, null, 0, null); mv.visitMethodInsn(INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false); // 把當前的時間戳存起來 mv.visitVarInsn(LSTORE, 3); } @Override protected void onMethodExit(int opcode) { super.onMethodExit(opcode); mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;"); // 把之前存儲的時間戳取出來 mv.visitVarInsn(LLOAD, 3); mv.visitVarInsn(LLOAD, 1); mv.visitInsn(LSUB); mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(J)V", false); } @Override public void visitMaxs(int i, int i1) { super.visitMaxs(i, i1); }
在 adapter 中,有兩個非常重要的方法:
-
onMethodEnter:表示正在進入一個方法,在執行方法里的內容前會調用。因此,此處是對一個方法添加相關處理邏輯的很好的辦法。
-
onMethodExit:表示正在退出一個方法,在執行 return 之前。如果一個方法存在返回值,只能再該方法添加靜態方法。
public class ChangeVisitor extends ClassVisitor { ChangeVisitor(ClassVisitor classVisitor) { super(Opcodes.ASM5, classVisitor); } @Override public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) { MethodVisitor methodVisitor = super.visitMethod(access, name, desc, signature, exceptions); System.out.print(name); if (name.equals("main")) { return new ChangeAdapter(Opcodes.ASM4, methodVisitor, access, name, desc); } return methodVisitor; } }
ChangeVisitor 主要就是對 ASM 訪問 class 文件方法的時候,做個攔截。如果發現方法名是 main,就讓其走前面寫好的 ChangeAdapter,這樣,我們就可以改寫 class 文件的方法了。
運行結果
可以看到輸出結果,是 100 ms,成功的對 main 方法的耗時進行了計算。
如果方法帶有返回值
前面修改的 main 是沒有返回值的,那麼如果存在返回值?這麼寫還合適嗎?
如果你添加了非靜態方法的調用,去看生成的 class 文件也許可能是對的,但是在調用的時候就會報錯。示例如下:
protected void onMethodExit(int opcode) { mv.visitVarInsn(LLOAD, longT); mv.visitInsn(LSUB); mv.visitVarInsn(LSTORE, longT); mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;"); mv.visitLdcInsn("work2 createExampleClass"); mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false); mv.visitVarInsn(LLOAD, longT); }
這裡是調用了一些非靜態方法,接下去看生成的 class 文件:
從class 文件來看,生成的 class 文件是沒有問題的,結果在反射調用的時候報了異常:
通過 javap -c Example.class 將反編譯結果輸出如下:
$ javap -c Example.class public class work2.Example { public work2.Example(); Code: 0: aload_0 1: invokespecial #8 // Method java/lang/Object."<init>":()V 4: return public long computer(); Code: 0: ldc2_w #29 // long 32423l 3: lstore_1 4: getstatic #16 // Field java/lang/System.out:Ljava/io/PrintStream; 7: ldc #18 // String work2 createExampleClass 9: invokevirtual #24 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 12: invokestatic #27 // Method java/lang/System.currentTimeMillis:()J 15: lstore_2 16: invokestatic #27 // Method java/lang/System.currentTimeMillis:()J 19: lstore 4 21: lload 4 23: lload_2 24: lsub 25: lstore 6 27: lload 6 29: lload_1 30: lsub 31: lstore_1 32: getstatic #16 // Field java/lang/System.out:Ljava/io/PrintStream; 35: ldc #18 // String work2 createExampleClass 37: invokevirtual #24 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 40: lload_1 41: lreturn }
下面的是修改前的帶有返回值的反編譯結果:
$ javap -c Example.class public class work2.Example { public work2.Example(); Code: 0: aload_0 1: invokespecial #8 // Method java/lang/Object."<init>":()V 4: return public long computer(); Code: 0: ldc2_w #29 // long 32423l 3: lstore_1 4: getstatic #16 // Field java/lang/System.out:Ljava/io/PrintStream; 7: ldc #18 // String work2 createExampleClass 9: invokevirtual #24 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 12: invokestatic #27 // Method java/lang/System.currentTimeMillis:()J 15: lstore_2 16: invokestatic #27 // Method java/lang/System.currentTimeMillis:()J 19: lstore 4 21: lload 4 23: lload_2 24: lsub 25: lstore 6 27: lload 6 29: lreturn }
可以發現 27 行前面的程式碼都是一樣的,27 後面我們嘗試修改 class 文件,同時替換返回值,但是最終還是失敗了。這裡原因我沒有去尋找,應該就是我們的修改導致堆棧資訊存在變化,從而導致校驗失敗。
如果我們實在需要對帶有返回值的返回值進行修改,可以參考下面的實例,使用靜態方法:
protected void onMethodExit(int opcode) { super.onMethodExit(opcode); mv.visitLdcInsn("main"); mv.visitMethodInsn(INVOKESTATIC, "work2/Main", "test", "(Ljava/lang/String;)V", false); mv.visitLdcInsn("ssss"); mv.visitMethodInsn(INVOKESTATIC, "work2/Main", "test", "(Ljava/lang/String;)V", false); }
可以從 INVOKESTATIC 關鍵字看出,這些都是靜態方法。
到這裡,關於 ASM 使用說明到這裡就結束了。
源碼已上傳到 CSDN : ASM-demo.zip 。