用 Javassist 轉(zhuǎn)換字節(jié)碼中的方法
總裁, Sosnoski Software Solutions, Inc.
2003 年 10 月 25 日
厭倦了只能按編寫好源代碼的方式執(zhí)行的 Java 類了嗎?那么打起精神吧,因?yàn)槟鸵l(fā)現(xiàn)如何將編譯器編譯好的類進(jìn)行改造的方法了!在本文中,Java 顧問 Dennis Sosnoski 通過介紹字節(jié)碼操作庫(kù) Javassist 將他的 Java 編程的動(dòng)態(tài)性系列帶入高潮,Javassist 是廣泛使用的 JBoss 應(yīng)用服務(wù)器中加入的面向方面的編程功能的基礎(chǔ)。您會(huì)看到到用 Javassist 轉(zhuǎn)換現(xiàn)有類的基本內(nèi)容,并且了解到這種用框架源代碼處理類的方法的威力和局限性。
講過了 Java 類格式和利用反射進(jìn)行的運(yùn)行時(shí)訪問后,本系列到了進(jìn)入更高級(jí)主題的時(shí)候了。本月我將開始本系列的第二部分,在這里 Java 類信息只不過是由應(yīng)用程序操縱的另一種形式的數(shù)據(jù)結(jié)構(gòu)而已。我將這個(gè)主題的整個(gè)內(nèi)容稱為 classworking。
我將以 Javassist 字節(jié)碼操作庫(kù)作為對(duì) classworking 的討論的開始。Javassist 不僅是一個(gè)處理字節(jié)碼的庫(kù),而且更因?yàn)樗牧硪豁?xiàng)功能使得它成為試驗(yàn) classworking 的很好的起點(diǎn)。這一項(xiàng)功能就是:可以用 Javassist 改變 Java 類的字節(jié)碼,而無(wú)需真正了解關(guān)于字節(jié)碼或者 Java 虛擬機(jī)(Java virtual machine JVM)結(jié)構(gòu)的任何內(nèi)容。從某方面將這一功能有好處也有壞處 -- 我一般不提倡隨便使用不了解的技術(shù) -- 但是比起在單條指令水平上工作的框架,它確實(shí)使字節(jié)碼操作更可具有可行性了。
Javassist 基礎(chǔ)
Javassist 使您可以檢查、編輯以及創(chuàng)建 Java 二進(jìn)制類。檢查方面基本上與通過 Reflection API 直接在 Java 中進(jìn)行的一樣,但是當(dāng)想要修改類而不只是執(zhí)行它們時(shí),則另一種訪問這些信息的方法就很有用了。這是因?yàn)?JVM 設(shè)計(jì)上并沒有提供在類裝載到 JVM 中后訪問原始類數(shù)據(jù)的任何方法,這項(xiàng)工作需要在 JVM 之外完成。
不要錯(cuò)過本系列的其余部分 第二部分:“ 引入反射” (2003年6月) 第三部分:“ 應(yīng)用返射” (2003年7月) |
Javassist 使用 javassist.ClassPool
類跟蹤和控制所操作的類。這個(gè)類的工作方式與 JVM 類裝載器非常相似,但是有一個(gè)重要的區(qū)別是它不是將裝載的、要執(zhí)行的類作為應(yīng)用程序的一部分鏈接,類池使所裝載的類可以通過 Javassist API 作為數(shù)據(jù)使用。可以使用默認(rèn)的類池,它是從 JVM 搜索路徑中裝載的,也可以定義一個(gè)搜索您自己的路徑列表的類池。甚至可以直接從字節(jié)數(shù)組或者流中裝載二進(jìn)制類,以及從頭開始創(chuàng)建新類。
裝載到類池中的類由 javassist.CtClass
實(shí)例表示。與標(biāo)準(zhǔn)的 Java java.lang.Class
類一樣, CtClass
提供了檢查類數(shù)據(jù)(如字段和方法)的方法。不過,這只是 CtClass
的部分內(nèi)容,它還定義了在類中添加新字段、方法和構(gòu)造函數(shù)、以及改變類、父類和接口的方法。奇怪的是,Javassist 沒有提供刪除一個(gè)類中字段、方法或者構(gòu)造函數(shù)的任何方法。
字段、方法和構(gòu)造函數(shù)分別由 javassist.CtField、
javassist.CtMethod
和 javassist.CtConstructor
的實(shí)例表示。這些類定義了修改由它們所表示的對(duì)象的所有方法的方法,包括方法或者構(gòu)造函數(shù)中的實(shí)際字節(jié)碼內(nèi)容。
所有字節(jié)碼的源代碼
Javassist 讓您可以完全替換一個(gè)方法或者構(gòu)造函數(shù)的字節(jié)碼正文,或者在現(xiàn)有正文的開始或者結(jié)束位置選擇性地添加字節(jié)碼(以及在構(gòu)造函數(shù)中添加其他一些變量)。不管是哪種情況,新的字節(jié)碼都作為類 Java 的源代碼聲明或者 String
中的塊傳遞。Javassist 方法將您提供的源代碼高效地編譯為 Java 字節(jié)碼,然后將它們插入到目標(biāo)方法或者構(gòu)造函數(shù)的正文中。
Javassist 接受的源代碼與 Java 語(yǔ)言的并不完全一致,不過主要的區(qū)別只是增加了一些特殊的標(biāo)識(shí)符,用于表示方法或者構(gòu)造函數(shù)參數(shù)、方法返回值和其他在插入的代碼中可能用到的內(nèi)容。這些特殊標(biāo)識(shí)符以符號(hào) $
開頭,所以它們不會(huì)干擾代碼中的其他內(nèi)容。
對(duì)于在傳遞給 Javassist 的源代碼中可以做的事情有一些限制。第一項(xiàng)限制是使用的格式,它必須是單條語(yǔ)句或者塊。在大多數(shù)情況下這算不上是限制,因?yàn)榭梢詫⑺枰娜魏握Z(yǔ)句序列放到塊中。下面是一個(gè)使用特殊 Javassist 標(biāo)識(shí)符表示方法中前兩個(gè)參數(shù)的例子,這個(gè)例子用來(lái)展示其使用方法:
|
對(duì)于源代碼的一項(xiàng)更實(shí)質(zhì)性的限制是不能引用在所添加的聲明或者塊外聲明的局部變量。這意味著如果在方法開始和結(jié)尾處都添加了代碼,那么一般不能將在開始處添加的代碼中的信息傳遞給在結(jié)尾處添加的代碼。有可能繞過這項(xiàng)限制,但是繞過是很復(fù)雜的 -- 通常需要設(shè)法將分別插入的代碼合并為一個(gè)塊。
用 Javassist 進(jìn)行 Classworking
作為使用 Javassist 的一個(gè)例子,我將使用一個(gè)通常直接在源代碼中處理的任務(wù):測(cè)量執(zhí)行一個(gè)方法所花費(fèi)的時(shí)間。這在源代碼中可以容易地完成,只要在方法開始時(shí)記錄當(dāng)前時(shí)間、之后在方法結(jié)束時(shí)再次檢查當(dāng)前時(shí)間并計(jì)算兩個(gè)值的差。如果沒有源代碼,那么得到這種計(jì)時(shí)信息就要困難得多。這就是 classworking 方便的地方 -- 它讓您對(duì)任何方法都可以作這種改變,并且不需要有源代碼。
清單 1 顯示了一個(gè)(不好的)示例方法,我用它作為我的計(jì)時(shí)試驗(yàn)的實(shí)驗(yàn)品: StringBuilder
類的 buildString
方法。這個(gè)方法使用一種所有 Java 性能優(yōu)化的高手都會(huì)叫您 不要使用的方法構(gòu)造一個(gè)具有任意長(zhǎng)度的 String
-- 它通過反復(fù)向字符串的結(jié)尾附加單個(gè)字符來(lái)產(chǎn)生更長(zhǎng)的字符串。因?yàn)樽址遣豢勺兊模赃@種方法意味著每次新的字符串都要通過一個(gè)循環(huán)來(lái)構(gòu)造:使用從老的字符串中拷貝的數(shù)據(jù)并在結(jié)尾添加新的字符。最終的效果是用這個(gè)方法產(chǎn)生更長(zhǎng)的字符串時(shí),它的開銷越來(lái)越大。
|
添加方法計(jì)時(shí)
因?yàn)橛羞@個(gè)方法的源代碼,所以我將為您展示如何直接添加計(jì)時(shí)信息。它也作為使用 Javassist 時(shí)的一個(gè)模型。清單 2 只展示了 buildString()
方法,其中添加了計(jì)時(shí)功能。這里沒有多少變化。添加的代碼只是將開始時(shí)間保存為局部變量,然后在方法結(jié)束時(shí)計(jì)算持續(xù)時(shí)間并打印到控制臺(tái)。
|
用 Javassist 來(lái)做
來(lái)做使用 Javassist 操作類字節(jié)碼以得到同樣的效果看起來(lái)應(yīng)該不難。Javassist 提供了在方法的開始和結(jié)束位置添加代碼的方法,別忘了,我在為該方法中加入計(jì)時(shí)信息就是這么做的。
不過,還是有障礙。在描述 Javassist 是如何讓您添加代碼時(shí),我提到添加的代碼不能引用在方法中其他地方定義的局部變量。這種限制使我不能在 Javassist 中使用在源代碼中使用的同樣方法實(shí)現(xiàn)計(jì)時(shí)代碼,在這種情況下,我在開始時(shí)添加的代碼中定義了一個(gè)新的局部變量,并在結(jié)束處添加的代碼中引用這個(gè)變量。
那么還有其他方法可以得到同樣的效果嗎?是的,我 可以在類中添加一個(gè)新的成員字段,并使用這個(gè)字段而不是局部變量。不過,這是一種糟糕的解決方案,在一般性的使用中有一些限制。例如,考慮在一個(gè)遞歸方法中會(huì)發(fā)生的事情。每次方法調(diào)用自身時(shí),上次保存的開始時(shí)間值就會(huì)被覆蓋并且丟失。
幸運(yùn)的是有一種更簡(jiǎn)潔的解決方案。我可以保持原來(lái)方法的代碼不變,只改變方法名,然后用原來(lái)的方法名增加一個(gè)新方法。這個(gè) 攔截器(interceptor)方法可以使用與原來(lái)方法同樣的簽名,包括返回同樣的值。清單 3 展示了通過這種方法改編后源代碼看上去的樣子:
清單 3. 在源代碼中添加一個(gè)攔截器方法
|
通過 Javassist 可以很好地利用這種使用攔截器方法的方法。因?yàn)檎麄€(gè)方法是一個(gè)塊,所以我可以毫無(wú)問題地在正文中定義并且使用局部變量。為攔截器方法生成源代碼也很容易 -- 對(duì)于任何可能的方法,只需要幾個(gè)替換。
運(yùn)行攔截
實(shí)現(xiàn)添加方法計(jì)時(shí)的代碼要用到在 Javassist 基礎(chǔ)中描述的一些 Javassist API。清單 4 展示了該代碼,它是一個(gè)帶有兩個(gè)命令行參數(shù)的應(yīng)用程序,這兩個(gè)參數(shù)分別給出類名和要計(jì)時(shí)的方法名。 main()
方法的正文只給出類信息,然后將它傳遞給 addTiming()
方法以處理實(shí)際的修改。 addTiming()
方法首先通過在名字后面附加“ $impl”
重命名現(xiàn)有的方法,接著用原來(lái)的方法名創(chuàng)建該方法的一個(gè)拷貝。然后它用含有對(duì)經(jīng)過重命名的原方法的調(diào)用的計(jì)時(shí)代碼替換拷貝方法的正文。
|
構(gòu)造攔截器方法的正文時(shí)使用一個(gè) java.lang.StringBuffer
來(lái)累積正文文本(這顯示了處理 String
的構(gòu)造的正確方法,與在 StringBuilder
的構(gòu)造中使用的方法是相對(duì)的)。這種變化取決于原來(lái)的方法是否有返回值。如果它 有返回值,那么構(gòu)造的代碼就將這個(gè)值保存在局部變量中,這樣在攔截器方法結(jié)束時(shí)就可以返回它。如果原來(lái)的方法類型為 void
,那么就什么也不需要保存,也不用在攔截器方法中返回任何內(nèi)容。
除了對(duì)(重命名的)原來(lái)方法的調(diào)用,實(shí)際的正文內(nèi)容看起來(lái)就像標(biāo)準(zhǔn)的 Java 代碼。它是代碼中的 body.append(nname + "($$);\n")
這一行,其中 nname
是原來(lái)方法修改后的名字。在調(diào)用中使用的 $$
標(biāo)識(shí)符是 Javassist 表示正在構(gòu)造的方法的一系列參數(shù)的方式。通過在對(duì)原來(lái)方法的調(diào)用中使用這個(gè)標(biāo)識(shí)符,在調(diào)用攔截器方法時(shí)提供的參數(shù)就可以傳遞給原來(lái)的方法。
清單 5 展示了首先運(yùn)行未修改過的 StringBuilder
程序、然后運(yùn)行 JassistTiming
程序以添加計(jì)時(shí)信息、最后運(yùn)行修改后的 StringBuilder
程序的結(jié)果。可以看到修改后的 StringBuilder
運(yùn)行時(shí)會(huì)報(bào)告執(zhí)行的時(shí)間,還可以看到因?yàn)樽址畼?gòu)造代碼效率低下而導(dǎo)致的時(shí)間增加遠(yuǎn)遠(yuǎn)快于因?yàn)闃?gòu)造的字符串長(zhǎng)度的增加而導(dǎo)致的時(shí)間增加。
|
可以信任源代碼嗎?
Javassist 通過讓您處理源代碼而不是實(shí)際的字節(jié)碼指令清單而使 classworking 變得容易。但是這種方便性也有一個(gè)缺點(diǎn)。正如我在 所有字節(jié)碼的源代碼中提到的,Javassist 所使用的源代碼與 Java 語(yǔ)言并不完全一樣。除了在代碼中識(shí)別特殊的標(biāo)識(shí)符外,Javassist 還實(shí)現(xiàn)了比 Java 語(yǔ)言規(guī)范所要求的更寬松的編譯時(shí)代碼檢查。因此,如果不小心,就會(huì)從源代碼中生成可能會(huì)產(chǎn)生令人感到意外的結(jié)果的字節(jié)碼。
作為一個(gè)例子,清單 6 展示了在將方法開始時(shí)的攔截器代碼所使用的局部變量的類型從 long
變?yōu)?int
時(shí)的情況。Javassist 會(huì)接受這個(gè)源代碼并將它轉(zhuǎn)換為有效的字節(jié)碼,但是得到的時(shí)間是毫無(wú)意義的。如果試著直接在 Java 程序中編譯這個(gè)賦值,您就會(huì)得到一個(gè)編譯錯(cuò)誤,因?yàn)樗`反了 Java 語(yǔ)言的一個(gè)規(guī)則:一個(gè)窄化的賦值需要一個(gè)類型覆蓋。
long
儲(chǔ)存到一個(gè) int
中
|
取決于源代碼中的內(nèi)容,甚至可以讓 Javassist 生成無(wú)效的字節(jié)碼。清單7展示了這樣的一個(gè)例子,其中我將 JassistTiming
代碼修改為總是認(rèn)為計(jì)時(shí)的方法返回一個(gè) int
值。Javassist 同樣會(huì)毫無(wú)問題地接受這個(gè)源代碼,但是在我試圖執(zhí)行所生成的字節(jié)碼時(shí),它不能通過驗(yàn)證。
String
儲(chǔ)存到一個(gè) int
中
|
只要對(duì)提供給 Javassist 的源代碼加以小心,這就不算是個(gè)問題。不過,重要的是要認(rèn)識(shí)到 Javassist 沒有捕獲代碼中的所有錯(cuò)誤,所以有可能會(huì)出現(xiàn)沒有預(yù)見到的錯(cuò)誤結(jié)果。
后續(xù)內(nèi)容
Javassist 比我們?cè)诒疚闹兴懻摰膬?nèi)容要豐富得多。下一個(gè)月,我們將進(jìn)行更進(jìn)一步的分析,看一看 Javassist 為批量修改類以及為在運(yùn)行時(shí)裝載類時(shí)對(duì)類進(jìn)行動(dòng)態(tài)修改而提供的一些特殊的功能。這些功能使 Javassist 成為應(yīng)用程序中實(shí)現(xiàn)方面的一個(gè)很棒的工具,所以一定要繼續(xù)跟隨我們了解這個(gè)強(qiáng)大工具的全部?jī)?nèi)容。
- 您可以參閱本文在 developerWorks 全球站點(diǎn)上的 英文原文.
- Javassist 是由東京技術(shù)學(xué)院的數(shù)學(xué)和計(jì)算機(jī)科學(xué)系的 Shigeru Chiba 所創(chuàng)建的。它最近加入了開放源代碼 JBoss 應(yīng)用服務(wù)器項(xiàng)目,并成為其中新增加的面向方面的編程功能的基礎(chǔ)。Javassist 以 Mozilla Public License (MPL) 和 GNU Lesser General Public License (LGPL) 開放源代碼許可證的形式發(fā)布。
- 從“ Java bytecode: Understanding bytecode makes you a better programmer” ( developerWorks, 2001年7月)中了解有關(guān) Java 字節(jié)碼設(shè)計(jì)的更多內(nèi)容。
- 要了解更多面向方面編程的內(nèi)容嗎?可以從“ Improve modularity with aspect-oriented programming” ( developerWorks, 2002年1月)中找到關(guān)于使用 AspectJ 語(yǔ)言的概述。
- 開放源代碼 Jikes 項(xiàng)目提供了一個(gè)非常快速和高度兼容 Java 編程語(yǔ)言的編譯器。用它以老的方式生成您的字節(jié)碼 -- 從 Java 源代碼生成。
- 在 developerWorks Java 技術(shù)專區(qū) 可以找到關(guān)于 Java 編程各方面的數(shù)百篇文章。
關(guān)于作者![]() |