字節碼加強技術探索

1.字節碼

1.1 什麼是字節碼?

Java之因此能夠「一次編譯,處處運行」,一是由於JVM針對各類操做系統、平臺都進行了定製,二是由於不管在什麼平臺,均可以編譯生成固定格式的字節碼(.class文件)供JVM使用。所以,也能夠看出字節碼對於Java生態的重要性。之因此被稱之爲字節碼,是由於字節碼文件由十六進制值組成,而JVM以兩個十六進制值爲一組,即以字節爲單位進行讀取。在Java中通常是用javac命令編譯源代碼爲字節碼文件,一個.java文件從編譯到運行的示例如圖1所示。html

圖1 Java運行示意圖

圖1 Java運行示意圖

 

對於開發人員,瞭解字節碼能夠更準確、直觀地理解Java語言中更深層次的東西,好比經過字節碼,能夠很直觀地看到Volatile關鍵字如何在字節碼上生效。另外,字節碼加強技術在Spring AOP、各類ORM框架、熱部署中的應用家常便飯,深刻理解其原理對於咱們來講大有裨益。除此以外,因爲JVM規範的存在,只要最終能夠生成符合規範的字節碼就能夠在JVM上運行,所以這就給了各類運行在JVM上的語言(如Scala、Groovy、Kotlin)一種契機,能夠擴展Java所沒有的特性或者實現各類語法糖。理解字節碼後再學習這些語言,能夠「逆流而上」,從字節碼視角看它的設計思路,學習起來也「易如反掌」。java

本文重點着眼於字節碼加強技術,從字節碼開始逐層向上,由JVM字節碼操做集合到Java中操做字節碼的框架,再到咱們熟悉的各種框架原理及應用,也都會一一進行介紹。web

1.2 字節碼結構

.java文件經過javac編譯後將獲得一個.class文件,好比編寫一個簡單的ByteCodeDemo類,以下圖2的左側部分:編程

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

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

 

編譯後生成ByteCodeDemo.class文件,打開後是一堆十六進制數,按字節爲單位進行分割後展現如圖2右側部分所示。上文說起過,JVM對於字節碼是有規範要求的,那麼看似雜亂的十六進制符合什麼結構呢?JVM規範要求每個字節碼文件都要由十部分按照固定的順序組成,總體結構如圖3所示。接下來咱們將一一介紹這十部分:api

圖3 JVM規定的字節碼結構

圖3 JVM規定的字節碼結構

 

(1) 魔數(Magic Number)數組

全部的.class文件的前四個字節都是魔數,魔數的固定值爲:0xCAFEBABE。魔數放在文件開頭,JVM能夠根據文件的開頭來判斷這個文件是否多是一個.class文件,若是是,纔會繼續進行以後的操做。緩存

有趣的是,魔數的固定值是Java之父James Gosling制定的,爲CafeBabe(咖啡寶貝),而Java的圖標爲一杯咖啡。安全

(2) 版本號數據結構

版本號爲魔數以後的4個字節,前兩個字節表示次版本號(Minor Version),後兩個字節表示主版本號(Major Version)。上圖2中版本號爲「00 00 00 34」,次版本號轉化爲十進制爲0,主版本號轉化爲十進制爲52,在Oracle官網中查詢序號52對應的主版本號爲1.8,因此編譯該文件的Java版本號爲1.8.0。架構

(3) 常量池(Constant Pool)

緊接着主版本號以後的字節爲常量池入口。常量池中存儲兩類常量:字面量與符號引用。字面量爲代碼中聲明爲Final的常量值,符號引用如類和接口的全侷限定名、字段的名稱和描述符、方法的名稱和描述符。常量池總體上分爲兩部分:常量池計數器以及常量池數據區,以下圖4所示。

圖4 常量池的結構

圖4 常量池的結構

 

  • 常量池計數器(constant_pool_count):因爲常量的數量不固定,因此須要先放置兩個字節來表示常量池容量計數值。圖2中示例代碼的字節碼前10個字節以下圖5所示,將十六進制的24轉化爲十進制值爲36,排除掉下標「0」,也就是說,這個類文件中共有35個常量。

圖5 前十個字節及含義

圖5 前十個字節及含義

 

  • 常量池數據區:數據區是由(constant_pool_count-1)個cp_info結構組成,一個cp_info結構對應一個常量。在字節碼中共有14種類型的cp_info(以下圖6所示),每種類型的結構都是固定的。

圖6 各種型的cp_info

圖6 各種型的cp_info

 

具體以CONSTANT_utf8_info爲例,它的結構以下圖7左側所示。首先一個字節「tag」,它的值取自上圖6中對應項的Tag,因爲它的類型是utf8_info,因此值爲「01」。接下來兩個字節標識該字符串的長度Length,而後Length個字節爲這個字符串具體的值。從圖2中的字節碼摘取一個cp_info結構,以下圖7右側所示。將它翻譯過來後,其含義爲:該常量類型爲utf8字符串,長度爲一字節,數據爲「a」。

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

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

 

