詳解JVM基礎之字節碼的增強技術

字節碼增強技術

在上文中,著重介紹瞭字節碼的結構,這為我們瞭解字節碼增強技術的實現打下瞭基礎。字節碼增強技術就是一類對現有字節碼進行修改或者動態生成全新字節碼文件的技術。接下來,我們將從最直接操縱字節碼的實現方式開始深入進行剖析

ASM

對於需要手動操縱字節碼的需求,可以使用ASM,它可以直接生產 .class字節碼文件,也可以在類被加載入JVM之前動態修改類行為(如下圖17所示)。ASM的應用場景有AOP(Cglib就是基於ASM)、熱部署、修改其他jar包中的類等。當然,涉及到如此底層的步驟,實現起來也比較麻煩。接下來,本文將介紹ASM的兩種API,並用ASM來實現一個比較粗糙的AOP。但在此之前,為瞭讓大傢更快地理解ASM的處理流程,強烈建議讀者先對訪問者模式進行瞭解。簡單來說,訪問者模式主要用於修改或操作一些數據結構比較穩定的數據,而通過第一章,我們知道字節碼文件的結構是由JVM固定的,所以很適合利用訪問者模式對字節碼文件進行修改。

ASM API

核心API

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

  • ClassReader:用於讀取已經編譯好的.class文件。
  • ClassWriter:用於重新構建編譯後的類,如修改類名、屬性以及方法,也可以生成新的類的字節碼文件。
  • 各種Visitor類:如上所述,CoreAPI根據字節碼從上到下依次處理,對於字節碼文件中不同的區域有不同的Visitor,比如用於訪問方法的MethodVisitor、用於訪問類變量的FieldVisitor、用於訪問註解的AnnotationVisitor等。為瞭實現AOP,重點要使用的是MethodVisitor。

樹形API

ASM Tree API可以類比解析XML文件中的DOM方式,把整個類的結構讀取到內存中,缺點是消耗內存多,但是編程比較簡單。TreeApi不同於CoreAPI,TreeAPI通過各種Node類來映射字節碼的各個區域,類比DOM節點,就可以很好地理解這種編程方式。

直接利用ASM實現AOP

利用ASM的CoreAPI來增強類。這裡不糾結於AOP的專業名詞如切片、通知,隻實現在方法調用前、後增加邏輯,通俗易懂且方便理解。首先定義需要被增強的Base類:其中隻包含一個process()方法,方法內輸出一行“process”。增強後,我們期望的是,方法執行前輸出“start”,之後輸出”end”。

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

為瞭利用ASM實現AOP,需要定義兩個類:一個是MyClassVisitor類,用於對字節碼的visit以及修改;另一個是Generator類,在這個類中定義ClassReader和ClassWriter,其中的邏輯是,classReader讀取字節碼,然後交給MyClassVisitor類處理,處理完成後由ClassWriter寫字節碼並將舊的字節碼替換掉。Generator類較簡單,我們先看一下它的實現,如下所示,然後重點解釋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,用於對字節碼的觀察。它還包含一個內部類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);
        }
    }
}

利用這個類就可以實現對字節碼的修改。詳細解讀其中的代碼,對字節碼做修改的步驟是:

  • 首先通過MyClassVisitor類中的visitMethod方法,判斷當前字節碼讀到哪一個方法瞭。跳過構造方法 <init> 後,將需要被增強的方法交給內部類MyMethodVisitor來進行處理。
  • 接下來,進入內部類MyMethodVisitor中的visitCode方法,它會在ASM開始訪問某一個方法的Code區時被調用,重寫visitCode方法,將AOP中的前置邏輯就放在這裡。 MyMethodVisitor繼續讀取字節碼指令,每當ASM訪問到無參數指令時,都會調用MyMethodVisitor中的visitInsn方法。我們判斷瞭當前指令是否為無參數的“return”指令,如果是就在它的前面添加一些指令,也就是將AOP的後置邏輯放在該方法中。
  • 綜上,重寫MyMethodVisitor中的兩個方法,就可以實現AOP瞭,而重寫方法時就需要用ASM的寫法,手動寫入或者修改字節碼。通過調用methodVisitor的visitXXXXInsn()方法就可以實現字節碼的插入,XXXX對應相應的操作碼助記符類型,比如mv.visitLdcInsn(“end”)對應的操作碼就是ldc “end”,即將字符串“end”壓入棧。 完成這兩個visitor類後,運行Generator中的main方法完成對Base類的字節碼增強,增強後的結果可以在編譯後的target文件夾中找到Base.class文件進行查看,可以看到反編譯後的代碼已經改變瞭。然後寫一個測試類MyTest,在其中new Base(),並調用base.process()方法,可以看到下圖右側所示的AOP實現效果:

ASM工具

