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