日韩性视频-久久久蜜桃-www中文字幕-在线中文字幕av-亚洲欧美一区二区三区四区-撸久久-香蕉视频一区-久久无码精品丰满人妻-国产高潮av-激情福利社-日韩av网址大全-国产精品久久999-日本五十路在线-性欧美在线-久久99精品波多结衣一区-男女午夜免费视频-黑人极品ⅴideos精品欧美棵-人人妻人人澡人人爽精品欧美一区-日韩一区在线看-欧美a级在线免费观看

歡迎訪問(wèn) 生活随笔!

生活随笔

當(dāng)前位置: 首頁(yè) > 编程语言 > java >内容正文

java

干货!Java字节码增强探秘

發(fā)布時(shí)間:2024/4/11 java 41 豆豆
生活随笔 收集整理的這篇文章主要介紹了 干货!Java字节码增强探秘 小編覺(jué)得挺不錯(cuò)的,現(xiàn)在分享給大家,幫大家做個(gè)參考.

點(diǎn)擊上方“朱小廝的博客”,選擇“設(shè)為星標(biāo)”

后臺(tái)回復(fù)”加群“獲取公眾號(hào)專屬群聊入口

來(lái)源:美團(tuán)技術(shù)團(tuán)隊(duì)

1. 字節(jié)碼

1.1 什么是字節(jié)碼?

Java之所以可以“一次編譯,到處運(yùn)行”,一是因?yàn)镴VM針對(duì)各種操作系統(tǒng)、平臺(tái)都進(jìn)行了定制,二是因?yàn)闊o(wú)論在什么平臺(tái),都可以編譯生成固定格式的字節(jié)碼(.class文件)供JVM使用。因此,也可以看出字節(jié)碼對(duì)于Java生態(tài)的重要性。之所以被稱之為字節(jié)碼,是因?yàn)樽止?jié)碼文件由十六進(jìn)制值組成,而JVM以兩個(gè)十六進(jìn)制值為一組,即以字節(jié)為單位進(jìn)行讀取。在Java中一般是用javac命令編譯源代碼為字節(jié)碼文件,一個(gè).java文件從編譯到運(yùn)行的示例如圖1所示。

圖1 Java運(yùn)行示意圖

對(duì)于開發(fā)人員,了解字節(jié)碼可以更準(zhǔn)確、直觀地理解Java語(yǔ)言中更深層次的東西,比如通過(guò)字節(jié)碼,可以很直觀地看到Volatile關(guān)鍵字如何在字節(jié)碼上生效。另外,字節(jié)碼增強(qiáng)技術(shù)在Spring AOP、各種ORM框架、熱部署中的應(yīng)用屢見不鮮,深入理解其原理對(duì)于我們來(lái)說(shuō)大有裨益。除此之外,由于JVM規(guī)范的存在,只要最終可以生成符合規(guī)范的字節(jié)碼就可以在JVM上運(yùn)行,因此這就給了各種運(yùn)行在JVM上的語(yǔ)言(如Scala、Groovy、Kotlin)一種契機(jī),可以擴(kuò)展Java所沒(méi)有的特性或者實(shí)現(xiàn)各種語(yǔ)法糖。理解字節(jié)碼后再學(xué)習(xí)這些語(yǔ)言,可以“逆流而上”,從字節(jié)碼視角看它的設(shè)計(jì)思路,學(xué)習(xí)起來(lái)也“易如反掌”。

本文重點(diǎn)著眼于字節(jié)碼增強(qiáng)技術(shù),從字節(jié)碼開始逐層向上,由JVM字節(jié)碼操作集合到Java中操作字節(jié)碼的框架,再到我們熟悉的各類框架原理及應(yīng)用,也都會(huì)一一進(jìn)行介紹。

1.2 字節(jié)碼結(jié)構(gòu)

.java文件通過(guò)javac編譯后將得到一個(gè).class文件,比如編寫一個(gè)簡(jiǎn)單的ByteCodeDemo類,如下圖2的左側(cè)部分:

圖2 示例代碼(左側(cè))及對(duì)應(yīng)的字節(jié)碼(右側(cè))

編譯后生成ByteCodeDemo.class文件,打開后是一堆十六進(jìn)制數(shù),按字節(jié)為單位進(jìn)行分割后展示如圖2右側(cè)部分所示。上文提及過(guò),JVM對(duì)于字節(jié)碼是有規(guī)范要求的,那么看似雜亂的十六進(jìn)制符合什么結(jié)構(gòu)呢?JVM規(guī)范要求每一個(gè)字節(jié)碼文件都要由十部分按照固定的順序組成,整體結(jié)構(gòu)如圖3所示。接下來(lái)我們將一一介紹這十個(gè)部分:

圖3 JVM規(guī)定的字節(jié)碼結(jié)構(gòu)

(1) 魔數(shù)(Magic Number)

所有的.class文件的前四個(gè)字節(jié)都是魔數(shù),魔數(shù)的固定值為:0xCAFEBABE。魔數(shù)放在文件開頭,JVM可以根據(jù)文件的開頭來(lái)判斷這個(gè)文件是否可能是一個(gè).class文件,如果是,才會(huì)繼續(xù)進(jìn)行之后的操作。

