Java字節(jié)碼增強探秘

本文轉載自 美團技術團隊:Java字節(jié)碼增強探秘

一、字節(jié)碼

1.1 什么是字節(jié)碼

Java之所以可以“一次編譯婚苹,到處運行”瞬哼,一是因為JVM針對各種操作系統(tǒng)、平臺都進行了定制租副,二是因為無論在什么平臺坐慰,都可以編譯生成固定格式的字節(jié)碼(.class文件)供JVM使用。因此用僧,也可以看出字節(jié)碼對于Java生態(tài)的重要性结胀。之所以被稱之為字節(jié)碼,是因為字節(jié)碼文件由十六進制值組成责循,而JVM以兩個十六進制值為一組糟港,即以字節(jié)為單位進行讀取。在Java中一般是用javac命令編譯源代碼為字節(jié)碼文件院仿,一個.java文件從編譯到運行的示例如圖1所示秸抚。

圖1 Java運行示意圖

對于開發(fā)人員,了解字節(jié)碼可以更準確歹垫、直觀地理解Java語言中更深層次的東西剥汤,比如通過字節(jié)碼,可以很直觀地看到Volatile關鍵字如何在字節(jié)碼上生效排惨。另外吭敢,字節(jié)碼增強技術在Spring AOP、各種ORM框架暮芭、熱部署中的應用屢見不鮮鹿驼,深入理解其原理對于我們來說大有裨益欲低。除此之外,由于JVM規(guī)范的存在畜晰,只要最終可以生成符合規(guī)范的字節(jié)碼就可以在JVM上運行砾莱,因此這就給了各種運行在JVM上的語言(如Scala、Groovy凄鼻、Kotlin)一種契機腊瑟,可以擴展Java所沒有的特性或者實現(xiàn)各種語法糖。理解字節(jié)碼后再學習這些語言野宜,可以“逆流而上”,從字節(jié)碼視角看它的設計思路魔策,學習起來也“易如反掌”匈子。

本文重點著眼于字節(jié)碼增強技術,從字節(jié)碼開始逐層向上闯袒,由JVM字節(jié)碼操作集合到Java中操作字節(jié)碼的框架虎敦,再到我們熟悉的各類框架原理及應用,也都會一一進行介紹政敢。

1.2 字節(jié)碼結構

.java文件通過javac編譯后將得到一個.class文件其徙,比如編寫一個簡單的ByteCodeDemo類,如下圖2的左側部分:

圖2 示例代碼(左側)及對應的字節(jié)碼(右側)

編譯后生成ByteCodeDemo.class文件喷户,打開后是一堆十六進制數(shù)唾那,按字節(jié)為單位進行分割后展示如圖2右側部分所示。上文提及過褪尝,JVM對于字節(jié)碼是有規(guī)范要求的闹获,那么看似雜亂的十六進制符合什么結構呢?JVM規(guī)范要求每一個字節(jié)碼文件都要由十部分按照固定的順序組成河哑,整體結構如圖3所示避诽。接下來我們將一一介紹這十個部分:

圖3 JVM規(guī)定的字節(jié)碼結構

(1) 魔數(shù)(Magic Number)

所有的.class文件的前四個字節(jié)都是魔數(shù),魔數(shù)的固定值為:0xCAFEBABE璃谨。魔數(shù)放在文件開頭沙庐,JVM可以根據(jù)文件的開頭來判斷這個文件是否可能是一個.class文件,如果是佳吞,才會繼續(xù)進行之后的操作拱雏。

有趣的是,魔數(shù)的固定值是Java之父James Gosling制定的底扳,為CafeBabe(咖啡寶貝)古涧,而Java的圖標為一杯咖啡。

(2) 版本號

版本號為魔數(shù)之后的4個字節(jié)花盐,前兩個字節(jié)表示次版本號(Minor Version)羡滑,后兩個字節(jié)表示主版本號(Major Version)菇爪。上圖2中版本號為“00 00 00 34”,次版本號轉化為十進制為0柒昏,主版本號轉化為十進制為52凳宙,在Oracle官網中查詢序號52對應的主版本號為1.8,所以編譯該文件的Java版本號為1.8.0职祷。

(3) 常量池(Constant Pool)

緊接著主版本號之后的字節(jié)為常量池入口氏涩。常量池中存儲兩類常量:字面量與符號引用。字面量為代碼中聲明為Final的常量值有梆,符號引用如類和接口的全局限定名是尖、字段的名稱和描述符、方法的名稱和描述符泥耀。常量池整體上分為兩部分:常量池計數(shù)器以及常量池數(shù)據(jù)區(qū)饺汹,如下圖4所示。

圖4 常量池的結構
  • 常量池計數(shù)器(constant_pool_count):由于常量的數(shù)量不固定痰催,所以需要先放置兩個字節(jié)來表示常量池容量計數(shù)值兜辞。圖2中示例代碼的字節(jié)碼前10個字節(jié)如下圖5所示,將十六進制的24轉化為十進制值為36夸溶,排除掉下標“0”逸吵,也就是說,這個類文件中共有35個常量缝裁。
