ASM 簡介

前言

很早之前就寫過面向切面的編程思想,主要學習了AOP的思想(參考:AOP簡介)以及使用 AspectJ 實現簡單的切面編程(參考:AspectJ之切點語法)。

其他常見的AOP編程框架還有 CglibHibernateSpring 等等,而這些目前流行的AOP框架絕大多數底層實現都是直接或間接地通過 ASM 來實現字節碼操作。

因此,如果你想實現一些簡單的切面編程,直接采用上面提及的AOP框架是絕對可以實現的,但是這些框架相對于 ASM 來說重了許多,在你進行代碼切入的時候,可能會為你引入許多其他包的代碼,導致生成的class文件體積增大不少,因此,對于一些簡單的代碼切片,推薦使用 ASM 字節碼操作庫直接對class文件動態進行代碼切入。

ASM 簡介

ASM 是一個 Java 字節碼操控框架。它能被用來動態生成類或者增強既有類的功能。ASM 可以直接產生二進制 class 文件,也可以在類被加載入 Java 虛擬機之前動態改變類行為。Java class 被存儲在嚴格格式定義的 .class 文件里,這些類文件擁有足夠的元數據來解析類中的所有元素:類名稱、方法、屬性以及 Java 字節碼(指令)。ASM 從類文件中讀入信息后,能夠改變類行為,分析類信息,甚至能夠根據用戶要求生成新類。

簡單的說,ASM 可以讀取解析class文件內容,并提供接口讓你可以對class文件字節碼內容進行CRUD操作······

注: class文件存儲的是java字節碼,ASM 是對java字節碼操作的一層封裝,因此,如果你很了解 class文件格式的話,你甚至可以通過直接使用文本編輯器(eg:Vim)來改寫class文件。

知道了 ASM 的作用后,接下來我們就來看下 ASM 的執行模式,了解它的執行模式后,我們才能更好地使用。

ASM 框架執行流程

ASM 提供了兩組API:Core和Tree:

  • Core是基于訪問者模式來操作類的
  • Tree是基于樹節點來操作類的

本文我們主要討論的是 ASM 的 CoreAPI。

ASM 內部采用 訪問者模式.class 類文件的內容從頭到尾掃描一遍,每次掃描到類文件相應的內容時,都會調用ClassVisitor內部相應的方法。
比如:

  • 掃描到類文件時,會回調ClassVisitorvisit()方法;
  • 掃描到類注解時,會回調ClassVisitorvisitAnnotation()方法;
  • 掃描到類成員時,會回調ClassVisitorvisitField()方法;
  • 掃描到類方法時,會回調ClassVisitorvisitMethod()方法;
    ······
    掃描到相應結構內容時,會回調相應方法,該方法會返回一個對應的字節碼操作對象(比如,visitMethod()返回MethodVisitor實例),通過修改這個對象,就可以修改class文件相應結構部分內容,最后將這個ClassVisitor字節碼內容覆蓋原來.class文件就實現了類文件的代碼切入。

具體關系如下:

樹形關系 使用的接口
Class ClassVisitor
Field FieldVisitor
Method MethodVisitor
Annotation AnnotationVisitor

整個具體的執行時序如下圖所示:

ASM執行流程時序圖

通過時序圖可以看出ASM在處理class文件的整個過程。ASM通過樹這種數據結構來表示復雜的字節碼結構,并利用 Push模型 來對樹進行遍歷。

  • ASM 中提供一個ClassReader類,這個類可以直接由字節數組或者class文件間接的獲得字節碼數據。它會調用accept()方法,接受一個實現了抽象類ClassVisitor的對象實例作為參數,然后依次調用ClassVisitor的各個方法。字節碼空間上的偏移被轉成各種visitXXX方法。使用者只需要在對應的的方法上進行需求操作即可,無需考慮字節偏移。
  • 這個過程中ClassReader可以看作是一個事件生產者,ClassWriter繼承自ClassVisitor抽象類,負責將對象化的class文件內容重構成一個二進制格式的class字節碼文件,ClassWriter可以看作是一個事件的消費者。