有趣的是,魔數(shù)的固定值是Java之父James Gosling制定的,為CafeBabe(咖啡寶貝),而Java的圖標(biāo)為一杯咖啡。

(2) 版本號(hào)

版本號(hào)為魔數(shù)之后的4個(gè)字節(jié),前兩個(gè)字節(jié)表示次版本號(hào)(Minor Version),后兩個(gè)字節(jié)表示主版本號(hào)(Major Version)。上圖2中版本號(hào)為“00 00 00 34”,次版本號(hào)轉(zhuǎn)化為十進(jìn)制為0,主版本號(hào)轉(zhuǎn)化為十進(jìn)制為52,在Oracle官網(wǎng)中查詢序號(hào)52對(duì)應(yīng)的主版本號(hào)為1.8,所以編譯該文件的Java版本號(hào)為1.8.0。

(3) 常量池(Constant Pool)

緊接著主版本號(hào)之后的字節(jié)為常量池入口。常量池中存儲(chǔ)兩類常量:字面量與符號(hào)引用。字面量為代碼中聲明為Final的常量值,符號(hào)引用如類和接口的全局限定名、字段的名稱和描述符、方法的名稱和描述符。常量池整體上分為兩部分:常量池計(jì)數(shù)器以及常量池?cái)?shù)據(jù)區(qū),如下圖4所示。

圖4 常量池的結(jié)構(gòu)

  • 常量池計(jì)數(shù)器(constant_pool_count):由于常量的數(shù)量不固定,所以需要先放置兩個(gè)字節(jié)來(lái)表示常量池容量計(jì)數(shù)值。圖2中示例代碼的字節(jié)碼前10個(gè)字節(jié)如下圖5所示,將十六進(jìn)制的24轉(zhuǎn)化為十進(jìn)制值為36,排除掉下標(biāo)“0”,也就是說(shuō),這個(gè)類文件中共有35個(gè)常量。

圖5 前十個(gè)字節(jié)及含義

  • 常量池?cái)?shù)據(jù)區(qū):數(shù)據(jù)區(qū)是由(constant_pool_count-1)個(gè)cp_info結(jié)構(gòu)組成,一個(gè)cp_info結(jié)構(gòu)對(duì)應(yīng)一個(gè)常量。在字節(jié)碼中共有14種類型的cp_info(如下圖6所示),每種類型的結(jié)構(gòu)都是固定的。

圖6 各類型的cp_info

具體以CONSTANT_utf8_info為例,它的結(jié)構(gòu)如下圖7左側(cè)所示。首先一個(gè)字節(jié)“tag”,它的值取自上圖6中對(duì)應(yīng)項(xiàng)的Tag,由于它的類型是utf8_info,所以值為“01”。接下來(lái)兩個(gè)字節(jié)標(biāo)識(shí)該字符串的長(zhǎng)度Length,然后Length個(gè)字節(jié)為這個(gè)字符串具體的值。從圖2中的字節(jié)碼摘取一個(gè)cp_info結(jié)構(gòu),如下圖7右側(cè)所示。將它翻譯過(guò)來(lái)后,其含義為:該常量類型為utf8字符串,長(zhǎng)度為一字節(jié),數(shù)據(jù)為“a”。

圖7 CONSTANT_utf8_info的結(jié)構(gòu)(左)及示例(右)

其他類型的cp_info結(jié)構(gòu)在本文不再贅述,整體結(jié)構(gòu)大同小異,都是先通過(guò)Tag來(lái)標(biāo)識(shí)類型,然后后續(xù)n個(gè)字節(jié)來(lái)描述長(zhǎng)度和(或)數(shù)據(jù)。先知其所以然,以后可以通過(guò)javap -verbose ByteCodeDemo命令,查看JVM反編譯后的完整常量池,如下圖8所示。可以看到反編譯結(jié)果將每一個(gè)cp_info結(jié)構(gòu)的類型和值都很明確地呈現(xiàn)了出來(lái)。

圖8 常量池反編譯結(jié)果

(4) 訪問(wèn)標(biāo)志

常量池結(jié)束之后的兩個(gè)字節(jié),描述該Class是類還是接口,以及是否被Public、Abstract、Final等修飾符修飾。JVM規(guī)范規(guī)定了如下圖9的訪問(wèn)標(biāo)志(Access_Flag)。需要注意的是,JVM并沒(méi)有窮舉所有的訪問(wèn)標(biāo)志,而是使用按位或操作來(lái)進(jìn)行描述的,比如某個(gè)類的修飾符為Public Final,則對(duì)應(yīng)的訪問(wèn)修飾符的值為ACC_PUBLIC | ACC_FINAL,即0x0001 | 0x0010=0x0011。

圖9 訪問(wèn)標(biāo)志

(5) 當(dāng)前類名

訪問(wèn)標(biāo)志后的兩個(gè)字節(jié),描述的是當(dāng)前類的全限定名。這兩個(gè)字節(jié)保存的值為常量池中的索引值,根據(jù)索引值就能在常量池中找到這個(gè)類的全限定名。

(6) 父類名稱