圖5 前十個字節(jié)及含義
  • 常量池數(shù)據(jù)區(qū):數(shù)據(jù)區(qū)是由(constant_pool_count-1)個cp_info結構組成扫皱,一個cp_info結構對應一個常量。在字節(jié)碼中共有14種類型的cp_info(如下圖6所示)捷绑,每種類型的結構都是固定的啸罢。
圖6 各類型的cp_info

具體以CONSTANT_utf8_info為例,它的結構如下圖7左側所示胎食。首先一個字節(jié)“tag”扰才,它的值取自上圖6中對應項的Tag,由于它的類型是utf8_info,所以值為“01”。接下來兩個字節(jié)標識該字符串的長度Length矛辕,然后Length個字節(jié)為這個字符串具體的值。從圖2中的字節(jié)碼摘取一個cp_info結構琅捏,如下圖7右側所示。將它翻譯過來后递雀,其含義為:該常量類型為utf8字符串柄延,長度為一字節(jié),數(shù)據(jù)為“a”。

圖7 CONSTANT_utf8_info的結構(左)及示例(右)

其他類型的cp_info結構在本文不再贅述搜吧,整體結構大同小異市俊,都是先通過Tag來標識類型,然后后續(xù)n個字節(jié)來描述長度和(或)數(shù)據(jù)滤奈。先知其所以然摆昧,以后可以通過javap -verbose ByteCodeDemo命令,查看JVM反編譯后的完整常量池蜒程,如下圖8所示绅你。可以看到反編譯結果將每一個cp_info結構的類型和值都很明確地呈現(xiàn)了出來昭躺。

圖8 常量池反編譯結果

(4) 訪問標志

常量池結束之后的兩個字節(jié)忌锯,描述該Class是類還是接口,以及是否被Public领炫、Abstract偶垮、Final等修飾符修飾。JVM規(guī)范規(guī)定了如下圖9的訪問標志(Access_Flag)驹吮。需要注意的是针史,JVM并沒有窮舉所有的訪問標志晶伦,而是使用按位或操作來進行描述的碟狞,比如某個類的修飾符為Public Final,則對應的訪問修飾符的值為ACC_PUBLIC | ACC_FINAL婚陪,即0x0001 | 0x0010=0x0011族沃。

圖9 訪問標志

(5) 當前類名

訪問標志后的兩個字節(jié),描述的是當前類的全限定名泌参。這兩個字節(jié)保存的值為常量池中的索引值脆淹,根據(jù)索引值就能在常量池中找到這個類的全限定名。

(6) 父類名稱

當前類名后的兩個字節(jié)沽一,描述父類的全限定名盖溺,同上,保存的也是常量池中的索引值铣缠。

(7) 接口信息

父類名稱后為兩字節(jié)的接口計數(shù)器烘嘱,描述了該類或父類實現(xiàn)的接口數(shù)量。緊接著的n個字節(jié)是所有接口名稱的字符串常量的索引值蝗蛙。

(8) 字段表

字段表用于描述類和接口中聲明的變量蝇庭,包含類級別的變量以及實例變量,但是不包含方法內部聲明的局部變量捡硅。字段表也分為兩部分哮内,第一部分為兩個字節(jié),描述字段個數(shù)壮韭;第二部分是每個字段的詳細信息fields_info北发。字段表結構如下圖所示:

圖10 字段表結構

以圖2中字節(jié)碼的字段表為例纹因,如下圖11所示。其中字段的訪問標志查圖9鲫竞,0002對應為Private辐怕。通過索引下標在圖8中常量池分別得到字段名為“a”,描述符為“I”(代表int)从绘。綜上寄疏,就可以唯一確定出一個類中聲明的變量private int a。

圖11 字段表示例

(9)方法表

字段表結束后為方法表僵井,方法表也是由兩部分組成陕截,第一部分為兩個字節(jié)描述方法的個數(shù);第二部分為每個方法的詳細信息批什。方法的詳細信息較為復雜农曲,包括方法的訪問標志、方法名驻债、方法的描述符以及方法的屬性乳规,如下圖所示:

圖12 方法表結構

方法的權限修飾符依然可以通過圖9的值查詢得到,方法名和方法的描述符都是常量池中的索引值合呐,可以通過索引值在常量池中找到暮的。而“方法的屬性”這一部分較為復雜,直接借助javap -verbose將其反編譯為人可以讀懂的信息進行解讀淌实,如圖13所示冻辩。可以看到屬性中包括以下三個部分:

  • “Code區(qū)”:源代碼對應的JVM指令操作碼拆祈,在進行字節(jié)碼增強時重點操作的就是“Code區(qū)”這一部分恨闪。
  • “LineNumberTable”:行號表,將Code區(qū)的操作碼和源代碼中的行號對應放坏,Debug時會起到作用(源代碼走一行咙咽,需要走多少個JVM指令操作碼)。
  • “LocalVariableTable”:本地變量表淤年,包含This和局部變量钧敞,之所以可以在每一個方法內部都可以調用This,是因為JVM將This作為每一個方法的第一個參數(shù)隱式進行傳入互亮。當然犁享,這是針對非Static方法而言。
