ASM入門篇

語言: CN / TW / HK

ASM簡介

ASM是一個通用的Java位元組碼操作和分析框架,它可以用來修改現有的類或直接以二進位制形式動態生成類。ASM提供了一些常見的位元組碼轉換和分析演算法,從中可以構建定製的複雜轉換和程式碼分析工具。ASM提供了與其他Java位元組碼框架類似的功能,但側重於效能。因為它的設計和實現都儘可能小和快,所以它非常適合在動態系統中使用(當然也可以以靜態方式使用,例如在編譯器中)。

ASM被用在很多專案中,包括如下:

  • OpenJDK,生成lambda呼叫站點,以及Nashorn編譯器;
  • Groovy編譯器和Kotlin編譯器;
  • Cobertura和Jacoco,以工具化類來度量程式碼覆蓋率;
  • CGLIB,用於動態生成代理類;
  • Gradle,在執行時生成一些類;

更多參考官網:https://asm.ow2.io/

IDE外掛

ASM是直接對位元組碼進行操作,如果不熟悉位元組碼操作集合的話,寫起來會很費勁,所以ASM為主流的IDE專門提供了開發外掛BytecodeOutline:

以IDEA為例,只需要對應的類中右擊->Show Bytecode outline即可,大致如下圖所示:

image-20210608154029529.png

面板中包含三個頁籤:

  • Bytecode:類對應的位元組碼檔案;
  • ASMified:利用ASM生成位元組碼對應的程式碼;
  • Groovified:類對應的位元組碼指令;

ASM API

ASM庫提供了兩個用於生成和轉換已編譯類的API,一個是核心API,以基於事件的形式來表示類;另一個是樹API,以基於物件的形式來表示類;可以對比XML檔案解析的方式:SAX模式和DOM模式;核心API對應SAX模式,樹API對應DOM模式;每種模式都有自己的優缺點:

  • 基於事件的API要快於基於物件的API,所需要的記憶體也較少,但在使用基於事件的API時,類轉換的實現可能要更難一些;
  • 基於物件的API會把整個類載入到記憶體中;

ASM庫組織在幾個包中,這些包分佈在幾個單獨的JAR檔案中:

  • org.objectweb.asmorg.objectweb.asm.signature包:定義基於事件的API並提供類解析器和編寫器元件,它們包含在asm.jar中;
  • org.objectweb.asm.util包:提供基於核心API的各種工具,這些工具可在ASM應用程式的開發和除錯過程中使用,包含在asm-util.jar中;
  • org.objectweb.asm.commons包:提供了幾個有用的預定義類轉換器,主要基於核心API,包含在asm-commons.jar中;
  • org.objectweb.asm.tree包:定義基於物件的API,並提供用於在基於事件的表示和基於物件的表示之間進行轉換的工具,包含在asm-tree.jar 中;
  • org.objectweb.asm.tree.analysis包:包提供了一個基於樹API的類分析框架和幾個預定義的類分析器,包含在asm-analysis.jar中;

核心API

在學習核心API之前,建議瞭解一下訪問者模式,因為ASM對位元組碼的操作和分析都是基於訪問者模式來實現的;

訪問者模式

訪問者模式建議將新行為放入一個名為訪問者的獨立類中, 而不是試圖將其整合到已有類中。現在, 需要執行操作的原始物件將作為引數被傳遞給訪問者中的方法, 讓方法能訪問物件所包含的一切必要資料;常見的應用場景:

  • 如果你需要對一個複雜物件結構 (例如物件樹) 中的所有元素執行某些操作, 可使用訪問者模式;
  • 可使用訪問者模式來清理輔助行為的業務邏輯;
  • 當某個行為僅在類層次結構中的一些類中有意義, 而在其他類中沒有意義時, 可使用該模式;

位元組碼其實就是一個複雜的物件結構,還有像Sharding-Jdbc中對sql的解析也用到訪問者模式,可以發現都是一些資料結構比較穩定的資料,固定的語法;

更多參考:訪問者模式

訪問者模式有兩個核心類分別是:獨立的訪問者、接收訪問者事件產生器;對應的ASM裡面就是兩個核心類:ClassVisitorClassReader,下面分別進行介紹;

ClassVisitor

用於生成和轉換編譯類的ASM API基於ClassVisitor抽象類,這個類中的每個方法都對應於同名的類檔案結構:

public abstract class ClassVisitor {
    public ClassVisitor(int api);
    public ClassVisitor(int api, ClassVisitor cv);
    public void visit(int version, int access, String name,String signature, String superName, String[] interfaces);
    public void visitSource(String source, String debug);
    public void visitOuterClass(String owner, String name, String desc);
    AnnotationVisitor visitAnnotation(String desc, boolean visible);
    public void visitAttribute(Attribute attr);
    public void visitInnerClass(String name, String outerName,String innerName, int access);
    public FieldVisitor visitField(int access, String name, String desc,String signature, Object value);
    public MethodVisitor visitMethod(int access, String name, String desc,String signature, String[] exceptions);
    void visitEnd();
}

內容可以具有任意長度和複雜性的部件將通過返回輔助訪問者類,主要包括:AnnotationVisitorFieldVisitorMethodVisitor;更多可以參考Java 虛擬機器規範

以上所有方法都會被事件產生器ClassReader呼叫,所有方法中的引數都是ClassReader提供的,當然呼叫每個方法是有順序的:

visit visitSource? visitOuterClass? ( visitAnnotation | visitAttribute )* ( visitInnerClass | visitField |visitMethod )* visitEnd

首先呼叫visit,然後是對visitSource 的最多一個呼叫,接下來是對visitOuterClass 的最多一個呼叫 , 然後是可按任意順序對 visitAnnotationvisitAttribute的任意多個訪問 , 接下來是可按任意順序對 visitInnerClassvisitFieldvisitMethod 的任意多個呼叫,最後以一個visitEnd呼叫結束。

ClassReader

此類主要功能就是讀取位元組碼檔案,然後把讀取的資料通知ClassVisitor,位元組碼檔案可以多種方式傳入:

  • public ClassReader(final InputStream inputStream):位元組流的方式;
  • public ClassReader(final String className):檔案全路徑;
  • public ClassReader(final byte[] classFile):二進位制檔案;

常見使用方式如下所示:

ClassReader classReader = new ClassReader("com/zh/asm/TestService");
ClassWriter classVisitor = new ClassWriter(ClassWriter.COMPUTE_MAXS);
classReader.accept(classVisitor, 0);

ClassReaderaccept方法處理接收一個訪問者,還包括另外一個parsingOptions引數,選項包括:

  • SKIP_CODE:跳過已編譯程式碼的訪問(如果您只需要類結構,這可能很有用);
  • SKIP_DEBUG:不訪問除錯資訊,也不為其建立人工標籤;
  • SKIP_FRAMES:跳過堆疊對映幀;
  • EXPAND_FRAMES:解壓縮這些幀;

ClassWriter

以上例項中使用了ClassWriter,其繼承於ClassVisitor,主要用來生成類,可以單獨使用,如下所示:

ClassWriter cw = new ClassWriter(0);
cw.visit(V1_5, ACC_PUBLIC + ACC_ABSTRACT + ACC_INTERFACE,"pkg/Comparable", null, "java/lang/Object",new String[]{"pkg/Mesurable"});
cw.visitField(ACC_PUBLIC + ACC_FINAL + ACC_STATIC, "LESS","I", null, new Integer(-1)).visitEnd();
cw.visitField(ACC_PUBLIC + ACC_FINAL + ACC_STATIC, "EQUAL","I", null, new Integer(0)).visitEnd();
cw.visitField(ACC_PUBLIC + ACC_FINAL + ACC_STATIC, "GREATER","I", null, new Integer(1)).visitEnd();
cw.visitMethod(ACC_PUBLIC + ACC_ABSTRACT, "compareTo","(Ljava/lang/Object;)I", null, null).visitEnd();
cw.visitEnd();
byte[] b = cw.toByteArray();

//輸出
FileOutputStream fileOutputStream = new FileOutputStream(new File("F:/asm/Comparable.class"));
fileOutputStream.write(b);
fileOutputStream.close();

以上通過ClassWriter生成一個位元組碼檔案,然後轉換成位元組陣列,最後通過FileOutputStream輸出到檔案中,反編譯結果如下:

package pkg;

public interface Comparable extends Mesurable {
    int LESS = -1;
    int EQUAL = 0;
    int GREATER = 1;