當(dāng)前類名后的兩個(gè)字節(jié),描述父類的全限定名,同上,保存的也是常量池中的索引值。

(7) 接口信息

父類名稱后為兩字節(jié)的接口計(jì)數(shù)器,描述了該類或父類實(shí)現(xiàn)的接口數(shù)量。緊接著的n個(gè)字節(jié)是所有接口名稱的字符串常量的索引值。

(8) 字段表

字段表用于描述類和接口中聲明的變量,包含類級(jí)別的變量以及實(shí)例變量,但是不包含方法內(nèi)部聲明的局部變量。字段表也分為兩部分,第一部分為兩個(gè)字節(jié),描述字段個(gè)數(shù);第二部分是每個(gè)字段的詳細(xì)信息fields_info。字段表結(jié)構(gòu)如下圖所示:

圖10 字段表結(jié)構(gòu)

以圖2中字節(jié)碼的字段表為例,如下圖11所示。其中字段的訪問(wèn)標(biāo)志查圖9,0002對(duì)應(yīng)為Private。通過(guò)索引下標(biāo)在圖8中常量池分別得到字段名為“a”,描述符為“I”(代表int)。綜上,就可以唯一確定出一個(gè)類中聲明的變量private int a。

圖11 字段表示例

(9)方法表

字段表結(jié)束后為方法表,方法表也是由兩部分組成,第一部分為兩個(gè)字節(jié)描述方法的個(gè)數(shù);第二部分為每個(gè)方法的詳細(xì)信息。方法的詳細(xì)信息較為復(fù)雜,包括方法的訪問(wèn)標(biāo)志、方法名、方法的描述符以及方法的屬性,如下圖所示:

圖12 方法表結(jié)構(gòu)

方法的權(quán)限修飾符依然可以通過(guò)圖9的值查詢得到,方法名和方法的描述符都是常量池中的索引值,可以通過(guò)索引值在常量池中找到。而“方法的屬性”這一部分較為復(fù)雜,直接借助javap -verbose將其反編譯為人可以讀懂的信息進(jìn)行解讀,如圖13所示。可以看到屬性中包括以下三個(gè)部分:

  • “Code區(qū)”:源代碼對(duì)應(yīng)的JVM指令操作碼,在進(jìn)行字節(jié)碼增強(qiáng)時(shí)重點(diǎn)操作的就是“Code區(qū)”這一部分。

  • “LineNumberTable”:行號(hào)表,將Code區(qū)的操作碼和源代碼中的行號(hào)對(duì)應(yīng),Debug時(shí)會(huì)起到作用(源代碼走一行,需要走多少個(gè)JVM指令操作碼)。

  • “LocalVariableTable”:本地變量表,包含This和局部變量,之所以可以在每一個(gè)方法內(nèi)部都可以調(diào)用This,是因?yàn)镴VM將This作為每一個(gè)方法的第一個(gè)參數(shù)隱式進(jìn)行傳入。當(dāng)然,這是針對(duì)非Static方法而言。

圖13 反編譯后的方法表

(10)附加屬性表

字節(jié)碼的最后一部分,該項(xiàng)存放了在該文件中類或接口所定義屬性的基本信息。

1.3 字節(jié)碼操作集合

在上圖13中,Code區(qū)的紅色編號(hào)0~17,就是.java中的方法源代碼編譯后讓JVM真正執(zhí)行的操作碼。為了幫助人們理解,反編譯后看到的是十六進(jìn)制操作碼所對(duì)應(yīng)的助記符,十六進(jìn)制值操作碼與助記符的對(duì)應(yīng)關(guān)系,以及每一個(gè)操作碼的用處可以查看Oracle官方文檔進(jìn)行了解,在需要用到時(shí)進(jìn)行查閱即可。比如上圖中第一個(gè)助記符為iconst_2,對(duì)應(yīng)到圖2中的字節(jié)碼為0x05,用處是將int值2壓入操作數(shù)棧中。以此類推,對(duì)0~17的助記符理解后,就是完整的add()方法的實(shí)現(xiàn)。

1.4 操作數(shù)棧和字節(jié)碼

JVM的指令集是基于棧而不是寄存器,基于棧可以具備很好的跨平臺(tái)性(因?yàn)榧拇嫫髦噶罴陀布煦^),但缺點(diǎn)在于,要完成同樣的操作,基于棧的實(shí)現(xiàn)需要更多指令才能完成(因?yàn)闂V皇且粋€(gè)FILO結(jié)構(gòu),需要頻繁壓棧出棧)。另外,由于棧是在內(nèi)存實(shí)現(xiàn)的,而寄存器是在CPU的高速緩存區(qū),相較而言,基于棧的速度要慢很多,這也是為了跨平臺(tái)性而做出的犧牲。

我們?cè)谏衔乃f(shuō)的操作碼或者操作集合,其實(shí)控制的就是這個(gè)JVM的操作數(shù)棧。為了更直觀地感受操作碼是如何控制操作數(shù)棧的,以及理解常量池、變量表的作用,將add()方法的對(duì)操作數(shù)棧的操作制作為GIF,如下圖14所示,圖中僅截取了常量池中被引用的部分,以指令iconst_2開始到ireturn結(jié)束,與圖13中Code區(qū)0~17的指令一一對(duì)應(yīng):