圖13 反編譯后的方法表

(10)附加屬性表

字節(jié)碼的最后一部分豹休,該項存放了在該文件中類或接口所定義屬性的基本信息炊昆。

1.3 字節(jié)碼操作集合

在上圖13中,Code區(qū)的紅色編號0~17,就是.java中的方法源代碼編譯后讓JVM真正執(zhí)行的操作碼凤巨。為了幫助人們理解视乐,反編譯后看到的是十六進制操作碼所對應的助記符,十六進制值操作碼與助記符的對應關系敢茁,以及每一個操作碼的用處可以查看Oracle官方文檔進行了解佑淀,在需要用到時進行查閱即可。比如上圖中第一個助記符為iconst_2彰檬,對應到圖2中的字節(jié)碼為0x05伸刃,用處是將int值2壓入操作數(shù)棧中。以此類推逢倍,對0~17的助記符理解后捧颅,就是完整的add()方法的實現(xiàn)。

1.4 操作數(shù)棧和字節(jié)碼

JVM的指令集是基于棧而不是寄存器较雕,基于椀镅疲可以具備很好的跨平臺性(因為寄存器指令集往往和硬件掛鉤),但缺點在于亮蒋,要完成同樣的操作扣典,基于棧的實現(xiàn)需要更多指令才能完成(因為棧只是一個FILO結構,需要頻繁壓棧出棧)慎玖。另外贮尖,由于棧是在內存實現(xiàn)的,而寄存器是在CPU的高速緩存區(qū)凄吏,相較而言远舅,基于棧的速度要慢很多闰蛔,這也是為了跨平臺性而做出的犧牲痕钢。

我們在上文所說的操作碼或者操作集合,其實控制的就是這個JVM的操作數(shù)棧序六。為了更直觀地感受操作碼是如何控制操作數(shù)棧的任连,以及理解常量池、變量表的作用例诀,將add()方法的對操作數(shù)棧的操作制作為GIF随抠,如下圖14所示,圖中僅截取了常量池中被引用的部分繁涂,以指令iconst_2開始到ireturn結束拱她,與圖13中Code區(qū)0~17的指令一一對應:

圖14 控制操作數(shù)棧示意圖
1.5 查看字節(jié)碼工具

如果每次查看反編譯后的字節(jié)碼都使用javap命令的話,好非常繁瑣扔罪。這里推薦一個Idea插件:jclasslib秉沼。使用效果如圖15所示,代碼編譯后在菜單欄"View"中選擇"Show Bytecode With jclasslib",可以很直觀地看到當前字節(jié)碼文件的類信息唬复、常量池矗积、方法區(qū)等信息。

圖15 jclasslib查看字節(jié)碼

二敞咧、字節(jié)碼增強

在上文中棘捣,著重介紹了字節(jié)碼的結構,這為我們了解字節(jié)碼增強技術的實現(xiàn)打下了基礎休建。字節(jié)碼增強技術就是一類對現(xiàn)有字節(jié)碼進行修改或者動態(tài)生成全新字節(jié)碼文件的技術乍恐。接下來,我們將從最直接操縱字節(jié)碼的實現(xiàn)方式開始深入進行剖析测砂。

圖16 字節(jié)碼增強技術
2.1 ASM

對于需要手動操縱字節(jié)碼的需求禁熏,可以使用ASM,它可以直接生成.class字節(jié)碼文件邑彪,也可以在類被加載入JVM之前動態(tài)修改類行為(如下圖17所示)瞧毙。ASM的應用場景有AOP(Cglib就是基于ASM)、熱部署寄症、修改其他jar包中的類等宙彪。當然,涉及到如此底層的步驟有巧,實現(xiàn)起來也比較麻煩释漆。接下來,本文將介紹ASM的兩種API篮迎,并用ASM來實現(xiàn)一個比較粗糙的AOP男图。但在此之前,為了讓大家更快地理解ASM的處理流程甜橱,強烈建議讀者先對訪問者模式進行了解逊笆。簡單來說,訪問者模式主要用于修改或操作一些數(shù)據(jù)結構比較穩(wěn)定的數(shù)據(jù)岂傲,而通過第一章难裆,我們知道字節(jié)碼文件的結構是由JVM固定的,所以很適合利用訪問者模式對字節(jié)碼文件進行修改镊掖。

圖17 ASM修改字節(jié)碼
2.1.1 ASM API
2.1.1.1 核心API

ASM Core API可以類比解析XML文件中的SAX方式乃戈,不需要把這個類的整個結構讀取進來,就可以用流式的方法來處理字節(jié)碼文件亩进。好處是非常節(jié)約內存症虑,但是編程難度較大。然而出于性能考慮归薛,一般情況下編程都使用Core API谍憔。在Core API中有以下幾個關鍵類:

  • ClassReader:用于讀取已經編譯好的.class文件驶冒。
  • ClassWriter:用于重新構建編譯后的類,如修改類名韵卤、屬性以及方法骗污,也可以生成新的類的字節(jié)碼文件。
  • 各種Visitor類:如上所述沈条,CoreAPI根據(jù)字節(jié)碼從上到下依次處理需忿,對于字節(jié)碼文件中不同的區(qū)域有不同的Visitor,比如用于訪問方法的MethodVisitor蜡歹、用于訪問類變量的FieldVisitor屋厘、用于訪問注解的AnnotationVisitor等。為了實現(xiàn)AOP月而,重點要使用的是MethodVisitor汗洒。