    int compareTo(Object var1);
}

在例項化ClassWriter需要提供一個引數flags,選項包括:

  • COMPUTE_MAXS:將為你計算區域性變數與運算元棧部分的大小;還是必須呼叫 visitMaxs,但可以使用任何引數:它們將被忽略並重新計算;使用這一選項時,仍然必須自行計算這些幀;
  • COMPUTE_FRAMES:一切都是自動計算;不再需要呼叫 visitFrame,但仍然必須呼叫 visitMaxs(引數將被忽略並重新計算);
  • 0:不會自動計算任何東西;必須自行計算幀、區域性變數與運算元棧的大小;

以上只是對ClassWriter的單獨使用,但更有意義的其實是把以上三個核心類整合起來使用,下面重點看看轉換操作;

轉換操作

在類讀取器和類寫入器之間引入一個 ClassVisitor,把三者整合起來,大致程式碼結構如下所示:

ClassReader classReader = new ClassReader("com/zh/asm/TestService");
ClassWriter classWriter = new ClassWriter(ClassWriter.COMPUTE_MAXS);
//處理
ClassVisitor classVisitor = new AddFieldAdapter(classWriter...);
classReader.accept(classVisitor, 0);

上述程式碼相對應的體系結構如下圖所示:

image-20210609172035340.png

這裡提供了一個新增屬性的介面卡,可以重寫visitEnd方法,然後寫入新的屬性,程式碼如下:

public class AddFieldAdapter extends ClassVisitor {
    private int fAcc;
    private String fName;
    private String fDesc;
    //是否已經有相同名稱的屬性
    private boolean isFieldPresent;

    public AddFieldAdapter(ClassVisitor cv, int fAcc, String fName,
                           String fDesc) {
        super(ASM4, cv);
        this.fAcc = fAcc;
        this.fName = fName;
        this.fDesc = fDesc;
    }

    @Override
    public FieldVisitor visitField(int access, String name, String desc,
                                   String signature, Object value) {
        //判斷是否有相同名稱的欄位,不存在才會在visitEnd中新增
        if (name.equals(fName)) {
            isFieldPresent = true;
        }
        return cv.visitField(access, name, desc, signature, value);
    }

    @Override
    public void visitEnd() {
        if (!isFieldPresent) {
            FieldVisitor fv = cv.visitField(fAcc, fName, fDesc, null, null);
            if (fv != null) {
                fv.visitEnd();
            }
        }
        cv.visitEnd();
    }
}

根據ClassVisitor的每個方法被呼叫的順序,如果類中有多個屬性,那麼visitField會被呼叫多次,每次都會檢查要新增的欄位是否已經有了,然後儲存在isFieldPresent標識中,這樣在訪問最後的visitEnd中判斷是否需要新增新屬性;

ClassVisitor classVisitor = new AddFieldAdapter(classWriter,ACC_PUBLIC + ACC_FINAL + ACC_STATIC,"id","I");

這裡添加了一個public static final int id;可以把位元組陣列寫入class類檔案中,然後反編譯檢視:

public class TestService {
    public static final int id;
    ......
}

工具類

除了上面幾個核心類之外,ASM也提供了一些工具類,方便使用者使用:

  • Type Type物件表示一種 Java型別,既可以由型別描述符構造,也可以由Class物件構建;Type類還包含表示基元型別的靜態變數;
  • TraceClassVisitor 擴充套件了ClassVisitor類,並構建了所訪問類的文字表示;使用TraceClassVisitor以便獲得實際生成內容的可讀跟蹤;
  • CheckClassAdapter ClassWriter 類並不會核實對其方法的呼叫順序是否恰當,以及引數是否有效;因此有可能會生成一些被 Java 虛擬機器驗證器拒絕的無效類。為了儘可能提前檢測出部分此類錯誤,可以使用CheckClassAdapter類 ;
  • ASMifier 這個類為TraceClassVisitor工具提供了一個可選的後端(預設情況下,它使用一個Textifier後端,產生上面顯示的輸出型別)。這個後端使TraceClassVisitor類的每個方法列印用於呼叫它的Java程式碼。

方法

在介紹上面的ClassVisitor在訪問複雜性的部件將通過返回輔助訪問者類,其中包括:AnnotationVisitorFieldVisitorMethodVisitor;在介紹MethodVisitor之前瞭解一下Java 虛擬機器執行模型;