圖14 控制操作數(shù)棧示意圖

1.5 查看字節(jié)碼工具

如果每次查看反編譯后的字節(jié)碼都使用javap命令的話,好非常繁瑣。這里推薦一個(gè)Idea插件:jclasslib。使用效果如圖15所示,代碼編譯后在菜單欄"View"中選擇"Show Bytecode With jclasslib",可以很直觀地看到當(dāng)前字節(jié)碼文件的類信息、常量池、方法區(qū)等信息。

圖15 jclasslib查看字節(jié)碼

2. 字節(jié)碼增強(qiáng)

在上文中,著重介紹了字節(jié)碼的結(jié)構(gòu),這為我們了解字節(jié)碼增強(qiáng)技術(shù)的實(shí)現(xiàn)打下了基礎(chǔ)。字節(jié)碼增強(qiáng)技術(shù)就是一類對(duì)現(xiàn)有字節(jié)碼進(jìn)行修改或者動(dòng)態(tài)生成全新字節(jié)碼文件的技術(shù)。接下來(lái),我們將從最直接操縱字節(jié)碼的實(shí)現(xiàn)方式開始深入進(jìn)行剖析。

圖16 字節(jié)碼增強(qiáng)技術(shù)

2.1 ASM

對(duì)于需要手動(dòng)操縱字節(jié)碼的需求,可以使用ASM,它可以直接生成.class字節(jié)碼文件,也可以在類被加載入JVM之前動(dòng)態(tài)修改類行為(如下圖17所示)。ASM的應(yīng)用場(chǎng)景有AOP(Cglib就是基于ASM)、熱部署、修改其他jar包中的類等。當(dāng)然,涉及到如此底層的步驟,實(shí)現(xiàn)起來(lái)也比較麻煩。接下來(lái),本文將介紹ASM的兩種API,并用ASM來(lái)實(shí)現(xiàn)一個(gè)比較粗糙的AOP。但在此之前,為了讓大家更快地理解ASM的處理流程,強(qiáng)烈建議讀者先對(duì)訪問(wèn)者模式進(jìn)行了解。簡(jiǎn)單來(lái)說(shuō),訪問(wèn)者模式主要用于修改或操作一些數(shù)據(jù)結(jié)構(gòu)比較穩(wěn)定的數(shù)據(jù),而通過(guò)第一章,我們知道字節(jié)碼文件的結(jié)構(gòu)是由JVM固定的,所以很適合利用訪問(wèn)者模式對(duì)字節(jié)碼文件進(jìn)行修改。

圖17 ASM修改字節(jié)碼

2.1.1 ASM API
2.1.1.1 核心API

ASM Core API可以類比解析XML文件中的SAX方式,不需要把這個(gè)類的整個(gè)結(jié)構(gòu)讀取進(jìn)來(lái),就可以用流式的方法來(lái)處理字節(jié)碼文件。好處是非常節(jié)約內(nèi)存,但是編程難度較大。然而出于性能考慮,一般情況下編程都使用Core API。在Core API中有以下幾個(gè)關(guān)鍵類:

  • ClassReader:用于讀取已經(jīng)編譯好的.class文件。

  • ClassWriter:用于重新構(gòu)建編譯后的類,如修改類名、屬性以及方法,也可以生成新的類的字節(jié)碼文件。

  • 各種Visitor類:如上所述,CoreAPI根據(jù)字節(jié)碼從上到下依次處理,對(duì)于字節(jié)碼文件中不同的區(qū)域有不同的Visitor,比如用于訪問(wèn)方法的MethodVisitor、用于訪問(wèn)類變量的FieldVisitor、用于訪問(wèn)注解的AnnotationVisitor等。為了實(shí)現(xiàn)AOP,重點(diǎn)要使用的是MethodVisitor。

2.1.1.2 樹形API

ASM Tree API可以類比解析XML文件中的DOM方式,把整個(gè)類的結(jié)構(gòu)讀取到內(nèi)存中,缺點(diǎn)是消耗內(nèi)存多,但是編程比較簡(jiǎn)單。TreeApi不同于CoreAPI,TreeAPI通過(guò)各種Node類來(lái)映射字節(jié)碼的各個(gè)區(qū)域,類比DOM節(jié)點(diǎn),就可以很好地理解這種編程方式。

2.1.2 直接利用ASM實(shí)現(xiàn)AOP

利用ASM的CoreAPI來(lái)增強(qiáng)類。這里不糾結(jié)于AOP的專業(yè)名詞如切片、通知,只實(shí)現(xiàn)在方法調(diào)用前、后增加邏輯,通俗易懂且方便理解。首先定義需要被增強(qiáng)的Base類:其中只包含一個(gè)process()方法,方法內(nèi)輸出一行“process”。增強(qiáng)后,我們期望的是,方法執(zhí)行前輸出“start”,之后輸出"end"。

public?class?Base?{public?void?process(){System.out.println("process");} }

為了利用ASM實(shí)現(xiàn)AOP,需要定義兩個(gè)類:一個(gè)是MyClassVisitor類,用于對(duì)字節(jié)碼的Visit以及修改;另一個(gè)是Generator類,在這個(gè)類中定義ClassReader和ClassWriter,其中的邏輯是,classReader讀取字節(jié)碼,然后交給MyClassVisitor類處理,處理完成后由ClassWriter寫字節(jié)碼并將舊的字節(jié)碼替換掉。Generator類較簡(jiǎn)單,我們先看一下它的實(shí)現(xiàn),如下所示,然后重點(diǎ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,用于對(duì)字節(jié)碼的觀察。它還包含一個(gè)內(nèi)部類MyMethodVisitor,繼承自MethodVisitor用于對(duì)類內(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);}@Overridepublic?void?visit(int?version,?int?access,?String?name,?String?signature,String?superName,?String[]?interfaces)?{cv.visit(version,?access,?name,?signature,?superName,?interfaces);}@Overridepublic?MethodVisitor?visitMethod(int?access,?String?name,?String?desc,?String?signature,?String[]?exceptions)?{MethodVisitor?mv?=?cv.visitMethod(access,?name,?desc,?signature,exceptions);//Base類中有兩個(gè)方法:無(wú)參構(gòu)造以及process方法,這里不增強(qiáng)構(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);}@Overridepublic?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);}@Overridepublic?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);}} }

利用這個(gè)類就可以實(shí)現(xiàn)對(duì)字節(jié)碼的修改。詳細(xì)解讀其中的代碼,對(duì)字節(jié)碼做修改的步驟是:

  • 首先通過(guò)MyClassVisitor類中的visitMethod方法,判斷當(dāng)前字節(jié)碼讀到哪一個(gè)方法了。跳過(guò)構(gòu)造方法"<init>"后,將需要被增強(qiáng)的方法交給內(nèi)部類MyMethodVisitor來(lái)進(jìn)行處理。

  • 接下來(lái),進(jìn)入內(nèi)部類MyMethodVisitor中的visitCode方法,它會(huì)在ASM開始訪問(wèn)某一個(gè)方法的Code區(qū)時(shí)被調(diào)用,重寫visitCode方法,將AOP中的前置邏輯就放在這里。

  • MyMethodVisitor繼續(xù)讀取字節(jié)碼指令,每當(dāng)ASM訪問(wèn)到無(wú)參數(shù)指令時(shí),都會(huì)調(diào)用MyMethodVisitor中的visitInsn方法。我們判斷了當(dāng)前指令是否為無(wú)參數(shù)的“return”指令,如果是就在它的前面添加一些指令,也就是將AOP的后置邏輯放在該方法中。

  • 綜上,重寫MyMethodVisitor中的兩個(gè)方法,就可以實(shí)現(xiàn)AOP了,而重寫方法時(shí)就需要用ASM的寫法,手動(dòng)寫入或者修改字節(jié)碼。通過(guò)調(diào)用methodVisitor的visitXXXXInsn()方法就可以實(shí)現(xiàn)字節(jié)碼的插入,XXXX對(duì)應(yīng)相應(yīng)的操作碼助記符類型,比如mv.visitLdcInsn("end")對(duì)應(yīng)的操作碼就是ldc "end",即將字符串“end”壓入棧。

完成這兩個(gè)Visitor類后,運(yùn)行Generator中的main方法完成對(duì)Base類的字節(jié)碼增強(qiáng),增強(qiáng)后的結(jié)果可以在編譯后的Target文件夾中找到Base.class文件進(jìn)行查看,可以看到反編譯后的代碼已經(jīng)改變了(如圖18左側(cè)所示)。然后寫一個(gè)測(cè)試類MyTest,在其中new Base(),并調(diào)用base.process()方法,可以看到下圖右側(cè)所示的AOP實(shí)現(xiàn)效果:

圖18 ASM實(shí)現(xiàn)AOP的效果

2.1.3 ASM工具

利用ASM手寫字節(jié)碼時(shí),需要利用一系列visitXXXXInsn()方法來(lái)寫對(duì)應(yīng)的助記符,所以需要先將每一行源代碼轉(zhuǎn)化為一個(gè)個(gè)的助記符,然后通過(guò)ASM的語(yǔ)法轉(zhuǎn)換為visitXXXXInsn()這種寫法。第一步將源碼轉(zhuǎn)化為助記符就已經(jīng)夠麻煩了,不熟悉字節(jié)碼操作集合的話,需要我們將代碼編譯后再反編譯,才能得到源代碼對(duì)應(yīng)的助記符。第二步利用ASM寫字節(jié)碼時(shí),如何傳參也很令人頭疼。ASM社區(qū)也知道這兩個(gè)問(wèn)題,所以提供了工具ASM ByteCode Outline。

安裝后,右鍵選擇“Show Bytecode Outline”,在新標(biāo)簽頁(yè)中選擇“ASMified”這個(gè)tab,如圖19所示,就可以看到這個(gè)類中的代碼對(duì)應(yīng)的ASM寫法了。圖中上下兩個(gè)紅框分別對(duì)應(yīng)AOP中的前置邏輯于后置邏輯,將這兩塊直接復(fù)制到Visitor中的visitMethod()以及visitInsn()方法中,就可以了。

圖19 ASM Bytecode Outline

2.2 Javassist

ASM是在指令層次上操作字節(jié)碼的,閱讀上文后,我們的直觀感受是在指令層次上操作字節(jié)碼的框架實(shí)現(xiàn)起來(lái)比較晦澀。故除此之外,我們?cè)俸?jiǎn)單介紹另外一類框架:強(qiáng)調(diào)源代碼層次操作字節(jié)碼的框架Javassist。