至此,相信讀者已經對 ASM 框架的執行過程有一定了解了。接下來我們還剩的一點內容就是如何實現class文件字節碼的修改。

ASM 字節碼修改

由于 ASM 是直接對class文件的字節碼進行操作,因此,要修改class文件內容時,也要注入相應的java字節碼。

所以,在注入字節碼之前,我們還需要了解下class文件的結構,JVM指令等知識。

  1. class文件結構
    Java源文件經過javac編譯器編譯之后,將會生成對應的二進制.class文件,如下圖所示:
ASM – Javac 流程

Java類文件是 8 位字節的二進制流。數據項按順序存儲在class文件中,相鄰的項之間沒有間隔,這使得class文件變得緊湊,減少存儲空間。在Java類文件中包含了許多大小不同的項,由于每一項的結構都有嚴格規定,這使得 class 文件能夠從頭到尾被順利地解析。

每個class文件都是有固定的結構信息,而且保留了源碼文件中的符號。下圖是class文件的格式圖。其中帶 * 號的表示可重復的結構。

class文件結構圖
  • 類結構體中所有的修飾符、字符常量和其他常量都被存儲在class文件開始的一個常量堆棧(Constant Stack)中,其他結構體通過索引引用。

  • 每個類必須包含headers(包括:class name, super class, interface, etc.)和常量堆棧(Constant Stack)其他元素,例如:字段(fields)、方法(methods)和全部屬性(attributes)可以選擇顯示或者不顯示。

  • 每個字段塊(Field section)包括名稱、修飾符(public, private, etc.)、描述符號(descriptor)和字段屬性。

  • 每個方法區域(Method section)里面的信息與header部分的信息類似,信息關于最大堆棧(max stack)和最大本地變量數量(max local variable numbers)被用于修改字節碼。對于非abstract和非native的方法有一個方法指令表,exceptions表和代碼屬性表。除此之外,還可以有其他方法屬性。

  • 每個類、字段、方法和方法代碼的屬性有屬于自己的名稱記錄在類文件格式的JVM規范的部分,這些屬性展示了字節碼多方面的信息,例如源文件名、內部類、簽名、代碼行數、本地變量表和注釋。JVM規范允許定義自定義屬性,這些屬性會被標準的VM(虛擬機)忽略,但是可以包含附件信息。

  • 方法代碼表包含一系列對java虛擬機的指令。有些指令在代碼中使用偏移量,當指令從方法代碼被插入或者移除時,全部偏移量的值可能需要調整。

  1. Java類型與class文件內部類型對應關系
    Java類型分為基本類型和引用類型,在 JVM 中對每一種類型都有與之相對應的類型描述,如下表:
Java type JVM Type descriptor
boolean Z
char C
byte B
short S
int I
float F
long J
double D
Object Ljava/lang/Object;
int[] [I
Object[][] [[Ljava/lang/Object;

ASM 中要獲得一個類的 JVM 內部描述,可以使用org.objectweb.asm.Type類中的getDescriptor(final Class c)方法,如下:

public class TypeDescriptors {    
    public static void main(String[] args) {    
        System.out.println(Type.getDescriptor(TypeDescriptors.class));    
        System.out.println(Type.getDescriptor(String.class));    
    }        
}

運行結果:

Lorg/victorzhzh/core/structure/TypeDescriptors;    
Ljava/lang/String;    
  1. Java方法聲明與class文件內部聲明的對應關系
    在·Java·的二進制文件中,方法的方法名和方法的描述都是存儲在Constant pool 中的,且在兩個不同的單元里。因此,方法描述中不含有方法名,只含有參數類型和返回類型。

格式:(參數描述符)返回值描述符

Method declaration in source file Method descriptor
void m(int i, float f) (IF)V
int m(Object o) (Ljava/lang/Object;)I
int[] m(int i, String s) (ILjava/lang/String;)[I
Object m(int[] i) ([I]Ljava/lang/Object;
String m() ()Ljava/lang/String;
  1. JVM 指令
    假設現在我們有如下一個類:
package com.yn.test;
public class Test {
    public static void main(String[] agrs){
        System.out.println("Hello World!");
    }
}

我們先用javac com/yn/test/Test.java編譯得到Test.class文件,然后再使用javap -c com/yn/test/Test來查看下這個Test.class文件的字節碼,結果如下圖所示:

Test.class字節碼
  1. 上圖中第3行到第7行,是類Test的默認構造函數(由編譯器默認生成),Code以下部分是構造函數內部代碼,其中:
  • aload_0: 這個指令是LOAD系列指令中的一個,它的意思表示裝載當前第 0 個元素到堆棧中。代碼上相當于“this”。而這個數據元素的類型是一個引用類型。這些指令包含了:ALOAD,ILOAD,LLOAD,FLOAD,DLOAD。區分它們的作用就是針對不用數據類型而準備的LOAD指令,此外還有專門負責處理數組的指令 SALOAD。
  • invokespecial: 這個指令是調用系列指令中的一個。其目的是調用對象類的方法。后面需要給上父類的方法完整簽名。“#1”的意思是 .class 文件常量表中第1個元素。值為:“java/lang/Object."<init>":()V”。結合ALOAD_0。這兩個指令可以翻譯為:“super()”。其含義是調用自己的父類構造方法。
  1. 第9到14行是main方法,Code以下是其字節碼表示:
  • getstatic: 這個指令是GET系列指令中的一個其作用是獲取靜態字段內容到堆棧中。這一系列指令包括了:GETFIELD、GETSTATIC。它們分別用于獲取動態字段和靜態字段。此處表示的意思獲取靜態成員System.out到堆棧中。
  • ldc:這個指令的功能是從常量表中裝載一個數據到堆棧中。此處表示從常量池中獲取字符串"Hello World!"。
  • invokevirtual:也是一種調用指令,這個指令區別與 invokespecial 的是它是根據引用調用對象類的方法。此處表示調用java.io.PrintStream.println(String)方法,結合前面的操作,這里調用的就是System.out.println("Hello World!")
  • return: 這也是一系列指令中的一個,其目的是方法調用完畢返回:可用的其他指令有:IRETURN,DRETURN,ARETURN等,用于表示不同類型參數的返回。

更多詳細內容,請參考:JVM字節碼指令理解JVM指令深入字節碼 -- 使用 ASM 實現 AOP
更多字節碼指令詳情,請參考官網:The Java Virtual Machine Instruction Set

接下來,我們就可以根據上面所講的內容,將代碼字節碼注入到class文件中了。

現在假設我們想要在類Testmain方法前后動態插入代碼,如下所示:

package com.yn.test;
public class Test {
    public static void main(String[] agrs){
        System.out.println("asm insert before");
        System.out.println("Hello World!");
        System.out.println("asm insert after");
    }
}

要完成在main方法前后插入輸出代碼,需要以下幾步操作:

  1. 讀取Test.class文件,可以通過 ASM 提供的ClassReader類進行class文件的讀取與遍歷。
// 使用全限定名,創建一個ClassReader對象
ClassReader classReader = new ClassReader("com.yn.test.Test");

// 構建一個ClassWriter對象,并設置讓系統自動計算棧和本地變量大小
ClassWriter classWriter = new ClassWriter(ClassWriter.COMPUTE_MAXS);

//創建一個自定義ClassVisitor,方便后續ClassReader的遍歷通知
ClassVisitor classVisitor = new TestClassVisitor(classWriter);

//開始掃描class文件
classReader.accept(classVisitor, ClassReader.SKIP_DEBUG);
  1. 構造System.out.println(String)ASM 代碼。
    上面我們從javap反編譯得到的字節碼可以知道,實現System.out.println("Hello World!");的字節碼總共需要3步操作:
    (1). 獲取System靜態成員out,其對應的指令為getstatic,對應的 ASM 代碼為:
mv.visitFieldInsn(Opcodes.GETSTATIC,
                  Type.getInternalName(System.class), //"java/lang/System"
                  "out",
                  Type.getDescriptor(PrintStream.class) //"Ljava/io/PrintStream;"
            );

(2). 獲取字符串常量"Hello World!",其對應的指令為ldc,對應的 ASM 代碼為:

mv.visitLdcInsn("Hello World!");

(3). 獲取PrintStream.println(String)方法,其對應的指令為invokervirtual,對應的 ASM 代碼為:

mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL,
                   Type.getInternalName(PrintStream.class), //"java/io/PrintStream"
                   "println",
                   "(Ljava/lang/String;)V",//方法描述符
                   false);
  1. main方法進入前,進行代碼插入,可以通過MethodVisitor.visitCode()方法。
// 在源方法前去修改方法內容,這部分的修改將加載源方法的字節碼之前
@Override
public void visitCode() {
      mv.visitCode();
      System.out.println("method start to insert code");
      sop("asm insert before");//this is the insert code
    }
  1. main方法退出前,進行代碼插入,可以通關過MethodVisitor.visitInsn()方法,通過判斷當前的指令為return時,表明即將執行return語句,此時插入字節碼即可。
@Override
public void visitInsn(int opcode) {
    //檢測到return語句
    if (opcode == Opcodes.RETURN) {
        System.out.println("method end to insert code");
        sop("asm insert after");
    }
        //執行原本語句
        mv.visitInsn(opcode);
  }
  1. 字節碼插入class文件成功后,導出字節碼到原文件中。
//獲取改寫后的class二進制字節碼
byte[] classFile = classWriter.toByteArray();
// 將這個類輸出到原先的類文件目錄下,這是原先的類文件已經被修改
File file = new File("E:/code/Android/Projects/AsmButterknife/sample-java/build/classes/java/main/com/yn/test/Test.class");
FileOutputStream fos = new FileOutputStream(file);
fos.write(classFile);
fos.close();

至此,我們已經完成了對Test.class的代碼注入。
詳細代碼請參見:AsmTest

注: asm-commons 包中提供了一個類AdviceAdapter,使用該類可以更加方便的讓我們在方法前后注入代碼,因為其提供了方法onMethodEnter()onMethodExit()

通過上面介紹的內容,我們已經成功使用 ASM 動態注入字節碼到class文件中。但是如果直接采用 ASM 代碼注入字節碼,還是相對困難的,幸運的是 ASM 給我們提供了 ASMifier 工具,使得我們可以直接通過.class文件反編譯為 ASM 代碼。

因此,當我們要使用 ASM 框架往class文件注入字節碼時,我們通常是將要注入的java源碼先寫出來,然后通過javac編譯出目標.class文件,然后再通過 ASMifier 工具反編譯該.class文件,得到所需的 ASM 注入代碼。

ASMifier 存在于asm-util.jar中,同時需要依賴asm.jar,幸運的是 ASM 提供了一個asm-all.jar包,可以方便我們直接運行 ASMifier

asm-all.jar下載地址:asm-all

運行命令如下:

java -classpath "asm-all.jar" org.objectweb.asm.util.ASMifier org/domain/package/YourClass.class

如果還嫌上面的操作麻煩,github 上已經有人寫了個前端頁面方便我們將源碼轉變為 ASM 代碼操作:asmifier

參考

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,321評論 6 543
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,559評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,442評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,835評論 1 317
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,581評論 6 412
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,922評論 1 328
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,931評論 3 447
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,096評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,639評論 1 336
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,374評論 3 358
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,591評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,104評論 5 364
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,789評論 3 349
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,196評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,524評論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,322評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,554評論 2 379

推薦閱讀更多精彩內容