2.1.1.2 樹形API

ASM Tree API可以類比解析XML文件中的DOM方式,把整個類的結構讀取到內存中父款,缺點是消耗內存多溢谤,但是編程比較簡單。TreeApi不同于CoreAPI憨攒,TreeAPI通過各種Node類來映射字節(jié)碼的各個區(qū)域世杀,類比DOM節(jié)點,就可以很好地理解這種編程方式肝集。

2.1.2 直接利用ASM實現(xiàn)AOP

利用ASM的CoreAPI來增強類瞻坝。這里不糾結于AOP的專業(yè)名詞如切片、通知杏瞻,只實現(xiàn)在方法調用前所刀、后增加邏輯,通俗易懂且方便理解捞挥。首先定義需要被增強的Base類:其中只包含一個process()方法浮创,方法內輸出一行“process”。增強后树肃,我們期望的是蒸矛,方法執(zhí)行前輸出“start”瀑罗,之后輸出"end"胸嘴。

public class Base {
     public void process(){
       System.out.println("process");
     }
}

為了利用ASM實現(xiàn)AOP,需要定義兩個類:一個是MyClassVisitor類斩祭,用于對字節(jié)碼的Visit以及修改劣像;另一個是Generator類,在這個類中定義ClassReader和ClassWriter摧玫,其中的邏輯是耳奕,classReader讀取字節(jié)碼绑青,然后交給MyClassVisitor類處理,處理完成后由ClassWriter寫字節(jié)碼并將舊的字節(jié)碼替換掉屋群。Generator類較簡單闸婴,我們先看一下它的實現(xiàn),如下所示芍躏,然后重點解釋MyClassVisitor類邪乍。

import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassVisitor;
import org.objectweb.asm.ClassWriter;

public class Generator {
    public static void main(String[] args) throws Exception {
        // 讀取
        ClassReader classReader = new ClassReader("meituan/bytecode/asm/Base");
        ClassWriter classWriter = new ClassWriter(ClassWriter.COMPUTE_MAXS);
        // 處理
        ClassVisitor classVisitor = new MyClassVisitor(classWriter);
        classReader.accept(classVisitor, ClassReader.SKIP_DEBUG);
        byte[] data = classWriter.toByteArray();
        // 輸出
        File f = new File("operation-server/target/classes/meituan/bytecode/asm/Base.class");
        FileOutputStream fout = new FileOutputStream(f);
        fout.write(data);
        fout.close();
        System.out.println("now generator cc success!!!!!");
    }
}

MyClassVisitor繼承自ClassVisitor,用于對字節(jié)碼的觀察对竣。它還包含一個內部類MyMethodVisitor庇楞,繼承自MethodVisitor用于對類內方法的觀察,整體代碼如下:

import org.objectweb.asm.ClassVisitor;
import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Opcodes;

public class MyClassVisitor extends ClassVisitor implements Opcodes {
    public MyClassVisitor(ClassVisitor cv) {
        super(ASM5, cv);
    }

    @Override
    public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
        cv.visit(version, access, name, signature, superName, interfaces);
    }

    @Override
    public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
        MethodVisitor mv = cv.visitMethod(access, name, desc, signature, exceptions);
        // Base類中有兩個方法:無參構造以及process方法否纬,這里不增強構造方法
        if (!name.equals("<init>") && mv != null) {
            mv = new MyMethodVisitor(mv);
        }
        return mv;
    }

    class MyMethodVisitor extends MethodVisitor implements Opcodes {
        public MyMethodVisitor(MethodVisitor mv) {
            super(Opcodes.ASM5, 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);
        }
    }
}

利用這個類就可以實現(xiàn)對字節(jié)碼的修改。詳細解讀其中的代碼临燃,對字節(jié)碼做修改的步驟是:

  • 首先通過MyClassVisitor類中的visitMethod方法睛驳,判斷當前字節(jié)碼讀到哪一個方法了。跳過構造方法"<init>"后膜廊,將需要被增強的方法交給內部類MyMethodVisitor來進行處理柏靶。
  • 接下來,進入內部類MyMethodVisitor中的visitCode方法溃论,它會在ASM開始訪問某一個方法的Code區(qū)時被調用屎蜓,重寫visitCode方法,將AOP中的前置邏輯就放在這里钥勋。
  • MyMethodVisitor繼續(xù)讀取字節(jié)碼指令炬转,每當ASM訪問到無參數(shù)指令時,都會調用MyMethodVisitor中的visitInsn方法算灸。我們判斷了當前指令是否為無參數(shù)的“return”指令扼劈,如果是就在它的前面添加一些指令,也就是將AOP的后置邏輯放在該方法中菲驴。
  • 綜上荐吵,重寫MyMethodVisitor中的兩個方法,就可以實現(xiàn)AOP了赊瞬,而重寫方法時就需要用ASM的寫法先煎,手動寫入或者修改字節(jié)碼。通過調用methodVisitor的visitXXXXInsn()方法就可以實現(xiàn)字節(jié)碼的插入巧涧,XXXX對應相應的操作碼助記符類型薯蝎,比如mv.visitLdcInsn("end")對應的操作碼就是ldc "end",即將字符串“end”壓入棧谤绳。