執行模型

每個方法被執行的時候,Java虛擬機器都會同步建立一個棧幀(Stack Frame)用於儲存區域性變量表、運算元棧、動態連線、方法出口等信 息。每一個方法被呼叫直至執行完畢的過程,就對應著一個棧幀在虛擬機器棧中從入棧到出棧的過程;

  • 區域性變量表:包含可由其索引以隨機順序訪問的變數;
  • 運算元棧:位元組碼指令用作運算元的值堆疊;

看一個具有3幀的執行棧:

image-20210610102350747.png

第一幀:包含3個區域性變數,運算元棧最大值為4,包含2個值;

第二幀:包含2個區域性變數,運算元棧最大值為3,包含2個值;

第三幀:包含4個區域性變數,運算元棧最大值為2,包含2個值;

位元組程式碼指令

位元組碼指令由標識該指令的操作碼和固定數量的引數組成:

  • 操作碼:是一個無符號位元組值,由助記符號標識。例如,操作碼值0由助記符NOP設計,並對應於不執行任何操作的指令。
  • 引數:是靜態值,確定了精確的指令行為。它們緊跟在操作碼之後給出。

位元組碼指令分為兩類:

  • 一小部分指令用於將值從區域性變數轉移到運算元堆疊;
  • 其他指令只作用於運算元堆疊:它們從堆疊中彈出一些值,根據這些值計算結果,然後將其推回堆疊;

區域性變數指令:

  • ILOAD:用於載入一個 boolean、byte、 char、short 或int 區域性變數;
  • LLOAD, FLOAD, DLOAD :分別用於載入 long、float 或 double值;
  • ALOAD:用於載入任意非基元值,即物件和陣列引用;

運算元棧指令:

  • ISTORE:從運算元棧中彈出一個boolean、byte、 char、short 或int 區域性變數值,並將它儲存在由其索引i指定的區域性變數中;
  • LSTORE,FSTORE,DSTORE:分別彈出 long、float 或 double值;
  • ASTORE:用於彈出任意非基元值;
  • GETFIELDPUTFIELDGETFIELD owner name desc彈出一個物件引用,並推送其name欄位的值; PUTFIELD owner name desc彈出一個值和一個物件引用,並將該值儲存在其name欄位中; 在這兩種情況下,物件必須是owner型別,其欄位必須是desc型別。GETSTATICPUTSTATIC是類似的指令,但是對於靜態欄位。
  • INVOKEVIRTUAL、INVOKESTATIC、INVOKESPECIAL、INVOKEINTERFACE、INVOKEDYNAMICINVOKEVIRTUAL owner name desc呼叫類owner中定義的name方法,其方法描述符為descINVOKESTATIC用於靜態方法,INVOKESPECIAL用於私有方法和建構函式,INVOKEINTERFACE用於介面中定義的方法。最後,對於java7類,INVOKEDYNAMIC用於新的動態方法呼叫機制。

MethodVisitor

用於生成和轉換已編譯方法的ASM API是基於MethodVisitor抽象類的;它由ClassVisitorvisitMethod方法返回;此類還根據這些指令的引數數量和型別為每個位元組碼指令類別定義了一個方法;必須按以下順序呼叫這些方法:

visitAnnotationDefault? ( visitAnnotation | visitParameterAnnotation | visitAttribute )*( visitCode( visitTryCatchBlock | visitLabel | visitFrame | visitXxx Insn |visitLocalVariable | visitLineNumber )*visitMaxs )?visitEnd