其餘類型的cp_info結構在本文再也不贅述,總體結構大同小異,都是先經過Tag來標識類型,而後後續n個字節來描述長度和(或)數據。先知其因此然,之後能夠經過javap -verbose ByteCodeDemo命令,查看JVM反編譯後的完整常量池,以下圖8所示。能夠看到反編譯結果將每個cp_info結構的類型和值都很明確地呈現了出來。

圖8 常量池反編譯結果

圖8 常量池反編譯結果

 

(4) 訪問標誌

常量池結束以後的兩個字節,描述該Class是類仍是接口,以及是否被Public、Abstract、Final等修飾符修飾。JVM規範規定了以下圖9的訪問標誌(Access_Flag)。須要注意的是,JVM並無窮舉全部的訪問標誌,而是使用按位或操做來進行描述的,好比某個類的修飾符爲Public Final,則對應的訪問修飾符的值爲ACC_PUBLIC | ACC_FINAL,即0x0001 | 0x0010=0x0011。

圖9 訪問標誌

圖9 訪問標誌

 

(5) 當前類名

訪問標誌後的兩個字節,描述的是當前類的全限定名。這兩個字節保存的值爲常量池中的索引值,根據索引值就能在常量池中找到這個類的全限定名。

(6) 父類名稱

當前類名後的兩個字節,描述父類的全限定名,同上,保存的也是常量池中的索引值。

(7) 接口信息

父類名稱後爲兩字節的接口計數器,描述了該類或父類實現的接口數量。緊接着的n個字節是全部接口名稱的字符串常量的索引值。

(8) 字段表

字段表用於描述類和接口中聲明的變量,包含類級別的變量以及實例變量,可是不包含方法內部聲明的局部變量。字段表也分爲兩部分,第一部分爲兩個字節,描述字段個數;第二部分是每一個字段的詳細信息fields_info。字段表結構以下圖所示:

圖10 字段表結構

圖10 字段表結構

 

以圖2中字節碼的字段表爲例,以下圖11所示。其中字段的訪問標誌查圖9,0002對應爲Private。經過索引下標在圖8中常量池分別獲得字段名爲「a」,描述符爲「I」(表明int)。綜上,就能夠惟一肯定出一個類中聲明的變量private int a。

圖11 字段表示例

圖11 字段表示例

 

(9)方法表

字段表結束後爲方法表,方法表也是由兩部分組成,第一部分爲兩個字節描述方法的個數;第二部分爲每一個方法的詳細信息。方法的詳細信息較爲複雜,包括方法的訪問標誌、方法名、方法的描述符以及方法的屬性,以下圖所示:

圖12 方法表結構

圖12 方法表結構

 

方法的權限修飾符依然能夠經過圖9的值查詢獲得,方法名和方法的描述符都是常量池中的索引值,能夠經過索引值在常量池中找到。而「方法的屬性」這一部分較爲複雜,直接藉助javap -verbose將其反編譯爲人能夠讀懂的信息進行解讀,如圖13所示。能夠看到屬性中包括如下三個部分:

  • 「Code區」:源代碼對應的JVM指令操做碼,在進行字節碼加強時重點操做的就是「Code區」這一部分。
  • 「LineNumberTable」:行號表,將Code區的操做碼和源代碼中的行號對應,Debug時會起到做用(源代碼走一行,須要走多少個JVM指令操做碼)。
  • 「LocalVariableTable」:本地變量表,包含This和局部變量,之因此能夠在每個方法內部均可以調用This,是由於JVM將This做爲每個方法的第一個參數隱式進行傳入。固然,這是針對非Static方法而言。

圖13 反編譯後的方法表

圖13 反編譯後的方法表

 

(10)附加屬性表

字節碼的最後一部分,該項存放了在該文件中類或接口所定義屬性的基本信息。

1.3 字節碼操做集合

在上圖13中,Code區的紅色編號0~17,就是.java中的方法源代碼編譯後讓JVM真正執行的操做碼。爲了幫助人們理解,反編譯後看到的是十六進制操做碼所對應的助記符,十六進制值操做碼與助記符的對應關係,以及每個操做碼的用處能夠查看Oracle官方文檔進行了解,在須要用到時進行查閱便可。好比上圖中第一個助記符爲iconst_2,對應到圖2中的字節碼爲0x05,用處是將int值2壓入操做數棧中。以此類推,對0~17的助記符理解後,就是完整的add()方法的實現。

1.4 操做數棧和字節碼

JVM的指令集是基於棧而不是寄存器,基於棧能夠具有很好的跨平臺性(由於寄存器指令集每每和硬件掛鉤),但缺點在於,要完成一樣的操做,基於棧的實現須要更多指令才能完成(由於棧只是一個FILO結構,須要頻繁壓棧出棧)。另外,因爲棧是在內存實現的,而寄存器是在CPU的高速緩存區,相較而言,基於棧的速度要慢不少,這也是爲了跨平臺性而作出的犧牲。