利用Javassist實(shí)現(xiàn)字節(jié)碼增強(qiáng)時(shí),可以無(wú)須關(guān)注字節(jié)碼刻板的結(jié)構(gòu),其優(yōu)點(diǎn)就在于編程簡(jiǎn)單。直接使用Java編碼的形式,而不需要了解虛擬機(jī)指令,就能動(dòng)態(tài)改變類的結(jié)構(gòu)或者動(dòng)態(tài)生成類。其中最重要的是ClassPool、CtClass、CtMethod、CtField這四個(gè)類:

  • CtClass(compile-time class):編譯時(shí)類信息,它是一個(gè)Class文件在代碼中的抽象表現(xiàn)形式,可以通過(guò)一個(gè)類的全限定名來(lái)獲取一個(gè)CtClass對(duì)象,用來(lái)表示這個(gè)類文件。

  • ClassPool:從開發(fā)視角來(lái)看,ClassPool是一張保存CtClass信息的HashTable,Key為類名,Value為類名對(duì)應(yīng)的CtClass對(duì)象。當(dāng)我們需要對(duì)某個(gè)類進(jìn)行修改時(shí),就是通過(guò)pool.getCtClass("className")方法從pool中獲取到相應(yīng)的CtClass。

  • CtMethod、CtField:這兩個(gè)比較好理解,對(duì)應(yīng)的是類中的方法和屬性。

了解這四個(gè)類后,我們可以寫一個(gè)小Demo來(lái)展示Javassist簡(jiǎn)單、快速的特點(diǎn)。我們依然是對(duì)Base中的process()方法做增強(qiáng),在方法調(diào)用前后分別輸出"start"和"end",實(shí)現(xiàn)代碼如下。我們需要做的就是從Pool中獲取到相應(yīng)的CtClass對(duì)象和其中的方法,然后執(zhí)行method.insertBefore和insertAfter方法,參數(shù)為要插入的Java代碼,再以字符串的形式傳入即可,實(shí)現(xiàn)起來(lái)也極為簡(jiǎ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();} }

3. 運(yùn)行時(shí)類的重載

3.1 問(wèn)題引出

上一章重點(diǎn)介紹了兩種不同類型的字節(jié)碼操作框架,且都利用它們實(shí)現(xiàn)了較為粗糙的AOP。其實(shí),為了方便大家理解字節(jié)碼增強(qiáng)技術(shù),在上文中我們避重就輕將ASM實(shí)現(xiàn)AOP的過(guò)程分為了兩個(gè)Main方法:第一個(gè)是利用MyClassVisitor對(duì)已編譯好的Class文件進(jìn)行修改,第二個(gè)是New對(duì)象并調(diào)用。這期間并不涉及到JVM運(yùn)行時(shí)對(duì)類的重加載,而是在第一個(gè)Main方法中,通過(guò)ASM對(duì)已編譯類的字節(jié)碼進(jìn)行替換,在第二個(gè)Main方法中,直接使用已替換好的新類信息。另外在Javassist的實(shí)現(xiàn)中,我們也只加載了一次Base類,也不涉及到運(yùn)行時(shí)重加載類。

如果我們?cè)谝粋€(gè)JVM中,先加載了一個(gè)類,然后又對(duì)其進(jìn)行字節(jié)碼增強(qiáng)并重新加載會(huì)發(fā)生什么呢?模擬這種情況,只需要我們?cè)谏衔闹蠮avassist的Demo中main()方法的第一行添加Base b=new Base(),即在增強(qiáng)前就先讓JVM加載Base類,然后在執(zhí)行到c.toClass()方法時(shí)會(huì)拋出錯(cuò)誤,如下圖20所示。跟進(jìn)c.toClass()方法中,我們會(huì)發(fā)現(xiàn)它是在最后調(diào)用了ClassLoader的Native方法defineClass()時(shí)報(bào)錯(cuò)。也就是說(shuō),JVM是不允許在運(yùn)行時(shí)動(dòng)態(tài)重載一個(gè)類的。

圖20 運(yùn)行時(shí)重復(fù)load類的錯(cuò)誤信息

顯然,如果只能在類加載前對(duì)類進(jìn)行強(qiáng)化,那字節(jié)碼增強(qiáng)技術(shù)的使用場(chǎng)景就變得很窄了。我們期望的效果是:在一個(gè)持續(xù)運(yùn)行并已經(jīng)加載了所有類的JVM中,還能利用字節(jié)碼增強(qiáng)技術(shù)對(duì)其中的類行為做替換并重新加載。為了模擬這種情況,我們將Base類做改寫,在其中編寫main方法,每五秒調(diào)用一次process()方法,在process()方法中輸出一行“process”。

