詳解JVM基礎(chǔ)之字節(jié)碼的增強技術(shù)
字節(jié)碼增強技術(shù)
在上文中,著重介紹了字節(jié)碼的結(jié)構(gòu),這為我們了解字節(jié)碼增強技術(shù)的實現(xiàn)打下了基礎(chǔ)。字節(jié)碼增強技術(shù)就是一類對現(xiàn)有字節(jié)碼進行修改或者動態(tài)生成全新字節(jié)碼文件的技術(shù)。接下來,我們將從最直接操縱字節(jié)碼的實現(xiàn)方式開始深入進行剖析
ASM
對于需要手動操縱字節(jié)碼的需求,可以使用ASM,它可以直接生產(chǎn) .class字節(jié)碼文件,也可以在類被加載入JVM之前動態(tài)修改類行為(如下圖17所示)。ASM的應(yīng)用場景有AOP(Cglib就是基于ASM)、熱部署、修改其他jar包中的類等。當(dāng)然,涉及到如此底層的步驟,實現(xiàn)起來也比較麻煩。接下來,本文將介紹ASM的兩種API,并用ASM來實現(xiàn)一個比較粗糙的AOP。但在此之前,為了讓大家更快地理解ASM的處理流程,強烈建議讀者先對訪問者模式進行了解。簡單來說,訪問者模式主要用于修改或操作一些數(shù)據(jù)結(jié)構(gòu)比較穩(wěn)定的數(shù)據(jù),而通過第一章,我們知道字節(jié)碼文件的結(jié)構(gòu)是由JVM固定的,所以很適合利用訪問者模式對字節(jié)碼文件進行修改。
ASM API
核心API
ASM Core API可以類比解析XML文件中的SAX方式,不需要把這個類的整個結(jié)構(gòu)讀取進來,就可以用流式的方法來處理字節(jié)碼文件。好處是非常節(jié)約內(nèi)存,但是編程難度較大。然而出于性能考慮,一般情況下編程都使用Core API。在Core API中有以下幾個關(guān)鍵類:
- ClassReader:用于讀取已經(jīng)編譯好的.class文件。
- ClassWriter:用于重新構(gòu)建編譯后的類,如修改類名、屬性以及方法,也可以生成新的類的字節(jié)碼文件。
- 各種Visitor類:如上所述,CoreAPI根據(jù)字節(jié)碼從上到下依次處理,對于字節(jié)碼文件中不同的區(qū)域有不同的Visitor,比如用于訪問方法的MethodVisitor、用于訪問類變量的FieldVisitor、用于訪問注解的AnnotationVisitor等。為了實現(xiàn)AOP,重點要使用的是MethodVisitor。
樹形API
ASM Tree API可以類比解析XML文件中的DOM方式,把整個類的結(jié)構(gòu)讀取到內(nèi)存中,缺點是消耗內(nèi)存多,但是編程比較簡單。TreeApi不同于CoreAPI,TreeAPI通過各種Node類來映射字節(jié)碼的各個區(qū)域,類比DOM節(jié)點,就可以很好地理解這種編程方式。
直接利用ASM實現(xiàn)AOP
利用ASM的CoreAPI來增強類。這里不糾結(jié)于AOP的專業(yè)名詞如切片、通知,只實現(xiàn)在方法調(diào)用前、后增加邏輯,通俗易懂且方便理解。首先定義需要被增強的Base類:其中只包含一個process()方法,方法內(nèi)輸出一行“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é)碼的觀察。它還包含一個內(nèi)部類MyMethodVisitor,繼承自MethodVisitor用于對類內(nèi)方法的觀察,它的整體代碼如下:
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類中有兩個方法:無參構(gòu)造以及process方法,這里不增強構(gòu)造方法 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方法,判斷當(dāng)前字節(jié)碼讀到哪一個方法了。跳過構(gòu)造方法
<init>
后,將需要被增強的方法交給內(nèi)部類MyMethodVisitor來進行處理。 - 接下來,進入內(nèi)部類MyMethodVisitor中的visitCode方法,它會在ASM開始訪問某一個方法的Code區(qū)時被調(diào)用,重寫visitCode方法,將AOP中的前置邏輯就放在這里。 MyMethodVisitor繼續(xù)讀取字節(jié)碼指令,每當(dāng)ASM訪問到無參數(shù)指令時,都會調(diào)用MyMethodVisitor中的visitInsn方法。我們判斷了當(dāng)前指令是否為無參數(shù)的“return”指令,如果是就在它的前面添加一些指令,也就是將AOP的后置邏輯放在該方法中。
- 綜上,重寫MyMethodVisitor中的兩個方法,就可以實現(xiàn)AOP了,而重寫方法時就需要用ASM的寫法,手動寫入或者修改字節(jié)碼。通過調(diào)用methodVisitor的visitXXXXInsn()方法就可以實現(xiàn)字節(jié)碼的插入,XXXX對應(yīng)相應(yīng)的操作碼助記符類型,比如mv.visitLdcInsn(“end”)對應(yīng)的操作碼就是ldc “end”,即將字符串“end”壓入棧。 完成這兩個visitor類后,運行Generator中的main方法完成對Base類的字節(jié)碼增強,增強后的結(jié)果可以在編譯后的target文件夾中找到Base.class文件進行查看,可以看到反編譯后的代碼已經(jīng)改變了。然后寫一個測試類MyTest,在其中new Base(),并調(diào)用base.process()方法,可以看到下圖右側(cè)所示的AOP實現(xiàn)效果:
ASM工具
利用ASM手寫字節(jié)碼時,需要利用一系列visitXXXXInsn()方法來寫對應(yīng)的助記符,所以需要先將每一行源代碼轉(zhuǎn)化為一個個的助記符,然后通過ASM的語法轉(zhuǎn)換為visitXXXXInsn()這種寫法。第一步將源碼轉(zhuǎn)化為助記符就已經(jīng)夠麻煩了,不熟悉字節(jié)碼操作集合的話,需要我們將代碼編譯后再反編譯,才能得到源代碼對應(yīng)的助記符。第二步利用ASM寫字節(jié)碼時,如何傳參也很令人頭疼。ASM社區(qū)也知道這兩個問題,所以提供了工具ASM ByteCode Outline (opens new window)。
安裝后,右鍵選擇“Show Bytecode Outline”,在新標簽頁中選擇“ASMified”這個tab,如圖19所示,就可以看到這個類中的代碼對應(yīng)的ASM寫法了。圖中上下兩個紅框分別對應(yīng)AOP中的前置邏輯于后置邏輯,將這兩塊直接復(fù)制到visitor中的visitMethod()以及visitInsn()方法中,就可以了。
Javassist
ASM是在指令層次上操作字節(jié)碼的,閱讀上文后,我們的直觀感受是在指令層次上操作字節(jié)碼的框架實現(xiàn)起來比較晦澀。故除此之外,我們再簡單介紹另外一類框架:強調(diào)源代碼層次操作字節(jié)碼的框架Javassist。
利用Javassist實現(xiàn)字節(jié)碼增強時,可以無須關(guān)注字節(jié)碼刻板的結(jié)構(gòu),其優(yōu)點就在于編程簡單。直接使用java編碼的形式,而不需要了解虛擬機指令,就能動態(tài)改變類的結(jié)構(gòu)或者動態(tài)生成類。其中最重要的是ClassPool、CtClass、CtMethod、CtField這四個類:
- CtClass(compile-time class):編譯時類信息,它是一個class文件在代碼中的抽象表現(xiàn)形式,可以通過一個類的全限定名來獲取一個CtClass對象,用來表示這個類文件。
- ClassPool:從開發(fā)視角來看,ClassPool是一張保存CtClass信息的HashTable,key為類名,value為類名對應(yīng)的CtClass對象。當(dāng)我們需要對某個類進行修改時,就是通過pool.getCtClass(“className”)方法從pool中獲取到相應(yīng)的CtClass。
- CtMethod、CtField:這兩個比較好理解,對應(yīng)的是類中的方法和屬性。
了解這四個類后,我們可以寫一個小Demo來展示Javassist簡單、快速的特點。我們依然是對Base中的process()方法做增強,在方法調(diào)用前后分別輸出”start”和”end”,實現(xiàn)代碼如下。我們需要做的就是從pool中獲取到相應(yīng)的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(); } }
運行時類的重載
問題引出
上一章重點介紹了兩種不同類型的字節(jié)碼操作框架,且都利用它們實現(xiàn)了較為粗糙的AOP。其實,為了方便大家理解字節(jié)碼增強技術(shù),在上文中我們避重就輕將ASM實現(xiàn)AOP的過程分為了兩個main方法:第一個是利用MyClassVisitor對已編譯好的class文件進行修改,第二個是new對象并調(diào)用。這期間并不涉及到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)它是在最后調(diào)用了ClassLoader的native方法defineClass()時報錯。也就是說,JVM是不允許在運行時動態(tài)重載一個類的。
顯然,如果只能在類加載前對類進行強化,那字節(jié)碼增強技術(shù)的使用場景就變得很窄了。我們期望的效果是:在一個持續(xù)運行并已經(jīng)加載了所有類的JVM中,還能利用字節(jié)碼增強技術(shù)對其中的類行為做替換并重新加載。為了模擬這種情況,我們將Base類做改寫,在其中編寫main方法,每五秒調(diào)用一次process()方法,在process()方法中輸出一行“process”。
我們的目的就是,在JVM運行中的時候,將process()方法做替換,在其前后分別打印“start”和“end”。也就是在運行中時,每五秒打印的內(nèi)容由”process”變?yōu)榇蛴?rdquo;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]; //打印當(dāng)前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語言編寫的插樁服務(wù)提供支持。它需要依賴JVMTI的Attach API機制實現(xiàn),JVMTI這一部分,我們將在下一小節(jié)進行介紹。在JDK 1.6以前,instrument只能在JVM剛啟動開始加載類時生效,而在JDK 1.6之后,instrument支持了在運行時對類定義的修改。要使用instrument的類修改功能,我們需要實現(xiàn)它提供的ClassFileTransformer接口,定義一個類文件轉(zhuǎn)換器。接口中的transform()方法會在類文件被加載時調(diào)用,而在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,并指定要被重加載的類,代碼如下所示。這樣,當(dāng)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!"); } } }
JVMTI & Agent & Attach API
上一小節(jié)中,我們給出了Agent類的代碼,追根溯源需要先介紹JPDA(Java Platform Debugger Architecture)。如果JVM啟動時開啟了JPDA,那么類是允許被重新加載的。在這種情況下,已被加載的舊版本類信息可以被卸載,然后重新加載新版本的類。正如JDPA名稱中的Debugger,JDPA其實是一套用于調(diào)試Java程序的標準,任何JDK都必須實現(xiàn)該標準。
JPDA定義了一整套完整的體系,它將調(diào)試體系分為三部分,并規(guī)定了三者之間的通信接口。三部分由低到高分別是Java 虛擬機工具接口(JVMTI),Java 調(diào)試協(xié)議(JDWP)以及 Java 調(diào)試接口(JDI),三者之間的關(guān)系如下圖所示:
現(xiàn)在回到正題,我們可以借助JVMTI的一部分能力,幫助動態(tài)重載類信息。JVM TI(JVM TOOL INTERFACE,JVM工具接口)是JVM提供的一套對JVM進行操作的工具接口。通過JVMTI,可以實現(xiàn)對JVM的多種操作,它通過接口注冊各種事件勾子,在JVM事件觸發(fā)時,同時觸發(fā)預(yù)定義的勾子,以實現(xiàn)對各個JVM事件的響應(yīng),事件包括類文件加載、異常產(chǎn)生與捕獲、線程啟動和結(jié)束、進入和退出臨界區(qū)、成員變量修改、GC開始和結(jié)束、方法調(diào)用進入和退出、臨界區(qū)競爭與等待、VM啟動與退出等等。
而Agent就是JVMTI的一種實現(xiàn),Agent有兩種啟動方式,一是隨Java進程啟動而啟動,經(jīng)常見到的java -agentlib就是這種方式;二是運行時載入,通過attach API,將模塊(jar包)動態(tài)地Attach到指定進程id的Java進程內(nèi)。
Attach API 的作用是提供JVM進程間通信的能力,比如說我們?yōu)榱俗屃硗庖粋€JVM進程把線上服務(wù)的線程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的全限定名,如下圖所示;
最后利用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é)碼通過定義的類轉(zhuǎn)化器TestTransformer做了Base類的字節(jié)碼替換(通過javassist),并完成了類的重新加載。由此,我們達成了“在JVM運行時,改變類的字節(jié)碼并重新載入類信息”的目的。
以下為運行時重新載入類的效果:先運行Base中的main()方法,啟動一個JVM,可以在控制臺看到每隔五秒輸出一次”process”。接著執(zhí)行Attacher中的main()方法,并將上一個JVM的pid傳入。此時回到上一個main()方法的控制臺,可以看到現(xiàn)在每隔五秒輸出”process”前后會分別輸出”start”和”end”,也就是說完成了運行時的字節(jié)碼增強,并重新載入了這個類。
使用場景
至此,字節(jié)碼增強技術(shù)的可使用范圍就不再局限于JVM加載類前了。通過上述幾個類庫,我們可以在運行時對JVM中的類進行修改并重載了。通過這種手段,可以做的事情就變得很多了:
- 熱部署:不部署服務(wù)而對線上服務(wù)做修改,可以做打點、增加日志等操作。
- Mock:測試時候?qū)δ承┓?wù)做Mock。
- 性能診斷工具:比如bTrace就是利用Instrument,實現(xiàn)無侵入地跟蹤一個正在運行的JVM,監(jiān)控到類和方法級別的狀態(tài)信息。
總結(jié)
字節(jié)碼增強技術(shù)相當(dāng)于是一把打開運行時JVM的鑰匙,利用它可以動態(tài)地對運行中的程序做修改,也可以跟蹤JVM運行中程序的狀態(tài)。此外,我們平時使用的動態(tài)代理、AOP也與字節(jié)碼增強密切相關(guān),它們實質(zhì)上還是利用各種手段生成符合規(guī)范的字節(jié)碼文件。綜上所述,掌握字節(jié)碼增強后可以高效地定位并快速修復(fù)一些棘手的問題(如線上性能問題、方法出現(xiàn)不可控的出入?yún)⑿枰o急加日志等問題),也可以在開發(fā)中減少冗余代碼,大大提高開發(fā)效率。
以上就是詳解JVM基礎(chǔ)之字節(jié)碼的增強技術(shù)的詳細內(nèi)容,更多關(guān)于JVM字節(jié)碼增強技術(shù)的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
SpringBoot+Quartz+數(shù)據(jù)庫存儲的完美集合
這篇文章主要介紹了SpringBoot+Quartz+數(shù)據(jù)庫存儲的示例代碼,本文通過實例代碼圖文相結(jié)合給大家介紹的非常詳細,對大家的學(xué)習(xí)或工作具有一定的參考借鑒價值,需要的朋友可以參考下2022-02-02詳解springboot接口如何優(yōu)雅的接收時間類型參數(shù)
這篇文章主要為大家詳細介紹了springboot的接口如何優(yōu)雅的接收時間類型參數(shù),文中為大家整理了三種常見的方法,希望對大家有一定的幫助2023-09-09Springboot中使用Redis實現(xiàn)分布式鎖的示例代碼
在分布式系統(tǒng)中,為了保證數(shù)據(jù)的一致性和任務(wù)的互斥執(zhí)行,分布式鎖是一種常見的解決方案,本文主要介紹了Springboot中使用Redis實現(xiàn)分布式鎖的示例代碼,具有一定的參考價值,感興趣的可以了解一下2024-05-05