咱們在上文所說的操做碼或者操做集合,其實控制的就是這個JVM的操做數棧。爲了更直觀地感覺操做碼是如何控制操做數棧的,以及理解常量池、變量表的做用,將add()方法的對操做數棧的操做製做爲GIF,以下圖14所示,圖中僅截取了常量池中被引用的部分,以指令iconst_2開始到ireturn結束,與圖13中Code區0~17的指令一一對應:

圖14 控制操做數棧示意圖

圖14 控制操做數棧示意圖

 

1.5 查看字節碼工具

若是每次查看反編譯後的字節碼都使用javap命令的話,好很是繁瑣。這裏推薦一個Idea插件:jclasslib。使用效果如圖15所示,代碼編譯後在菜單欄」View」中選擇」Show Bytecode With jclasslib」,能夠很直觀地看到當前字節碼文件的類信息、常量池、方法區等信息。

圖15 jclasslib查看字節碼

圖15 jclasslib查看字節碼

 

2. 字節碼加強

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

圖16 字節碼加強技術

圖16 字節碼加強技術

 

2.1 ASM

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

圖17 ASM修改字節碼

圖17 ASM修改字節碼

 

2.1.1 ASM API

2.1.1.1 核心API

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

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

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

2.1.2 直接利用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文件進行查看,能夠看到反編譯後的代碼已經改變了(如圖18左側所示)。而後寫一個測試類MyTest,在其中new Base(),並調用base.process()方法,能夠看到下圖右側所示的AOP實現效果:

圖18 ASM實現AOP的效果

圖18 ASM實現AOP的效果

 

2.1.3 ASM工具

利用ASM手寫字節碼時,須要利用一系列visitXXXXInsn()方法來寫對應的助記符,因此須要先將每一行源代碼轉化爲一個個的助記符,而後經過ASM的語法轉換爲visitXXXXInsn()這種寫法。第一步將源碼轉化爲助記符就已經夠麻煩了,不熟悉字節碼操做集合的話,須要咱們將代碼編譯後再反編譯,才能獲得源代碼對應的助記符。第二步利用ASM寫字節碼時,如何傳參也很使人頭疼。ASM社區也知道這兩個問題,因此提供了工具ASM ByteCode Outline

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

圖19 ASM Bytecode Outline

圖19 ASM Bytecode Outline

 

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

3. 運行時類的重載

3.1 問題引出

上一章重點介紹了兩種不一樣類型的字節碼操做框架,且都利用它們實現了較爲粗糙的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是不容許在運行時動態重載一個類的。

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

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

 

顯然,若是隻能在類加載前對類進行強化,那字節碼加強技術的使用場景就變得很窄了。咱們指望的效果是:在一個持續運行並已經加載了全部類的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"); } } 

3.2 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!"); } } } 

3.3 JVMTI & Agent & Attach API

上一小節中,咱們給出了Agent類的代碼,追根溯源須要先介紹JPDA(Java Platform Debugger Architecture)。若是JVM啓動時開啓了JPDA,那麼類是容許被從新加載的。在這種狀況下,已被加載的舊版本類信息能夠被卸載,而後從新加載新版本的類。正如JDPA名稱中的Debugger,JDPA實際上是一套用於調試Java程序的標準,任何JDK都必須實現該標準。

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

圖21 JPDA

圖21 JPDA

 

如今回到正題,咱們能夠藉助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的全限定名,以下圖所示;

圖22 Manifest.mf

圖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,將指定類的字節碼經過定義的類轉化器TestTransformer作了Base類的字節碼替換(經過javassist),並完成了類的從新加載。由此,咱們達成了「在JVM運行時,改變類的字節碼並從新載入類信息」的目的。

如下爲運行時從新載入類的效果:先運行Base中的main()方法,啓動一個JVM,能夠在控制檯看到每隔五秒輸出一次」process」。接着執行Attacher中的main()方法,並將上一個JVM的pid傳入。此時回到上一個main()方法的控制檯,能夠看到如今每隔五秒輸出」process」先後會分別輸出」start」和」end」,也就是說完成了運行時的字節碼加強,並從新載入了這個類。

圖23 運行時重載入類的效果

圖23 運行時重載入類的效果

 

3.4 使用場景

至此,字節碼加強技術的可以使用範圍就再也不侷限於JVM加載類前了。經過上述幾個類庫,咱們能夠在運行時對JVM中的類進行修改並重載了。經過這種手段,能夠作的事情就變得不少了:

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

4. 總結

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

5. 參考文獻

做者簡介

澤恩,美團點評研發工程師。

招聘信息

美團到店住宿業務研發團隊負責美團酒店核心業務系統建設,致力於經過技術踐行「幫你們住得更好」的使命。美團酒店多次刷新行業記錄,最近12個月酒店預訂間夜量達到3個億,單日入住間夜量峯值突破280萬。團隊的願景是:建設打造旅遊住宿行業一流的技術架構,從質量、安全、效率、性能多角度保障系統高速發展。

美團到店事業羣住宿業務研發團隊現誠聘後臺開發工程師/技術專家,歡迎有興趣的同窗投簡歷至:tech@meituan.com(註明:美團到店事業羣住宿業務研發團隊)

相關文章
相關標籤/搜索