我們的目的就是,在JVM運(yùn)行中的時(shí)候,將process()方法做替換,在其前后分別打印“start”和“end”。也就是在運(yùn)行中時(shí),每五秒打印的內(nèi)容由"process"變?yōu)榇蛴?#34;start process end"。那如何解決JVM不允許運(yùn)行時(shí)重加載類信息的問(wèn)題呢?為了達(dá)到這個(gè)目的,我們接下來(lái)一一介紹需要借助的Java類庫(kù)。

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)前PidSystem.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提供的一個(gè)可以修改已加載類的類庫(kù),專門為Java語(yǔ)言編寫的插樁服務(wù)提供支持。它需要依賴JVMTI的Attach API機(jī)制實(shí)現(xiàn),JVMTI這一部分,我們將在下一小節(jié)進(jìn)行介紹。在JDK 1.6以前,Instrument只能在JVM剛啟動(dòng)開始加載類時(shí)生效,而在JDK 1.6之后,Instrument支持了在運(yùn)行時(shí)對(duì)類定義的修改。要使用Instrument的類修改功能,我們需要實(shí)現(xiàn)它提供的ClassFileTransformer接口,定義一個(gè)類文件轉(zhuǎn)換器。接口中的transform()方法會(huì)在類文件被加載時(shí)調(diào)用,而在Transform方法里,我們可以利用上文中的ASM或Javassist對(duì)傳入的字節(jié)碼進(jìn)行改寫或替換,生成新的字節(jié)碼數(shù)組后返回。

我們定義一個(gè)實(shí)現(xiàn)了ClassFileTransformer接口的類TestTransformer,依然在其中利用Javassist對(duì)Base類中的process()方法進(jìn)行增強(qiáng),在前后分別打印“start”和“end”,代碼如下:

import?java.lang.instrument.ClassFileTransformer;public?class?TestTransformer?implements?ClassFileTransformer?{@Overridepublic?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,那么它要如何注入到正在運(yùn)行的JVM呢?還需要定義一個(gè)Agent,借助Agent的能力將Instrument注入到JVM中。我們將在下一小節(jié)介紹Agent,現(xiàn)在要介紹的是Agent中用到的另一個(gè)類Instrumentation。在JDK 1.6之后,Instrumentation可以做啟動(dòng)后的Instrument、本地代碼(Native Code)的Instrument,以及動(dòng)態(tài)改變Classpath等等。我們可以向Instrumentation中添加上文中定義的Transformer,并指定要被重加載的類,代碼如下所示。這樣,當(dāng)Agent被Attach到一個(gè)JVM中時(shí),就會(huì)執(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!");}} }

3.3 JVMTI & Agent & Attach API

上一小節(jié)中,我們給出了Agent類的代碼,追根溯源需要先介紹JPDA(Java Platform Debugger Architecture)。如果JVM啟動(dòng)時(shí)開啟了JPDA,那么類是允許被重新加載的。在這種情況下,已被加載的舊版本類信息可以被卸載,然后重新加載新版本的類。正如JDPA名稱中的Debugger,JDPA其實(shí)是一套用于調(diào)試Java程序的標(biāo)準(zhǔn),任何JDK都必須實(shí)現(xiàn)該標(biāo)準(zhǔn)。

JPDA定義了一整套完整的體系,它將調(diào)試體系分為三部分,并規(guī)定了三者之間的通信接口。三部分由低到高分別是Java 虛擬機(jī)工具接口(JVMTI),Java 調(diào)試協(xié)議(JDWP)以及 Java 調(diào)試接口(JDI),三者之間的關(guān)系如下圖所示:

圖21 JPDA

現(xiàn)在回到正題,我們可以借助JVMTI的一部分能力,幫助動(dòng)態(tài)重載類信息。JVM TI(JVM TOOL INTERFACE,JVM工具接口)是JVM提供的一套對(duì)JVM進(jìn)行操作的工具接口。通過(guò)JVMTI可以實(shí)現(xiàn)對(duì)JVM的多種操作,然后通過(guò)接口注冊(cè)各種事件勾子。在JVM事件觸發(fā)時(shí),同時(shí)觸發(fā)預(yù)定義的勾子,以實(shí)現(xiàn)對(duì)各個(gè)JVM事件的響應(yīng),事件包括類文件加載、異常產(chǎn)生與捕獲、線程啟動(dòng)和結(jié)束、進(jìn)入和退出臨界區(qū)、成員變量修改、GC開始和結(jié)束、方法調(diào)用進(jìn)入和退出、臨界區(qū)競(jìng)爭(zhēng)與等待、VM啟動(dòng)與退出等等。

而Agent就是JVMTI的一種實(shí)現(xiàn),Agent有兩種啟動(dòng)方式,一是隨Java進(jìn)程啟動(dòng)而啟動(dòng),經(jīng)常見到的java -agentlib就是這種方式;二是運(yùn)行時(shí)載入,通過(guò)Attach API,將模塊(jar包)動(dòng)態(tài)地Attach到指定進(jìn)程id的Java進(jìn)程內(nèi)。

Attach API 的作用是提供JVM進(jìn)程間通信的能力,比如說(shuō)我們?yōu)榱俗屃硗庖粋€(gè)JVM進(jìn)程把線上服務(wù)的線程Dump出來(lái),會(huì)運(yùn)行jstack或jmap的進(jìn)程,并傳遞pid的參數(shù),告訴它要對(duì)哪個(gè)進(jìn)程進(jìn)行線程Dump,這就是Attach API做的事情。在下面,我們將通過(guò)Attach API的loadAgent()方法,將打包好的Agent jar包動(dòng)態(tài)Attach到目標(biāo)JVM上。具體實(shí)現(xiàn)起來(lái)的步驟如下:

  • 定義Agent,并在其中實(shí)現(xiàn)AgentMain方法,如上一小節(jié)中定義的代碼塊7中的TestAgent類;

  • 然后將TestAgent類打成一個(gè)包含MANIFEST.MF的jar包,其中MANIFEST.MF文件中將Agent-Class屬性指定為TestAgent的全限定名,如下圖所示;

圖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?{//?傳入目標(biāo)?JVM?pidVirtualMachine?vm?=?VirtualMachine.attach("39333");?vm.loadAgent("/Users/zen/operation_server_jar/operation-server.jar");} }
  • 由于在MANIFEST.MF中指定了Agent-Class,所以在Attach后,目標(biāo)JVM在運(yùn)行時(shí)會(huì)走到TestAgent類中定義的agentmain()方法,而在這個(gè)方法中,我們利用Instrumentation,將指定類的字節(jié)碼通過(guò)定義的類轉(zhuǎn)化器TestTransformer做了Base類的字節(jié)碼替換(通過(guò)javassist),并完成了類的重新加載。由此,我們達(dá)成了“在JVM運(yùn)行時(shí),改變類的字節(jié)碼并重新載入類信息”的目的。

以下為運(yùn)行時(shí)重新載入類的效果:先運(yùn)行Base中的main()方法,啟動(dòng)一個(gè)JVM,可以在控制臺(tái)看到每隔五秒輸出一次"process"。接著執(zhí)行Attacher中的main()方法,并將上一個(gè)JVM的pid傳入。此時(shí)回到上一個(gè)main()方法的控制臺(tái),可以看到現(xiàn)在每隔五秒輸出"process"前后會(huì)分別輸出"start"和"end",也就是說(shuō)完成了運(yùn)行時(shí)的字節(jié)碼增強(qiáng),并重新載入了這個(gè)類。

圖23 運(yùn)行時(shí)重載入類的效果

3.4 使用場(chǎng)景

至此,字節(jié)碼增強(qiáng)技術(shù)的可使用范圍就不再局限于JVM加載類前了。通過(guò)上述幾個(gè)類庫(kù),我們可以在運(yùn)行時(shí)對(duì)JVM中的類進(jìn)行修改并重載了。通過(guò)這種手段,可以做的事情就變得很多了:

  • 熱部署:不部署服務(wù)而對(duì)線上服務(wù)做修改,可以做打點(diǎn)、增加日志等操作。

  • Mock:測(cè)試時(shí)候?qū)δ承┓?wù)做Mock。

  • 性能診斷工具:比如bTrace就是利用Instrument,實(shí)現(xiàn)無(wú)侵入地跟蹤一個(gè)正在運(yùn)行的JVM,監(jiān)控到類和方法級(jí)別的狀態(tài)信息。

4. 總結(jié)

字節(jié)碼增強(qiáng)技術(shù)相當(dāng)于是一把打開運(yùn)行時(shí)JVM的鑰匙,利用它可以動(dòng)態(tài)地對(duì)運(yùn)行中的程序做修改,也可以跟蹤JVM運(yùn)行中程序的狀態(tài)。此外,我們平時(shí)使用的動(dòng)態(tài)代理、AOP也與字節(jié)碼增強(qiáng)密切相關(guān),它們實(shí)質(zhì)上還是利用各種手段生成符合規(guī)范的字節(jié)碼文件。綜上所述,掌握字節(jié)碼增強(qiáng)后可以高效地定位并快速修復(fù)一些棘手的問(wèn)題(如線上性能問(wèn)題、方法出現(xiàn)不可控的出入?yún)⑿枰o急加日志等問(wèn)題),也可以在開發(fā)中減少冗余代碼,大大提高開發(fā)效率。

5. 參考文獻(xiàn)

  • 《ASM4-Guide》

  • Oracle:The class File Format

  • Oracle:The Java Virtual Machine Instruction Set

  • Javassist tutorial

  • JVM Tool Interface - Version 1.2

想知道更多?描下面的二維碼關(guān)注我

【精彩推薦】

  • 你知道為什么Java的main方法必須是public static void?
  • 一文講透微服務(wù)下如何保證事務(wù)的一致性

  • 如何理解Linux中的零拷貝技術(shù)

朕已閱?

總結(jié)

以上是生活随笔為你收集整理的干货!Java字节码增强探秘的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問(wèn)題。

如果覺(jué)得生活随笔網(wǎng)站內(nèi)容還不錯(cuò),歡迎將生活随笔推薦給好友。