完成這兩個Visitor類后占锯,運行Generator中的main方法完成對Base類的字節(jié)碼增強袒哥,增強后的結果可以在編譯后的Target文件夾中找到Base.class文件進行查看,可以看到反編譯后的代碼已經改變了(如圖18左側所示)消略。然后寫一個測試類MyTest堡称,在其中new Base(),并調用base.process()方法艺演,可以看到下圖右側所示的AOP實現(xiàn)效果:

圖18 ASM實現(xiàn)AOP的效果
2.1.3 ASM工具

利用ASM手寫字節(jié)碼時粮呢,需要利用一系列visitXXXXInsn()方法來寫對應的助記符,所以需要先將每一行源代碼轉化為一個個的助記符钞艇,然后通過ASM的語法轉換為visitXXXXInsn()這種寫法啄寡。第一步將源碼轉化為助記符就已經夠麻煩了,不熟悉字節(jié)碼操作集合的話哩照,需要我們將代碼編譯后再反編譯挺物,才能得到源代碼對應的助記符。第二步利用ASM寫字節(jié)碼時飘弧,如何傳參也很令人頭疼识藤。ASM社區(qū)也知道這兩個問題,所以提供了工具ASM ByteCode Outline次伶。

安裝后痴昧,右鍵選擇“Show Bytecode Outline”,在新標簽頁中選擇“ASMified”這個tab冠王,如圖19所示赶撰,就可以看到這個類中的代碼對應的ASM寫法了。圖中上下兩個紅框分別對應AOP中的前置邏輯于后置邏輯柱彻,將這兩塊直接復制到Visitor中的visitMethod()以及visitInsn()方法中豪娜,就可以了。

圖19 ASM Bytecode Outline
2.2 Javassist

ASM是在指令層次上操作字節(jié)碼的哟楷,閱讀上文后瘤载,我們的直觀感受是在指令層次上操作字節(jié)碼的框架實現(xiàn)起來比較晦澀。故除此之外卖擅,我們再簡單介紹另外一類框架:強調源代碼層次操作字節(jié)碼的框架Javassist鸣奔。

利用Javassist實現(xiàn)字節(jié)碼增強時,可以無須關注字節(jié)碼刻板的結構惩阶,其優(yōu)點就在于編程簡單挎狸。直接使用Java編碼的形式,而不需要了解虛擬機指令琳猫,就能動態(tài)改變類的結構或者動態(tài)生成類伟叛。其中最重要的是ClassPool、CtClass脐嫂、CtMethod统刮、CtField這四個類:

  • CtClass(compile-time class):編譯時類信息,它是一個Class文件在代碼中的抽象表現(xiàn)形式账千,可以通過一個類的全限定名來獲取一個CtClass對象侥蒙,用來表示這個類文件。
  • ClassPool:從開發(fā)視角來看匀奏,ClassPool是一張保存CtClass信息的HashTable鞭衩,Key為類名,Value為類名對應的CtClass對象娃善。當我們需要對某個類進行修改時论衍,就是通過pool.getCtClass("className")方法從pool中獲取到相應的CtClass。
  • CtMethod聚磺、CtField:這兩個比較好理解坯台,對應的是類中的方法和屬性。

了解這四個類后瘫寝,我們可以寫一個小Demo來展示Javassist簡單蜒蕾、快速的特點。我們依然是對Base中的process()方法做增強焕阿,在方法調用前后分別輸出"start"和"end"咪啡,實現(xiàn)代碼如下。我們需要做的就是從Pool中獲取到相應的CtClass對象和其中的方法暮屡,然后執(zhí)行method.insertBefore和insertAfter方法撤摸,參數(shù)為要插入的Java代碼,再以字符串的形式傳入即可褒纲,實現(xiàn)起來也極為簡單愁溜。

import com.meituan.mtrace.agent.javassist.*;

public class JavassistTest {
    public static void main(String[] args) throws NotFoundException, CannotCompileException, IllegalAccessException,
            InstantiationException, IOException {
        ClassPool cp = ClassPool.getDefault();
        CtClass cc = cp.get("meituan.bytecode.javassist.Base");
        CtMethod m = cc.getDeclaredMethod("process");
        m.insertBefore("{ System.out.println(\"start\"); }");
        m.insertAfter("{ System.out.println(\"end\"); }");
        Class c = cc.toClass();
        cc.writeFile("/Users/zen/projects");
        Base h = (Base) c.newInstance();
        h.process();
    }
}

三、運行時類的重載

3.1 問題引出

上一章重點介紹了兩種不同類型的字節(jié)碼操作框架外厂,且都利用它們實現(xiàn)了較為粗糙的AOP冕象。其實,為了方便大家理解字節(jié)碼增強技術汁蝶,在上文中我們避重就輕將ASM實現(xiàn)AOP的過程分為了兩個Main方法:第一個是利用MyClassVisitor對已編譯好的Class文件進行修改渐扮,第二個是New對象并調用。這期間并不涉及到JVM運行時對類的重加載掖棉,而是在第一個Main方法中墓律,通過ASM對已編譯類的字節(jié)碼進行替換,在第二個Main方法中幔亥,直接使用已替換好的新類信息耻讽。另外在Javassist的實現(xiàn)中,我們也只加載了一次Base類帕棉,也不涉及到運行時重加載類针肥。

如果我們在一個JVM中饼记,先加載了一個類,然后又對其進行字節(jié)碼增強并重新加載會發(fā)生什么呢慰枕?模擬這種情況具则,只需要我們在上文中Javassist的Demo中main()方法的第一行添加Base b=new Base(),即在增強前就先讓JVM加載Base類具帮,然后在執(zhí)行到c.toClass()方法時會拋出錯誤博肋,如下圖20所示。跟進c.toClass()方法中蜂厅,我們會發(fā)現(xiàn)它是在最后調用了ClassLoader的Native方法defineClass()時報錯匪凡。也就是說,JVM是不允許在運行時動態(tài)重載一個類的掘猿。

圖20 運行時重復load類的錯誤信息

顯然病游,如果只能在類加載前對類進行強化,那字節(jié)碼增強技術的使用場景就變得很窄了术奖。我們期望的效果是:在一個持續(xù)運行并已經加載了所有類的JVM中礁遵,還能利用字節(jié)碼增強技術對其中的類行為做替換并重新加載。為了模擬這種情況采记,我們將Base類做改寫佣耐,在其中編寫main方法,每五秒調用一次process()方法唧龄,在process()方法中輸出一行“process”兼砖。

我們的目的就是,在JVM運行中的時候既棺,將process()方法做替換讽挟,在其前后分別打印“start”和“end”。也就是在運行中時丸冕,每五秒打印的內容由"process"變?yōu)榇蛴?start process end"耽梅。那如何解決JVM不允許運行時重加載類信息的問題呢?為了達到這個目的胖烛,我們接下來一一介紹需要借助的Java類庫眼姐。

import java.lang.management.ManagementFactory;

public class Base {
    public static void main(String[] args) {
        String name = ManagementFactory.getRuntimeMXBean().getName();
        String s = name.split("@")[0];
        // 打印當前Pid
        System.out.println("pid:" + s);
        while (true) {
            try {
                Thread.sleep(5000L);
            } catch (Exception e) {
                break;
            }
            process();
        }
    }

    public static void process() {
        System.out.println("process");
    }
}
3.2 Instrument

Instrument是JVM提供的一個可以修改已加載類的類庫,專門為Java語言編寫的插樁服務提供支持佩番。它需要依賴JVMTI的Attach API機制實現(xiàn)众旗,JVMTI這一部分,我們將在下一小節(jié)進行介紹趟畏。在JDK 1.6以前贡歧,Instrument只能在JVM剛啟動開始加載類時生效,而在JDK 1.6之后,Instrument支持了在運行時對類定義的修改利朵。要使用Instrument的類修改功能律想,我們需要實現(xiàn)它提供的ClassFileTransformer接口,定義一個類文件轉換器哗咆。接口中的transform()方法會在類文件被加載時調用蜘欲,而在Transform方法里益眉,我們可以利用上文中的ASM或Javassist對傳入的字節(jié)碼進行改寫或替換晌柬,生成新的字節(jié)碼數(shù)組后返回。

我們定義一個實現(xiàn)了ClassFileTransformer接口的類TestTransformer郭脂,依然在其中利用Javassist對Base類中的process()方法進行增強年碘,在前后分別打印“start”和“end”,代碼如下:

import java.lang.instrument.ClassFileTransformer;

public class TestTransformer implements ClassFileTransformer {
    @Override
    public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
            ProtectionDomain protectionDomain, byte[] classfileBuffer) {
        System.out.println("Transforming " + className);
        try {
            ClassPool cp = ClassPool.getDefault();
            CtClass cc = cp.get("meituan.bytecode.jvmti.Base");
            CtMethod m = cc.getDeclaredMethod("process");
            m.insertBefore("{ System.out.println(\"start\"); }");
            m.insertAfter("{ System.out.println(\"end\"); }");
            return cc.toBytecode();
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }
}

現(xiàn)在有了Transformer展鸡,那么它要如何注入到正在運行的JVM呢屿衅?還需要定義一個Agent,借助Agent的能力將Instrument注入到JVM中莹弊。我們將在下一小節(jié)介紹Agent涤久,現(xiàn)在要介紹的是Agent中用到的另一個類Instrumentation。在JDK 1.6之后忍弛,Instrumentation可以做啟動后的Instrument响迂、本地代碼(Native Code)的Instrument,以及動態(tài)改變Classpath等等细疚。我們可以向Instrumentation中添加上文中定義的Transformer蔗彤,并指定要被重加載的類,代碼如下所示疯兼。這樣然遏,當Agent被Attach到一個JVM中時,就會執(zhí)行類字節(jié)碼替換并重載入JVM的操作吧彪。

