位元組碼增強技術探索

  • 2019 年 10 月 3 日
  • 筆記

1.位元組碼

1.1 什麼是位元組碼?

Java之所以可以“一次編譯,到處運行”,一是因為JVM針對各種作業系統、平台都進行了訂製,二是因為無論在什麼平台,都可以編譯生成固定格式的位元組碼(.class文件)供JVM使用。因此,也可以看出位元組碼對於Java生態的重要性。之所以被稱之為位元組碼,是因為位元組碼文件由十六進位值組成,而JVM以兩個十六進位值為一組,即以位元組為單位進行讀取。在Java中一般是用javac命令編譯源程式碼為位元組碼文件,一個.java文件從編譯到運行的示例如圖1所示。

圖1 Java運行示意圖

圖1 Java運行示意圖

 

對於開發人員,了解位元組碼可以更準確、直觀地理解Java語言中更深層次的東西,比如通過位元組碼,可以很直觀地看到Volatile關鍵字如何在位元組碼上生效。另外,位元組碼增強技術在Spring AOP、各種ORM框架、熱部署中的應用屢見不鮮,深入理解其原理對於我們來說大有裨益。除此之外,由於JVM規範的存在,只要最終可以生成符合規範的位元組碼就可以在JVM上運行,因此這就給了各種運行在JVM上的語言(如Scala、Groovy、Kotlin)一種契機,可以擴展Java所沒有的特性或者實現各種語法糖。理解位元組碼後再學習這些語言,可以“逆流而上”,從位元組碼視角看它的設計思路,學習起來也“易如反掌”。

本文重點著眼於位元組碼增強技術,從位元組碼開始逐層向上,由JVM位元組碼操作集合到Java中操作位元組碼的框架,再到我們熟悉的各類框架原理及應用,也都會一一進行介紹。

1.2 位元組碼結構

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

圖2 示例程式碼(左側)及對應的位元組碼(右側)

圖2 示例程式碼(左側)及對應的位元組碼(右側)

 

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

圖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萬。團隊的願景是:建設打造旅遊住宿行業一流的技術架構,從品質、安全、效率、性能多角度保障系統高速發展。

美團到店事業群住宿業務研發團隊現誠聘後台開發工程師/技術專家,歡迎有興趣的同學投簡歷至:[email protected](註明:美團到店事業群住宿業務研發團隊)