下面看一個對現有方法進行轉換例項,給方法新增開始和結束日誌;

  1. 準備需要被轉換的例項,在query方法處理前和處理後新增日誌;

    public class TestService {
    	public void query(int param) {
    		System.out.println("service handle...");
    	}
    }
    
  2. 重寫ClassVisitor中的visitMethod

    public class MyClassVisitor extends ClassVisitor implements Opcodes {
        public MyClassVisitor(ClassVisitor cv) {
            super(ASM5, cv);
        }
    
        @Override
        public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
            MethodVisitor methodVisitor = cv.visitMethod(access, name, desc, signature,
                    exceptions);
            if (!name.equals("<init>") && methodVisitor != null) {
                methodVisitor = new MyMethodVisitor(methodVisitor);
            }
            return methodVisitor;
        }
    }
    

    過濾掉<init>方法,其他方法都會被MyMethodVisitor包裝,然後重寫MethodVisitor的方法;

  3. 過載MethodVisitor

    public class MyMethodVisitor extends MethodVisitor implements Opcodes {
        public MyMethodVisitor(MethodVisitor mv) {
            super(Opcodes.ASM4, mv);
        }
    
        @Override
        public void visitCode() {
            super.visitCode();
            mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
            mv.visitLdcInsn("start");
            mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
        }
    
        @Override
        public void visitInsn(int opcode) {
            if ((opcode >= Opcodes.IRETURN && opcode <= Opcodes.RETURN)
                    || opcode == Opcodes.ATHROW) {
                //方法在返回之前列印"end"
                mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
                mv.visitLdcInsn("end");
                mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
            }
            mv.visitInsn(opcode);
        }
    }
    

    visitCode方法訪問之前呼叫,visitInsn需要判斷操作符是不是方法返回,一般方法在返回之前會執行mv.visitInsn(RETURN)操作,這時候可以通過opcode來判斷;

  4. 檢視生成的新的位元組碼檔案

    public class TestService {
        public TestService() {
        }
    
        public void query(int var1) {
            System.out.println("start");
            System.out.println("service handle...");
            System.out.println("end");
        }
    }
    

工具類

在方法下面也同樣提供了一些工具類:

  • LocalVariablesSorter:此方法介面卡將一個方法中使用的區域性變數按照它們在這個方法中的出現順序重新進行編號,同時可以使用 newLocal 方法建立一個新的區域性變數;
  • AdviceAdapter:此方法介面卡是一個抽象類,可用於在方法的開頭以及任何RETURNATHROW指令之前插入程式碼;其主要優點是它也適用於建構函式,其中程式碼不能僅插入建構函式的開頭,而是在呼叫超級建構函式之後插入。

使用場景

ASM被用在很多專案中,這裡介紹兩種常見的使用場景:AOP和代替反射;

AOP

面向切面程式設計,在程式開發中主要用來解決一些系統層面上的問題,比如日誌,事務,許可權等待;其中關鍵技術就是代理,代理包括動態代理和靜態代理,實現的方式也有多種:

  • AspectJ:屬於靜態織入,原理是靜態代理;
  • JDK動態代理:JDK動態代理兩個核心類:ProxyInvocationHandler
  • Cglib動態代理:封裝了ASM,可以再執行期動態生成新的Class;功能上比JDK動態代理更強大;

其中的Cglib動態代理方式就依賴ASM,上面的例項中我們也看到了ASM的位元組碼增強功能;

代替反射

FastJson以速度快著稱,其中有一項就是使用ASM代替了Java反射;另外還有ReflectASM包專門用來代替Java反射;

ReflectASM 是一個非常小的 Java 類庫,通過程式碼生成來提供高效能的反射處理,自動為 get/set 欄位提供訪問類,訪問類使用位元組碼操作而不是 Java 的反射技術,因此非常快。

看一段ReflectASM簡單使用方式:

TestBean testBean = new TestBean(1, "zhaohui", 18);
MethodAccess methodAccess = MethodAccess.get(TestBean.class);
String[] mns = methodAccess.getMethodNames();

for (int i = 0; i < mns.length; i++) {
    System.out.println(methodAccess.invoke(testBean, mns[i]));
}

這裡正常列印TestBean中的屬性值,為什麼速度快,因為內部會通過ASM生成一個臨時的TestBeanMethodAccess,內部重寫了invoke方法,反編譯之後如下所示:

public Object invoke(Object var1, int var2, Object... var3) {
        TestBean var4 = (TestBean)var1;
        switch(var2) {
        case 0:
            return var4.getName();
        case 1:
            return var4.getId();
        case 2:
            return var4.getAge();
        default:
            throw new IllegalArgumentException("Method not found: " + var2);
        }
 }

可以發現invoke裡面其實就是普通的呼叫,速度肯定比使用java反射快。

參考文件

asm4-guide.pdf

ASM4手冊中文版

感謝關注

可以關注微信公眾號「回滾吧程式碼」,第一時間閱讀,文章持續更新;專注Java原始碼、架構、演算法和麵試。