利用ASM手寫字節碼時,需要利用一系列visitXXXXInsn()方法來寫對應的助記符,所以需要先將每一行源代碼轉化為一個個的助記符,然後通過ASM的語法轉換為visitXXXXInsn()這種寫法。第一步將源碼轉化為助記符就已經夠麻煩瞭,不熟悉字節碼操作集合的話,需要我們將代碼編譯後再反編譯,才能得到源代碼對應的助記符。第二步利用ASM寫字節碼時,如何傳參也很令人頭疼。ASM社區也知道這兩個問題,所以提供瞭工具ASM ByteCode Outline (opens new window)。

安裝後,右鍵選擇“Show Bytecode Outline”,在新標簽頁中選擇“ASMified”這個tab,如圖19所示,就可以看到這個類中的代碼對應的ASM寫法瞭。圖中上下兩個紅框分別對應AOP中的前置邏輯於後置邏輯,將這兩塊直接復制到visitor中的visitMethod()以及visitInsn()方法中,就可以瞭。

Javassist

ASM是在指令層次上操作字節碼的,閱讀上文後,我們的直觀感受是在指令層次上操作字節碼的框架實現起來比較晦澀。故除此之外,我們再簡單介紹另外一類框架:強調源代碼層次操作字節碼的框架Javassist。

利用Javassist實現字節碼增強時,可以無須關註字節碼刻板的結構,其優點就在於編程簡單。直接使用java編碼的形式,而不需要瞭解虛擬機指令,就能動態改變類的結構或者動態生成類。其中最重要的是ClassPool、CtClass、CtMethod、CtField這四個類:

  • CtClass(compile-time class):編譯時類信息,它是一個class文件在代碼中的抽象表現形式,可以通過一個類的全限定名來獲取一個CtClass對象,用來表示這個類文件。
  • ClassPool:從開發視角來看,ClassPool是一張保存CtClass信息的HashTable,key為類名,value為類名對應的CtClass對象。當我們需要對某個類進行修改時,就是通過pool.getCtClass(“className”)方法從pool中獲取到相應的CtClass。
  • CtMethod、CtField:這兩個比較好理解,對應的是類中的方法和屬性。

瞭解這四個類後,我們可以寫一個小Demo來展示Javassist簡單、快速的特點。我們依然是對Base中的process()方法做增強,在方法調用前後分別輸出”start”和”end”,實現代碼如下。我們需要做的就是從pool中獲取到相應的CtClass對象和其中的方法,然後執行method.insertBefore和insertAfter方法,參數為要插入的Java代碼,再以字符串的形式傳入即可,實現起來也極為簡單。

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();
    }
}

運行時類的重載

問題引出

上一章重點介紹瞭兩種不同類型的字節碼操作框架,且都利用它們實現瞭較為粗糙的AOP。其實,為瞭方便大傢理解字節碼增強技術,在上文中我們避重就輕將ASM實現AOP的過程分為瞭兩個main方法:第一個是利用MyClassVisitor對已編譯好的class文件進行修改,第二個是new對象並調用。這期間並不涉及到JVM運行時對類的重加載,而是在第一個main方法中,通過ASM對已編譯類的字節碼進行替換,在第二個main方法中,直接使用已替換好的新類信息。另外在Javassist的實現中,我們也隻加載瞭一次Base類,也不涉及到運行時重加載類。

如果我們在一個JVM中,先加載瞭一個類,然後又對其進行字節碼增強並重新加載會發生什麼呢?模擬這種情況,隻需要我們在上文中Javassist的Demo中main()方法的第一行添加Base b=new Base(),即在增強前就先讓JVM加載Base類,然後在執行到c.toClass()方法時會拋出錯誤,如下圖20所示。跟進c.toClass()方法中,我們會發現它是在最後調用瞭ClassLoader的native方法defineClass()時報錯。也就是說,JVM是不允許在運行時動態重載一個類的。

顯然,如果隻能在類加載前對類進行強化,那字節碼增強技術的使用場景就變得很窄瞭。我們期望的效果是:在一個持續運行並已經加載瞭所有類的JVM中,還能利用字節碼增強技術對其中的類行為做替換並重新加載。為瞭模擬這種情況,我們將Base類做改寫,在其中編寫main方法,每五秒調用一次process()方法,在process()方法中輸出一行“process”。

我們的目的就是,在JVM運行中的時候,將process()方法做替換,在其前後分別打印“start”和“end”。也就是在運行中時,每五秒打印的內容由”process”變為打印”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");
    }
}

Instrument

instrument是JVM提供的一個可以修改已加載類的類庫,專門為Java語言編寫的插樁服務提供支持。它需要依賴JVMTI的Attach API機制實現,JVMTI這一部分,我們將在下一小節進行介紹。在JDK 1.6以前,instrument隻能在JVM剛啟動開始加載類時生效,而在JDK 1.6之後,instrument支持瞭在運行時對類定義的修改。要使用instrument的類修改功能,我們需要實現它提供的ClassFileTransformer接口,定義一個類文件轉換器。接口中的transform()方法會在類文件被加載時調用,而在transform方法裡,我們可以利用上文中的ASM或Javassist對傳入的字節碼進行改寫或替換,生成新的字節碼數組後返回。