import java.lang.instrument.Instrumentation;

public class TestAgent {
    public static void agentmain(String args, Instrumentation inst) {
        // 指定我們自己定義的Transformer待侵,在其中利用Javassist做字節(jié)碼替換
        inst.addTransformer(new TestTransformer(), true);
        try {
            // 重定義類并載入新的字節(jié)碼
            inst.retransformClasses(Base.class);
            System.out.println("Agent Load Done.");
        } catch (Exception e) {
            System.out.println("agent load failed!");
        }
    }
}
3.3 JVMTI & Agent & Attach API

上一小節(jié)中,我們給出了Agent類的代碼姨裸,追根溯源需要先介紹JPDA(Java Platform Debugger Architecture)秧倾。如果JVM啟動時開啟了JPDA孝治,那么類是允許被重新加載的炮温。在這種情況下瘫想,已被加載的舊版本類信息可以被卸載乃沙,然后重新加載新版本的類闸迷。正如JDPA名稱中的Debugger餐弱,JDPA其實是一套用于調試Java程序的標準窟赏,任何JDK都必須實現(xiàn)該標準僧家。

JPDA定義了一整套完整的體系,它將調試體系分為三部分勋又,并規(guī)定了三者之間的通信接口苦掘。三部分由低到高分別是Java 虛擬機工具接口(JVMTI),Java 調試協(xié)議(JDWP)以及 Java 調試接口(JDI)楔壤,三者之間的關系如下圖所示:

圖21 JPDA

現(xiàn)在回到正題鹤啡,我們可以借助JVMTI的一部分能力,幫助動態(tài)重載類信息蹲嚣。JVM TI(JVM TOOL INTERFACE递瑰,JVM工具接口)是JVM提供的一套對JVM進行操作的工具接口。通過JVMTI可以實現(xiàn)對JVM的多種操作隙畜,然后通過接口注冊各種事件勾子抖部。在JVM事件觸發(fā)時,同時觸發(fā)預定義的勾子议惰,以實現(xiàn)對各個JVM事件的響應慎颗,事件包括類文件加載、異常產生與捕獲言询、線程啟動和結束俯萎、進入和退出臨界區(qū)、成員變量修改运杭、GC開始和結束夫啊、方法調用進入和退出、臨界區(qū)競爭與等待县习、VM啟動與退出等等涮母。

而Agent就是JVMTI的一種實現(xiàn),Agent有兩種啟動方式躁愿,一是隨Java進程啟動而啟動叛本,經常見到的java -agentlib就是這種方式;二是運行時載入彤钟,通過Attach API来候,將模塊(jar包)動態(tài)地Attach到指定進程id的Java進程內。

Attach API 的作用是提供JVM進程間通信的能力逸雹,比如說我們?yōu)榱俗屃硗庖粋€JVM進程把線上服務的線程Dump出來营搅,會運行jstack或jmap的進程,并傳遞pid的參數(shù)梆砸,告訴它要對哪個進程進行線程Dump转质,這就是Attach API做的事情。在下面帖世,我們將通過Attach API的loadAgent()方法休蟹,將打包好的Agent jar包動態(tài)Attach到目標JVM上。具體實現(xiàn)起來的步驟如下:

  • 定義Agent,并在其中實現(xiàn)AgentMain方法赂弓,如上一小節(jié)中定義的代碼塊7中的TestAgent類绑榴;
  • 然后將TestAgent類打成一個包含MANIFEST.MF的jar包,其中MANIFEST.MF文件中將Agent-Class屬性指定為TestAgent的全限定名盈魁,如下圖所示翔怎;
圖22 Manifest.mf
  • 最后利用Attach API,將我們打包好的jar包Attach到指定的JVM pid上杨耙,代碼如下:
import com.sun.tools.attach.VirtualMachine;

public class Attacher {
    public static void main(String[] args)
            throws AttachNotSupportedException, IOException, AgentLoadException, AgentInitializationException {
        // 傳入目標 JVM pid
        VirtualMachine vm = VirtualMachine.attach("39333");
        vm.loadAgent("/Users/zen/operation_server_jar/operation-server.jar");
    }
}
  • 由于在MANIFEST.MF中指定了Agent-Class赤套,所以在Attach后按脚,目標JVM在運行時會走到TestAgent類中定義的agentmain()方法于毙,而在這個方法中敦冬,我們利用Instrumentation辅搬,將指定類的字節(jié)碼通過定義的類轉化器TestTransformer做了Base類的字節(jié)碼替換(通過javassist),并完成了類的重新加載脖旱。由此堪遂,我們達成了“在JVM運行時,改變類的字節(jié)碼并重新載入類信息”的目的萌庆。

以下為運行時重新載入類的效果:先運行Base中的main()方法溶褪,啟動一個JVM,可以在控制臺看到每隔五秒輸出一次"process"践险。接著執(zhí)行Attacher中的main()方法猿妈,并將上一個JVM的pid傳入。此時回到上一個main()方法的控制臺巍虫,可以看到現(xiàn)在每隔五秒輸出"process"前后會分別輸出"start"和"end"彭则,也就是說完成了運行時的字節(jié)碼增強,并重新載入了這個類占遥。

圖23 運行時重載入類的效果
3.4 使用場景

至此俯抖,字節(jié)碼增強技術的可使用范圍就不再局限于JVM加載類前了。通過上述幾個類庫瓦胎,我們可以在運行時對JVM中的類進行修改并重載了芬萍。通過這種手段,可以做的事情就變得很多了:

  • 熱部署:不部署服務而對線上服務做修改搔啊,可以做打點柬祠、增加日志等操作。
  • Mock:測試時候對某些服務做Mock负芋。
  • 性能診斷工具:比如bTrace就是利用Instrument漫蛔,實現(xiàn)無侵入地跟蹤一個正在運行的JVM,監(jiān)控到類和方法級別的狀態(tài)信息。

四惩猫、總結

字節(jié)碼增強技術相當于是一把打開運行時JVM的鑰匙芝硬,利用它可以動態(tài)地對運行中的程序做修改,也可以跟蹤JVM運行中程序的狀態(tài)轧房。此外拌阴,我們平時使用的動態(tài)代理、AOP也與字節(jié)碼增強密切相關奶镶,它們實質上還是利用各種手段生成符合規(guī)范的字節(jié)碼文件迟赃。綜上所述,掌握字節(jié)碼增強后可以高效地定位并快速修復一些棘手的問題(如線上性能問題厂镇、方法出現(xiàn)不可控的出入參需要緊急加日志等問題)纤壁,也可以在開發(fā)中減少冗余代碼,大大提高開發(fā)效率捺信。

?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
  • 序言:七十年代末酌媒,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子迄靠,更是在濱河造成了極大的恐慌秒咨,老刑警劉巖,帶你破解...
    沈念sama閱讀 222,104評論 6 515
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件掌挚,死亡現(xiàn)場離奇詭異雨席,居然都是意外死亡,警方通過查閱死者的電腦和手機吠式,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 94,816評論 3 399
  • 文/潘曉璐 我一進店門陡厘,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人特占,你說我怎么就攤上這事糙置。” “怎么了摩钙?”我有些...
    開封第一講書人閱讀 168,697評論 0 360
  • 文/不壞的土叔 我叫張陵罢低,是天一觀的道長。 經常有香客問我胖笛,道長网持,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 59,836評論 1 298
  • 正文 為了忘掉前任长踊,我火速辦了婚禮功舀,結果婚禮上,老公的妹妹穿的比我還像新娘身弊。我一直安慰自己辟汰,他們只是感情好列敲,可當我...
    茶點故事閱讀 68,851評論 6 397
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著帖汞,像睡著了一般戴而。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上翩蘸,一...
    開封第一講書人閱讀 52,441評論 1 310
  • 那天所意,我揣著相機與錄音,去河邊找鬼催首。 笑死扶踊,一個胖子當著我的面吹牛,可吹牛的內容都是我干的郎任。 我是一名探鬼主播秧耗,決...
    沈念sama閱讀 40,992評論 3 421
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼舶治!你這毒婦竟也來了分井?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 39,899評論 0 276
  • 序言:老撾萬榮一對情侶失蹤歼疮,失蹤者是張志新(化名)和其女友劉穎杂抽,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體韩脏,經...
    沈念sama閱讀 46,457評論 1 318
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 38,529評論 3 341
  • 正文 我和宋清朗相戀三年铸磅,在試婚紗的時候發(fā)現(xiàn)自己被綠了赡矢。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 40,664評論 1 352
  • 序言:一個原本活蹦亂跳的男人離奇死亡阅仔,死狀恐怖吹散,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情八酒,我是刑警寧澤空民,帶...
    沈念sama閱讀 36,346評論 5 350
  • 正文 年R本政府宣布,位于F島的核電站羞迷,受9級特大地震影響界轩,放射性物質發(fā)生泄漏。R本人自食惡果不足惜衔瓮,卻給世界環(huán)境...
    茶點故事閱讀 42,025評論 3 334
  • 文/蒙蒙 一浊猾、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧热鞍,春花似錦葫慎、人聲如沸衔彻。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,511評論 0 24
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽艰额。三九已至,卻和暖如春椒涯,著一層夾襖步出監(jiān)牢的瞬間悴晰,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 33,611評論 1 272
  • 我被黑心中介騙來泰國打工逐工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留铡溪,地道東北人。 一個月前我還...
    沈念sama閱讀 49,081評論 3 377
  • 正文 我出身青樓泪喊,卻偏偏與公主長得像棕硫,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子袒啼,可洞房花燭夜當晚...
    茶點故事閱讀 45,675評論 2 359

推薦閱讀更多精彩內容