1 字節碼
1.1 字節碼
Java之所以可以“一次編譯,到處運行”,一是因為JVM針對各種操作系統、平臺都進行了定制,二是因為無論在什么平臺,都可以編譯生成固定格式的字節碼(.class文件)供JVM使用。之所以被稱之為字節碼,是因為字節碼文件由十六進制值組成,而JVM以兩個十六進制值為一組,即以字節為單位進行讀取。在Java中一般是用javac命令編譯源代碼為字節碼文件,一個.java文件從編譯到運行的示例如圖1所示。
了解字節碼可以更準確、直觀地理解Java語言中更深層次的東西,字節碼增強技術在Spring AOP、各種ORM框架、熱部署中常被使用。此外,由于JVM規范的存在,只要最終可以生成符合規范的字節碼就可以在JVM上運行,因此這就給了各種運行在JVM上的語言(如Scala、Groovy、Kotlin)一種契機,可以擴展Java所沒有的特性或者實現各種語法糖。
1.2 字節碼結構
.java文件通過javac編譯后將得到一個.class文件,比如編寫一個簡單的ByteCodeDemo類,如下圖2的左側部分:
編譯后生成ByteCodeDemo.class文件,打開后是一堆十六進制數,按字節為單位進行分割后展示如上圖右側部分所示。JVM規范要求每一個字節碼文件都要由十部分按照固定的順序組成,整體結構:
(1) 魔數
所有的.class文件的前四個字節都是魔數,魔數的固定值為:0xcafebabe。魔數放在文件開頭,JVM可以根據文件的開頭來判斷這個文件是否可能是一個.class文件,如果是,才會繼續進行之后的操作。
(2)版本號
魔數之后的4個字節為版本號,前兩個字節表示次版本號(Minor Version),后兩個字節表示主版本號(Major Version)。上圖中版本號為“00 00 00 34”,次版本號轉化為十進制為0,主版本號轉化為十進制為52,序號52對應的主版本號為1.8,所以編譯該文件的Java版本號為1.8.0。
(3)常量池
版本號之后為常量池區,這部分內容將在類加載后進入內存的運行時常量池中存放。常量池中存儲兩類數據:字面量和符號引用。
字面量:(1)文本字符串 (2)八種基本類型的值 (3)被聲明為final的常量等;
符號引用:類和接口的全局限定名、字段的名稱和描述符、方法的名稱和描述符等;
-
常量池計數器(constant_pool_count):由于常量的數量不固定,所以需要先放置兩個字節來表示常量池容量計數值。示例代碼的字節碼前10個字節如下圖所示,將十六進制的24轉化為十進制值為36,去掉下標“0”(常量池區從01開始計數,計數器統計數據從0開始統計需要減1為實際常量數),即這個類文件中共有35個常量。
image.png -
常量池數據區:數據區是由(constant_pool_count-1)個cp_info結構組成,一個cp_info結構對應一個常量。在字節碼中共有14種類型的cp_info,每種類型的結構都是固定的。
具體以CONSTANT_utf8_info為例。首先一個字節“tag”,它的值取自上圖中對應項的Tag,由于它的類型是utf8_info,所以值為“01”。接下來兩個字節標識該字符串的長度Length,然后Length個字節為這個字符串具體的值。表示為:該常量類型為utf8字符串,長度為一字節,數據為“a”。
我們可以通過javap -verbose ByteCodeDemo命令,查看JVM反編譯后的完整常量池
或者使用idea工具jclasslib
(4)訪問標志
常量池區之后的兩個字節描述該Class是類還是接口,以及是否被Public、Abstract、Final等修飾符修飾。JVM規范規定了如下圖9的訪問標志(Access_Flag)。需要注意的是,JVM并沒有窮舉所有的訪問標志,而是使用按位或操作來進行描述的,比如某個類的修飾符為Public Final,則對應的訪問修飾符的值為ACC_PUBLIC | ACC_FINAL,即0x0001 | 0x0010=0x0011。
(5)當前類名
訪問標志后的兩個字節,描述的是當前類的全限定名。這兩個字節保存的值為常量池中的索引值,根據索引值就能在常量池中找到這個類的全限定名。
(6)父類名稱
當前類名后的兩個字節,描述父類的全限定名,同上,保存的也是常量池中的索引值。
(7)接口信息
父類名稱后為兩字節的接口計數器,描述了該類或父類實現的接口數量。緊接著的n個字節是所有接口名稱的字符串常量的索引值。
(8)字段表
字段表用于描述類和接口中聲明的變量,包含類級別的變量以及實例變量,但是不包含方法內部聲明的局部變量。字段表也分為兩部分,第一部分為兩個字節,描述字段個數;第二部分是每個字段的詳細信息fields_info。字段表結構如下圖所示:
示例中字段的訪問標志如下圖,0002對應為Private。通過索引下標在圖8中常量池分別得到字段名為“a”,描述符為“I”(代表int)。綜上,就可以唯一確定出一個類中聲明的變量private int a。
(9)方法表
字段表結束后為方法表,方法表也是由兩部分組成,第一部分為兩個字節描述方法的個數;第二部分為每個方法的詳細信息。方法的詳細信息較為復雜,包括方法的訪問標志、方法名、方法的描述符以及方法的屬性,如下圖所示:
通過javap -verbose來分析方法對屬性部分。包括以下三部分:
- Code區:源代碼對應的JVM指令操作碼,在進行字節碼增強時重點操作的就是“Code區”這一部分。
- LineNumberTable:行號表,將Code區的操作碼和源代碼中的行號對應,Debug時會起到作用(源代碼走一行,需要走多少個JVM指令操作碼)。
- LocalVariableTable:本地變量表,包含This和局部變量,之所以可以在每一個方法內部都可以調用This,是因為JVM將This作為每一個方法的第一個參數隱式進行傳入。當然,這是針對非Static方法而言。
Code區的紅色編號0~17,就是.java中的方法源代碼編譯后讓JVM真正執行的操作碼。為了幫助人們理解,反編譯后看到的是十六進制操作碼所對應的助記符,十六進制值操作碼與助記符的對應關系,以及每一個操作碼的用處可以查看Oracle官方文檔進行了解,在需要用到時進行查閱即可。比如上圖中第一個助記符為iconst_2,對應到圖2中的字節碼為0x05,用處是將int值2壓入操作數棧中。
(10)附加屬性表
字節碼的最后一部分,該項存放了在該文件中類或接口所定義屬性的基本信息。
方法表后為兩字節的屬性表個數,第二部分是屬性表的詳細信息attribute_info
- attribute_name_index:占2個字節,表示屬性名字的索引,指向常量池。
- attribute_length:占4個字節,表示屬性的長度。
- info[attribute_length]:占1個字節,表示具體的信息。
1.3 操作數棧和字節碼
JVM的指令集是基于棧而不是寄存器,基于棧可以具備很好的跨平臺性(因為寄存器指令集往往和硬件掛鉤),但缺點在于,要完成同樣的操作,基于棧的實現需要更多指令才能完成(因為棧只是一個FILO結構,需要頻繁壓棧出棧)。另外,由于棧是在內存實現的,而寄存器是在CPU的高速緩存區,相較而言,基于棧的速度要慢很多,這也是為了跨平臺性而做出的犧牲。
https://pic2.zhimg.com/v2-ac42012daa48396d66eda1e9adcdb8c5_b.webp
2 字節碼增強
字節碼增強技術就是一類對現有字節碼進行修改或者動態生成全新字節碼文件的技術。
2.1 ASM
對于需要手動操縱字節碼的需求,可以使用ASM,它可以直接生產 .class字節碼文件,也可以在類被加載入JVM之前動態修改類行為。ASM的應用場景有AOP(Cglib就是基于ASM)、熱部署、修改其他jar包中的類等。建議先對訪問者模式進行了解。訪問者模式主要用于修改或操作一些數據結構比較穩定的數據,字節碼文件的結構是由JVM固定的,所以很適合利用訪問者模式對字節碼文件進行修改。
demo
public class Base {
public void process(){
System.out.println("process");
}
}
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);
}
}
}
public class Generator {
public static void main(String[] args) throws Exception {
//讀取
ClassReader classReader = new ClassReader("test.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("/Users/sunqiuxiang/Desktop/code/scfclient/target/classes/test/asm/Base.class");
FileOutputStream fout = new FileOutputStream(f);
fout.write(data);
fout.close();
System.out.println("now generator cc success!!!!!");
}
}
利用這個類就可以實現對字節碼的修改。詳細解讀其中的代碼,對字節碼做修改的步驟是:
- 首先通過MyClassVisitor類中的visitMethod方法,判斷當前字節碼讀到哪一個方法了。跳過構造方法后,將需要被增強的方法交給內部類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”壓入棧。
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
public class JavassistTest {
public static void main(String[] args) throws Exception {
ClassPool cp = ClassPool.getDefault();
CtClass cc = cp.get("test.javassist.Base");
CtMethod m = cc.getDeclaredMethod("process");
m.insertBefore("{ System.out.println(\"start\"); }");
m.insertAfter("{ System.out.println(\"end\"); }");
Class c = cc.toClass();
Base h = (Base) c.newInstance();
h.process();
}
}
2.3 Instrument
2.4 使用場景
字節碼增強技術的可使用范圍就不再局限于JVM加載類前了。通過上述幾個類庫,我們可以在運行時對JVM中的類進行修改并重載了。通過這種手段,可以做的事情就變得很多了:
熱部署:不部署服務而對線上服務做修改,可以做打點、增加日志等操作。
Mock:測試時候對某些服務做Mock。
性能診斷工具:比如bTrace就是利用Instrument,實現無侵入地跟蹤一個正在運行的JVM,監控到類和方法級別的狀態信息。