我們定義一個實現瞭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;
    }
}

現在有瞭Transformer,那麼它要如何註入到正在運行的JVM呢?還需要定義一個Agent,借助Agent的能力將Instrument註入到JVM中。我們將在下一小節介紹Agent,現在要介紹的是Agent中用到的另一個類Instrumentation。在JDK 1.6之後,Instrumentation可以做啟動後的Instrument、本地代碼(Native Code)的Instrument,以及動態改變Classpath等等。我們可以向Instrumentation中添加上文中定義的Transformer,並指定要被重加載的類,代碼如下所示。這樣,當Agent被Attach到一個JVM中時,就會執行類字節碼替換並重載入JVM的操作。

import java.lang.instrument.Instrumentation;

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

JVMTI & Agent & Attach API

上一小節中,我們給出瞭Agent類的代碼,追根溯源需要先介紹JPDA(Java Platform Debugger Architecture)。如果JVM啟動時開啟瞭JPDA,那麼類是允許被重新加載的。在這種情況下,已被加載的舊版本類信息可以被卸載,然後重新加載新版本的類。正如JDPA名稱中的Debugger,JDPA其實是一套用於調試Java程序的標準,任何JDK都必須實現該標準。

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

現在回到正題,我們可以借助JVMTI的一部分能力,幫助動態重載類信息。JVM TI(JVM TOOL INTERFACE,JVM工具接口)是JVM提供的一套對JVM進行操作的工具接口。通過JVMTI,可以實現對JVM的多種操作,它通過接口註冊各種事件勾子,在JVM事件觸發時,同時觸發預定義的勾子,以實現對各個JVM事件的響應,事件包括類文件加載、異常產生與捕獲、線程啟動和結束、進入和退出臨界區、成員變量修改、GC開始和結束、方法調用進入和退出、臨界區競爭與等待、VM啟動與退出等等。

而Agent就是JVMTI的一種實現,Agent有兩種啟動方式,一是隨Java進程啟動而啟動,經常見到的java -agentlib就是這種方式;二是運行時載入,通過attach API,將模塊(jar包)動態地Attach到指定進程id的Java進程內。

Attach API 的作用是提供JVM進程間通信的能力,比如說我們為瞭讓另外一個JVM進程把線上服務的線程Dump出來,會運行jstack或jmap的進程,並傳遞pid的參數,告訴它要對哪個進程進行線程Dump,這就是Attach API做的事情。在下面,我們將通過Attach API的loadAgent()方法,將打包好的Agent jar包動態Attach到目標JVM上。具體實現起來的步驟如下:

  • 定義Agent,並在其中實現AgentMain方法,如上一小節中定義的代碼塊7中的TestAgent類;
  • 然後將TestAgent類打成一個包含MANIFEST.MF的jar包,其中MANIFEST.MF文件中將Agent-Class屬性指定為TestAgent的全限定名,如下圖所示;

最後利用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,將指定類的字節碼通過定義的類轉化器TestTransformer做瞭Base類的字節碼替換(通過javassist),並完成瞭類的重新加載。由此,我們達成瞭“在JVM運行時,改變類的字節碼並重新載入類信息”的目的。

以下為運行時重新載入類的效果:先運行Base中的main()方法,啟動一個JVM,可以在控制臺看到每隔五秒輸出一次”process”。接著執行Attacher中的main()方法,並將上一個JVM的pid傳入。此時回到上一個main()方法的控制臺,可以看到現在每隔五秒輸出”process”前後會分別輸出”start”和”end”,也就是說完成瞭運行時的字節碼增強,並重新載入瞭這個類。

使用場景

至此,字節碼增強技術的可使用范圍就不再局限於JVM加載類前瞭。通過上述幾個類庫,我們可以在運行時對JVM中的類進行修改並重載瞭。通過這種手段,可以做的事情就變得很多瞭:

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

總結

字節碼增強技術相當於是一把打開運行時JVM的鑰匙,利用它可以動態地對運行中的程序做修改,也可以跟蹤JVM運行中程序的狀態。此外,我們平時使用的動態代理、AOP也與字節碼增強密切相關,它們實質上還是利用各種手段生成符合規范的字節碼文件。綜上所述,掌握字節碼增強後可以高效地定位並快速修復一些棘手的問題(如線上性能問題、方法出現不可控的出入參需要緊急加日志等問題),也可以在開發中減少冗餘代碼,大大提高開發效率。

以上就是詳解JVM基礎之字節碼的增強技術的詳細內容,更多關於JVM字節碼增強技術的資料請關註WalkonNet其它相關文章!

推薦閱讀: