idea如何反编译字节码指令_美团点评:Java字节码增强技术,线上问题诊断利器...
作者簡介:澤恩,美團到店住宿業務研發團隊工程師。文章轉載于公眾號:美團技術團隊
1. 字節碼
1.1 什么是字節碼?
Java之所以可以“一次編譯,到處運行”,一是因為JVM針對各種操作系統、平臺都進行了定制,二是因為無論在什么平臺,都可以編譯生成固定格式的字節碼(.class文件)供JVM使用。因此,也可以看出字節碼對于Java生態的重要性。之所以被稱之為字節碼,是因為字節碼文件由十六進制值組成,而JVM以兩個十六進制值為一組,即以字節為單位進行讀取。在Java中一般是用javac命令編譯源代碼為字節碼文件,一個.java文件從編譯到運行的示例如圖1所示。
圖1 Java運行示意圖
對于開發人員,了解字節碼可以更準確、直觀地理解Java語言中更深層次的東西,比如通過字節碼,可以很直觀地看到Volatile關鍵字如何在字節碼上生效。另外,字節碼增強技術在Spring AOP、各種ORM框架、熱部署中的應用屢見不鮮,深入理解其原理對于我們來說大有裨益。除此之外,由于JVM規范的存在,只要最終可以生成符合規范的字節碼就可以在JVM上運行,因此這就給了各種運行在JVM上的語言(如Scala、Groovy、Kotlin)一種契機,可以擴展Java所沒有的特性或者實現各種語法糖。理解字節碼后再學習這些語言,可以“逆流而上”,從字節碼視角看它的設計思路,學習起來也“易如反掌”。
本文重點著眼于字節碼增強技術,從字節碼開始逐層向上,由JVM字節碼操作集合到Java中操作字節碼的框架,再到我們熟悉的各類框架原理及應用,也都會一一進行介紹。
1.2 字節碼結構
.java文件通過javac編譯后將得到一個.class文件,比如編寫一個簡單的ByteCodeDemo類,如下圖2的左側部分:
圖2 示例代碼(左側)及對應的字節碼(右側)
編譯后生成ByteCodeDemo.class文件,打開后是一堆十六進制數,按字節為單位進行分割后展示如圖2右側部分所示。上文提及過,JVM對于字節碼是有規范要求的,那么看似雜亂的十六進制符合什么結構呢?JVM規范要求每一個字節碼文件都要由十部分按照固定的順序組成,整體結構如圖3所示。接下來我們將一一介紹這十個部分:
圖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 常量池的結構
- 常量池計數器(constant_pool_count):由于常量的數量不固定,所以需要先放置兩個字節來表示常量池容量計數值。圖2中示例代碼的字節碼前10個字節如下圖5所示,將十六進制的24轉化為十進制值為36,排除掉下標“0”,也就是說,這個類文件中共有35個常量。
圖5 前十個字節及含義
- 常量池數據區:數據區是由(constant_pool_count-1)個cp_info結構組成,一個cp_info結構對應一個常量。在字節碼中共有14種類型的cp_info(如下圖6所示),每種類型的結構都是固定的。
圖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的結構(左)及示例(右)
其他類型的cp_info結構在本文不再贅述,整體結構大同小異,都是先通過Tag來標識類型,然后后續n個字節來描述長度和(或)數據。先知其所以然,以后可以通過javap -verbose ByteCodeDemo命令,查看JVM反編譯后的完整常量池,如下圖8所示。可以看到反編譯結果將每一個cp_info結構的類型和值都很明確地呈現了出來。
圖8 常量池反編譯結果
(4) 訪問標志
常量池結束之后的兩個字節,描述該Class是類還是接口,以及是否被Public、Abstract、Final等修飾符修飾。JVM規范規定了如下圖9的訪問標志(Access_Flag)。需要注意的是,JVM并沒有窮舉所有的訪問標志,而是使用按位或操作來進行描述的,比如某個類的修飾符為Public Final,則對應的訪問修飾符的值為ACC_PUBLIC | ACC_FINAL,即0x0001 | 0x0010=0x0011。
圖9 訪問標志
(5) 當前類名
訪問標志后的兩個字節,描述的是當前類的全限定名。這兩個字節保存的值為常量池中的索引值,根據索引值就能在常量池中找到這個類的全限定名。
(6) 父類名稱
當前類名后的兩個字節,描述父類的全限定名,同上,保存的也是常量池中的索引值。
(7) 接口信息
父類名稱后為兩字節的接口計數器,描述了該類或父類實現的接口數量。緊接著的n個字節是所有接口名稱的字符串常量的索引值。
(8) 字段表
字段表用于描述類和接口中聲明的變量,包含類級別的變量以及實例變量,但是不包含方法內部聲明的局部變量。字段表也分為兩部分,第一部分為兩個字節,描述字段個數;第二部分是每個字段的詳細信息fields_info。字段表結構如下圖所示:
圖10 字段表結構
以圖2中字節碼的字段表為例,如下圖11所示。其中字段的訪問標志查圖9,0002對應為Private。通過索引下標在圖8中常量池分別得到字段名為“a”,描述符為“I”(代表int)。綜上,就可以唯一確定出一個類中聲明的變量private int a。
圖11 字段表示例
(9)方法表
字段表結束后為方法表,方法表也是由兩部分組成,第一部分為兩個字節描述方法的個數;第二部分為每個方法的詳細信息。方法的詳細信息較為復雜,包括方法的訪問標志、方法名、方法的描述符以及方法的屬性,如下圖所示:
圖12 方法表結構
方法的權限修飾符依然可以通過圖9的值查詢得到,方法名和方法的描述符都是常量池中的索引值,可以通過索引值在常量池中找到。而“方法的屬性”這一部分較為復雜,直接借助javap -verbose將其反編譯為人可以讀懂的信息進行解讀,如圖13所示。可以看到屬性中包括以下三個部分:
- “Code區”:源代碼對應的JVM指令操作碼,在進行字節碼增強時重點操作的就是“Code區”這一部分。
- “LineNumberTable”:行號表,將Code區的操作碼和源代碼中的行號對應,Debug時會起到作用(源代碼走一行,需要走多少個JVM指令操作碼)。
- “LocalVariableTable”:本地變量表,包含This和局部變量,之所以可以在每一個方法內部都可以調用This,是因為JVM將This作為每一個方法的第一個參數隱式進行傳入。當然,這是針對非Static方法而言。
圖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 控制操作數棧示意圖
1.5 查看字節碼工具
如果每次查看反編譯后的字節碼都使用javap命令的話,好非常繁瑣。這里推薦一個Idea插件:jclasslib。使用效果如圖15所示,代碼編譯后在菜單欄"View"中選擇"Show Bytecode With jclasslib",可以很直觀地看到當前字節碼文件的類信息、常量池、方法區等信息。
圖15 jclasslib查看字節碼
2. 字節碼增強
在上文中,著重介紹了字節碼的結構,這為我們了解字節碼增強技術的實現打下了基礎。字節碼增強技術就是一類對現有字節碼進行修改或者動態生成全新字節碼文件的技術。接下來,我們將從最直接操縱字節碼的實現方式開始深入進行剖析。
圖16 字節碼增強技術
2.1 ASM
對于需要手動操縱字節碼的需求,可以使用ASM,它可以直接生成.class字節碼文件,也可以在類被加載入JVM之前動態修改類行為(如下圖17所示)。ASM的應用場景有AOP(Cglib就是基于ASM)、熱部署、修改其他jar包中的類等。當然,涉及到如此底層的步驟,實現起來也比較麻煩。接下來,本文將介紹ASM的兩種API,并用ASM來實現一個比較粗糙的AOP。但在此之前,為了讓大家更快地理解ASM的處理流程,強烈建議讀者先對訪問者模式進行了解。簡單來說,訪問者模式主要用于修改或操作一些數據結構比較穩定的數據,而通過第一章,我們知道字節碼文件的結構是由JVM固定的,所以很適合利用訪問者模式對字節碼文件進行修改。
圖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"。
為了利用ASM實現AOP,需要定義兩個類:一個是MyClassVisitor類,用于對字節碼的Visit以及修改;另一個是Generator類,在這個類中定義ClassReader和ClassWriter,其中的邏輯是,classReader讀取字節碼,然后交給MyClassVisitor類處理,處理完成后由ClassWriter寫字節碼并將舊的字節碼替換掉。Generator類較簡單,我們先看一下它的實現,如下所示,然后重點解釋MyClassVisitor類。
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("") && 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總結
以上是生活随笔為你收集整理的idea如何反编译字节码指令_美团点评:Java字节码增强技术,线上问题诊断利器...的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: c语言08,标准C语言08_01.doc
- 下一篇: java访问其它服务器,一个Java W