1. 程式人生 > >【深入Java虛擬機器】之九:類載入及執行子系統的案例與實戰

【深入Java虛擬機器】之九:類載入及執行子系統的案例與實戰

摘自《深入理解 Java 虛擬機器:JVM 高階特性與最佳實踐》(第二版)

概述

        在 Class 檔案格式與執行引擎這部分中,使用者的程式能直接影響的內容並不太多,Class 檔案以何種格式儲存型別何時載入如何連線以及虛擬機器如何執行位元組碼指令等都是由虛擬機器直接控制的行為使用者程式無法對其進行改變能通過程式進行操作的,主要是位元組碼生成類載入器這兩部分的功能,但僅僅在如何處理這兩點上,就已經出現了許多值得欣賞和借鑑的思路,這些思路後來成為了許多常用功能和程式實現的基礎。在本章中,我們將看一下前面所學的知識在實際開發之中是如何應用的。

案例分析

        在案例分析部分,筆者準備了 4 個例子,關於類載入器和位元組碼的案例各有兩個。並且這兩個領域的案例中各有一個案例是大多數 Java 開發人員都使用過的工具或技術,另外一個案例雖然不一定每個人都使用過,但卻特別精彩地演繹出這個領域中的技術特性。希望這些案例能引起讀者的思考,並給讀者的日常工作帶來靈感。

Tomcat:正統的類載入器架構

        主流的 Java Web 伺服器,如 Tomcat、Jetty、WebLogic、WebSphere 或其他筆者沒有列舉的伺服器,都實現了自己定義的類載入器(一般都不止一個)。因為一個功能健全的 Web 伺服器,要解決如下幾個問題:

  • 部署在同一個伺服器上的兩個 Web 應用程式所使用的 Java 類庫可以實現相互隔離。這是最基本的需求,兩個不同的應用程式可能會依賴同一個第三方類庫的不同版本,不能要求一個類庫在一個伺服器中只有一份,伺服器應當保證兩個應用程式的類庫可以互相獨立使用。
  • 部署在同一個伺服器上的兩個 Web 應用程式所使用的 Java 類庫可以互相共享。這個需求也很常見,例如,使用者可能有 10 個使用 Spring 組織的應用程式部署在同一臺伺服器上,如果把 10 份 Spring 分別存放在各個應用程式的隔離目錄中,將會是很大的資源浪費——這主要倒不是浪費磁碟空間的問題,而是指類庫在使用時都要被載入到伺服器記憶體,如果類庫不能共享,虛擬機器的方法區就會很容易出現過度膨脹的風險。
  • 伺服器需要儘可能地保證自身的安全不受部署的 Web 應用程式影響。目前,有許多主流的 Java Web 伺服器自身也是使用 Java 語言來實現的。因此,伺服器本身也有類庫依賴的問題,一般來說,基於安全考慮,伺服器所使用的類庫應該與應用程式的類庫相互獨立。
  • 支援 JSP 應用的 Web 伺服器,大多數都需要支援 HotSwap 功能。我們知道,JSP 檔案最終要編譯成 Java Class 才能由虛擬機器執行,但 JSP 檔案由於其純文字儲存的特性,執行時修改的概率遠遠大於第三方類庫或程式自身的 Class 檔案。而且 ASP、PHP 和 JSP 這些網頁應用也把修改後無須重啟作為一個很大的 “優勢” 來看待,因此 “主流” 的 Web 伺服器都會支援 JSP 生成類的熱替換,當然也有 “非主流” 的,如執行在生產模式(Producation Mode)下的 WebLogic 伺服器預設就不會處理 JSP 檔案的變化。

        由於存在上述問題,在部署 Web 應用時,單獨的一個 ClassPath 就無法滿足需求了,所以各種 Web 伺服器都 “不約而同” 地提供了好幾個 ClassPath 路徑供使用者存放第三方類庫,這些路徑一般都以 “lib” 或 “classes” 命名。被放置到不同路徑中的類庫,具備不同的訪問範圍和服務物件,通常,每一個目錄都會有一個相對應的自定義類載入器去載入放置在裡面的 Java 類庫。現在,筆者就以 Tomcat 伺服器(注:本案例中選用的是 Tomcat 5.x 伺服器的目錄和類載入器結構,在 Tomcat 6.x 的預設配置下,/common、/server 和 /shared 三個目錄已經合併到一起了)為例,看一看 Tomcat 具體是如何規劃使用者類庫結構和類載入器的。

        在 Tomcat 目錄結構中,有 3 組目錄(“/common/*”、“/server/*” 和 “/shared/*”)可以存放 Java 類庫,另外還可以加上 Web 應用程式自身的目錄 “/WEB-INF/*”,一共 4 組,把 Java 類庫放置在這些目錄中的含義分別如下。

  • 放置在 /common 目錄中:類庫可被 Tomcat 和所有的 Web 應用程式共同使用。
  • 放置在 /server 目錄中:類庫可被 Tomcat 使用,對所有的 Web 應用程式都不可見。
  • 放置在 /shared 目錄中:類庫可被所有的 Web 應用程式共同使用,但對 Tomcat 自己不可見。
  • 放置在 /WebApp/WEB-INF 目錄中:類庫僅僅可以被此 Web 應用程式使用,對 Tomcat 和其他 Web 應用程式都不可見。

        為了支援這套目錄結構,並對目錄裡面的類庫進行載入和隔離,Tomcat 自定義了多個類載入器,這些類載入器按照經典的雙親委派模型來實現,其關係如圖 9-1 所示。

        灰色背景的 3 個類載入器是 JDK 預設提供的類載入器,這 3 個載入器的作用前面已經介紹過了。而 CommonClassLoader、CatalinaClassLoader、SharedClassLoader 和 WebAppClassLoader 則是 Tomcat 自己定義的類載入器,它們分別載入 /common/*、/server/*、/shared/* 和 /WebApp/WEB-INF/* 中的 Java 類庫。其中 WebApp 類載入器和 Jsp 類載入器通常會存在多個例項,每一個 Web 應用程式對應一個 WebApp 類載入器,每一個 JSP 檔案對應一個 Jsp 類載入器。

        從圖 9-1 的委派關係中可以看出,CommonClassLoader 能載入的類都可以被 CatalinaClassLoader 和 SharedClassLoader 使用,而 CatalinaClassLoader 和 SharedClassLoader 自己能載入的類則與對方相互隔離。WebAppClassLoader 可以使用 SharedClassLoader 載入到的類,但各個 WebAppClassLoader 例項之間相互隔離。而 JasperLoader 的載入範圍僅僅是這個 JSP 檔案所編譯出來的那一個 Class,它出現的目的就是為了被丟棄:當伺服器檢測到 JSP 檔案被修改時,會替換掉目前的 JasperLoader 的例項,並通過再建立一個新的 Jsp 類載入器來實現 JSP 檔案的 HotSwap 功能。

        對於 Tomcat 的 6.x 版本,只有指定了 tomcat/conf/catalina.properties 配置檔案的 server.loader 和 share.loader 項後才會真正建立 CatalinaClassLoader 和 SharedClassLoader 的例項,否則會用到這兩個類載入器的地方都會用 CommonClassLoader 的例項代替,而預設的配置檔案中沒有設定這兩個 loader 項,所以 Tomcat 6.x 順理成章地把 /common、/server 和 /shared 三個目錄預設合併到一起變成一個 /lib 目錄,這個目錄裡的類庫相當於以前 /common 目錄中類庫的作用。這是 Tomcat 設計團隊為了簡化大多數的部署場景所做的一項改進,如果預設設定不能滿足需要,使用者可以通過修改配置檔案指定 server.loader 和 share.loader 的方式重新啟用 Tomcat 5.x 的載入器架構。

        Tomcat 載入器的實現清晰易懂,並且採用了官方推薦的 “正統” 的使用類載入器的方式。如果讀者閱讀完上面的案例後,能完全理解 Tomcat 設計團隊這樣佈置載入器架構的用意,那說明已經大致掌握了類載入器 “主流”
 的使用方式,那麼筆者不妨再提一個問題來讓讀者思考一下:前面曾經提到過一個場景,如果有 10 個 Web 應用程式都是用 Spring 來進行組織和管理的話,可以把 Spring 放到 common 或 shared 目錄下讓這些程式共享。Spring 要對使用者程式的類進行管理,自然要能訪問到使用者程式的類,而使用者的程式顯然是放在 /WebApp/WEB-INF 目錄中的,那麼被 CommonClassLoader 或 SharedClassLoader 載入的 Spring 如何訪問並不在其載入範圍的使用者程式呢?

OSGi:靈活的類載入器架構

        Java 程式社群中流傳著這麼一個觀點:“學習 JEE 規範,去看 JBoss 原始碼;學習類載入器,就去看 OSGi 原始碼”。儘管 “JEE 規範” 和 “類載入器的知識” 並不是一個對等的概念,不過,既然這個觀點能在程式設計師中流傳開來,也從側面說明了 OSGi 對類載入器的運用確實有其獨到之處。

        OSGi(Open Service Gateway Initiative) 是 OSGi 聯盟(OSGi Alliance)制定的一個基於 Java 語言的動態模組化規範,這個規範最初由 Sun、IBM、愛立信等公司聯合發起,目的是使用服務提供商通過住宅閘道器為各種家用智慧裝置提供各種服務,後來這個規範在 Java 的其他技術領域也有相當不錯的發展,現在已經成為 Java 世界中 “事實上” 的模組化標準,並且已經有了 Equinox、Felix 等成熟的實現。OSGi 在 Java 程式設計師中最著名的應用案例就是 Eclipse IDE,另外還有許多大型的軟體平臺和中介軟體伺服器都基於或宣告將會基於 OSGi 規範來實現,如 IBM Jazz 平臺、GlassFish 伺服器、JBoss OSGi 等。

        OSGi 中的每個模組(稱為 Bundle)與普通的 Java 類庫區別並不太大,兩者一般都以 JAR 格式進行封裝,並且內部儲存的都是 Java Package 和 Class。但是一個 Bundle 可以宣告它所依賴的 Java Package(通過 Import-Package 描述),也可以宣告它允許匯出釋出的 Java Package(通過 Export-Package 描述)。在 OSGi 裡面,Bundle 之間的依賴關係從傳統的上層模組依賴底層模組轉變為平級模組之間的依賴(至少外觀上如此),而且類庫的可見效能得到非常精確的控制,一個模組裡只有被 Export 過的 Package 才可能由外界訪問,其他的 Package 和 Class 將會隱藏起來。除了更精確的模組劃分和可見性控制外,引入 OSGi 的另外一個重要理由是,基於 OSGi 的程式很可能(只是很可能,並不是一定會)可以實現模組級的熱插拔功能,當程序升級更新或除錯除錯時,可以只停用、重新安裝然後啟用程式的其中一部分,這對企業級程式開發來說是一個非常有誘惑力的特性。

        OSGi 之所以能有上述 “誘人” 的特點,要歸功於它靈活的類載入器架構。OSGi 的 Bundle 類載入器之間只有規則,沒有固定的委派關係。例如,某個 Bundle 聲明瞭一個它依賴的 Package,如果有其他 Bundle 聲明發布了這個 Package,那麼所有對這個 Package 的類載入動作都會委派給釋出它的 Bundle 類載入器去完成。不涉及某個具體的 Package 時,各個 Bundle 載入器都是平級關係,只有具體使用某個 Package 和 Class 的時候,才會根據 Package 匯入匯出定義來構造 Bundle 間的委派和依賴。

        另外,一個 Bundle 類載入器為其他 Bundle 提供服務時,會根據 Export-Package 列表嚴格控制訪問範圍。如果一個類存在於 Bundle 的類庫中但是沒有被 Export,那麼這個 Bundle 的類載入器能找到這個類,但不會提供給其他 Bundle 使用,而且 OSGi 平臺也不會把其他 Bundle 的類載入請求分配給這個 Bundle 來處理。

        我們可以舉一個更具體一些的簡單例子,假設存在 Bundle A、Bundle B、Bundle C 三個模組,並且這三個 Bundle 定義的依賴關係如下。

  • Bundle A:聲明發布了 packageA,依賴了 java.* 的包。
  • Bundle B:宣告依賴了 packageA 和 packageC,同時也依賴了 java.* 的包。
  • Bundle C:聲明發布了 packageC,依賴了 packageA。

        那麼,這三個 Bundle 之間的類載入器及父類載入器之間的關係如圖 9-2 所示。

        由於沒有牽扯到具體的 OSGi 實現,所以圖 9-2 中的類載入器都沒有指明具體的載入器實現,只是一個體現了載入器之間關係的概念模型,並且只是體現了 OSGi 中最簡單的載入器委派關係。一般來說,在 OSGi 中,載入一個類可能發生的查詢行為和委派關係會比圖 9-2 中顯示的複雜得多,類載入時可能進行的查詢規則如下:

  • 以 java.* 開頭的類,委派給父類載入器載入。
  • 否則,委派列表名單內的類,委派給父類載入器載入。
  • 否則,Import 列表中的類,委派給 Export 這個類的 Bundle 的類載入器載入。
  • 否則,查詢當前 Bundle 的 Classpath,使用自己的類載入器載入。
  • 否則,查詢是否在自己的 Fragment Bundle 中,如果是,則委派給 Fragment Bundle 的類載入器載入。
  • 否則,查詢 Dynamic Import 列表的 Bundle,委派給對應 Bundle 的類載入器載入。
  • 否則,類查詢失敗。

        從圖 9-2 中還可以看出,在 OSGi 裡面,載入器之間的關係不再是雙親委派模型的屬性結構,而是已經進一步發展成了一種更為複雜的、執行時才能確定的網狀結構。這種網狀的類載入器架構在帶來更好的靈活性的同時,也可能會產生許多新的隱患。筆者曾經參與過將一個非 OSGi 的大型系統向 Equinox OSGi 平臺遷移的專案,由於歷史原因,程式碼模組之間的的依賴關係錯綜複雜,勉強分離出各個模組的 Bundle 後,發現在高併發環境下經常出現死鎖。我們很容易就找到了死鎖的原因:如果出現了 Bundle A 依賴於 Bundle B 的 Package B,而 Bundle B 又依賴了 Bundle A 的 Package A,這兩個 Bundle 進行類載入時就很容易發生死鎖。具體情況是當 Bundle A 載入 Package B 的類時,首先需要鎖定當前類載入器的例項物件(java.lang.ClassLoader.loadClass() 是一個 synchronized 方法),然後把請求委派給 Bundle B 的載入器處理,但如果這時候 Bundle B 也正好想載入 Package A 的類,它也先鎖定自己的載入器再去請求 Bundle A 的載入器處理,這樣,兩個載入器都在等待對方處理自己的請求,而對方處理完之前自己又一直處於同步鎖定的狀態,因此它們就互相死鎖,永遠無法完成載入請求了。Equinox 的 Bug List 中有關於這類問題的 Bug,也提供了一個以犧牲效能為代價的解決方案——使用者可以啟用 osgi.classloader.singleThreadLoads 引數來按單執行緒序列化的方式強制進行類載入器動作。在 JDK 1.7 中,為非樹狀繼承關係下的類載入器架構進行了一次專門的升級,目的是從底層避免這類死鎖出現的可能。

        總體來說,OSGi 描繪了一個很美好的模組化開發的目標,而且定義了實現這個目標所需要的各種服務,同時也有成熟框架對其提供實現支援。對於單個虛擬機器下的應用,從開發初期就建立在 OSGi 是一個很不錯的選擇,這樣便於約束依賴。但並非所有的應用都適合採用 OSGi 作為基礎架構,OSGi 在提供強大功能的同時,也引入了額外的複雜度,帶來了執行緒死鎖記憶體洩露風險

位元組碼生成技術與動態代理的實現

        “位元組碼生成” 並不是什麼高深的技術,讀者在看到 “位元組碼生成” 這個標題時也不必去向諸如 Javassit、CGLib、ASM 之類的位元組碼類庫,因為 JDK 裡面的 javac 命令就是位元組碼生成技術的 “老祖宗”,並且 javac 也是一個由 Java 語言寫成的程式,它的程式碼存放在 OpenJDK 的 langtools/src/share/classes/com/sun/tools/javac 目錄中。要深入瞭解位元組碼生成,閱讀 javac 的原始碼是個很好的途徑,不過 javac 對於我們這個例子來說太過龐大了。在 Java 裡面除了 javac 和位元組碼類庫外,使用位元組碼生成的例子還有很多,如 Web 伺服器中的 JSP 編譯器,編譯時植入的 AOP 框架,還有很常用的動態代理技術,甚至在使用反射的時候虛擬機器都有可能會在執行時生成位元組碼來提高執行速度。我們選擇其中相對簡單的動態代理來看看位元組碼生成技術是如何影響程式運作的

        相信許多 Java 開發人員都使用過動態代理,即使沒有直接使用過 java.lang.reflect.Proxy 或實現過 java.lang.reflect.InvocationHandler 介面,應該也用過 Spring 來做過 Bean 的組織管理。如果使用過 Spring,那大多數情況都會用過動態代理,因為如果 Bean 是面向介面程式設計,那麼在 Spring 內部都是通過動態代理的方式來對 Bean 進行增強的。動態代理中所謂的 “動態”,是針對使用 Java 程式碼實際編寫了代理類的 “靜態” 代理而言的,它的優勢不在於省去了編寫代理類哪一點工作量,而是實現了可以在原始類和介面還未知的時候,就確定代理類的代理行為,當代理類與原始類脫離直接聯絡後,就可以很靈活地重用於不同的應用場景之中

        程式碼清單 9-1  演示了一個最簡單的動態代理的用法,原始的邏輯是列印一句 “hello world”,代理類的邏輯是在原始類方法執行前列印一句 “welcome”。我們先看一下程式碼,然後再分析 JDK 是如何做到的。

程式碼清單 9-1  動態代理的簡單示例


  
  1. import java.lang.reflect.InvocationHandler;
  2. import java.lang.reflect.Method;
  3. import java.lang.reflect.Proxy;
  4. public class DynamicProxyTest {
  5. interface IHello {
  6. void sayHello();
  7. }
  8. static class Hello implements IHello {
  9. @Override
  10. public void sayHello() {
  11. System.out.println( "hello world");
  12. }
  13. }
  14. static class DynamicProxy implements InvocationHandler {
  15. Object originalObj;
  16. Object bind(Object originalObj) {
  17. this.originalObj = originalObj;
  18. return Proxy.newProxyInstance(originalObj.getClass().getClassLoader(),
  19. originalObj.getClass().getInterfaces(), this);
  20. }
  21. @Override
  22. public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
  23. System.out.println( "welcome");
  24. return method.invoke(originalObj, args);
  25. }
  26. }
  27. public static void main(String[] args) throws Exception {
  28. IHello hello = (IHello) new DynamicProxy().bind( new Hello());
  29. hello.sayHello();
  30. }
  31. }

        執行結果如下:


  
  1. welcome
  2. hello world

        上述程式碼裡,唯一的 “黑匣子” 就是 Proxy.newProxyInstance() 方法,除此之外再沒有任何特殊之處。這個方法返回一個實現了 IHello 的介面,並且代理了 new Hello() 例項行為的物件。跟蹤這個方法的原始碼,可以看到程式進行了驗證、優化、快取、同步、生成位元組碼、顯式類載入等操作,前面的步驟並不是我們關注的重點,而最後它呼叫了 sun.misc.ProxyGenerator.generateProxyClass() 方法來完成生成位元組碼的動作,這個方法可以在執行時產生一個描述代理類的位元組碼 byte[] 陣列。如果想看一看這個再執行時產生的代理類中寫了什麼,可以在main() 方法中加入下面這句:

System.getProperties().put("sun.misc.ProxyGenerator.saveGeneratedFiles", "true");
  

圖 a

        加入這句程式碼後再次執行程式,磁碟中將會產生一個名為 “$Proxy0.class” 的代理類 Class 檔案(注:應該先在【專案目錄】非【ClassPath 目錄】下,建立和包名對應的資料夾,如圖 a 所示),反編譯後可以看見如程式碼清單 9-2 所示的內容。

程式碼清單 9-2  反編譯的動態代理類的程式碼


  
  1. package org.fenixsoft.def;
  2. import java.lang.reflect.InvocationHandler;
  3. import java.lang.reflect.Method;
  4. import java.lang.reflect.Proxy;
  5. import java.lang.reflect.UndeclaredThrowableException;
  6. public final class $Proxy0
  7. extends Proxy
  8. implements DynamicProxyTest. IHello
  9. {
  10. private static Method m3;
  11. private static Method m1;
  12. private static Method m0;
  13. private static Method m2;
  14. public $Proxy0(InvocationHandler paramInvocationHandler)
  15. {
  16. super(paramInvocationHandler);
  17. }
  18. public final void sayHello()
  19. {
  20. try
  21. {
  22. this.h.invoke( this, m3, null);
  23. return;
  24. }
  25. catch (Error|RuntimeException localError)
  26. {
  27. throw localError;
  28. }
  29. catch (Throwable localThrowable)
  30. {
  31. throw new UndeclaredThrowableException(localThrowable);
  32. }
  33. }
  34. // 此處由於版面原因,省略 equals()、hashCode()、toString() 三個方法的程式碼
  35. // 這 3 個方法的內容與 sayHello() 非常相似
  36.   static
  37. {
  38. try
  39. {
  40. m3 = Class.forName( "org.fenixsoft.def.DynamicProxyTest$IHello").getMethod( "sayHello", new Class[ 0]);
  41. m1 = Class.forName( "java.lang.Object").getMethod( "equals", new Class[] { Class.forName( "java.lang.Object") });
  42. m0 = Class.forName( "java.lang.Object").getMethod( "hashCode", new Class[ 0]);
  43. m2 = Class.forName( "java.lang.Object").getMethod( "toString", new Class[ 0]);
  44. return;
  45. }
  46. catch (NoSuchMethodException localNoSuchMethodException)
  47. {
  48. throw new NoSuchMethodError(localNoSuchMethodException.getMessage());
  49. }
  50. catch (ClassNotFoundException localClassNotFoundException)
  51. {
  52. throw new NoClassDefFoundError(localClassNotFoundException.getMessage());
  53. }
  54. }
  55. }

        這個代理類的實現程式碼也很簡單,它為傳入介面中的每一個方法,以及從 java.lang.Object 中繼承來的 equals()、hashCode()、toString() 方法都生成了對應的實現,並且統一呼叫了 InvocationHandler 物件的 invoke() 方法(程式碼中的 “this.h” 就是父類 Proxy 中儲存的 InvocationHandler 例項變數)來實現這些方法的內容,各個方法的區別不過是傳入的引數和 Method 物件有所不同而已,所以無論呼叫動態代理的哪一個方法,實際上都是在執行 InvocationHandler.invoke() 中的代理邏輯。

        這個例子中並沒有講到 generateProxyClass() 方法具體是如何產生代理類 “$Proxy0.class” 的位元組碼的,大致的生成過程其實就是根據 Class 檔案的格式規範去拼裝位元組碼,但在實際開發中,以 byte 為單位直接拼裝出位元組碼的應用場合很少見,這種生成方式也只能產生一些高度模板化的程式碼。對於使用者的程式程式碼來說,如果有要大量操作位元組碼的需求,還是使用封裝好的位元組碼類庫比較合適。如果讀者對動態代理的位元組碼拼裝過程很感興趣,可以在 OpenJDK 的 jdk/src/share/classes/sun/misc 目錄下找到 sun.misc.ProxyGenerator 的原始碼。

Retrotranslator:跨越 JDK 版本

        一般來說,以 “做專案” 為主的軟體公司比較容易更新技術,在下一個專案中換一個技術框架、升級到最新的 JDK 版本,甚至把 Java 換成 C#、C++ 來開發程式都是由可能的。但當公司發展壯大,技術有所積累,逐漸成為 “做產品” 為主的軟體公司後,自主選擇技術的權利就會喪失掉,因為之前所積累的程式碼和技術都是用真金白銀換來的,一個穩健的團隊也不會隨意地改變底層的技術。然而在飛速發展的程式設計領域,新技術總是日新月異、層出不窮,偏偏這些新技術又如鮮花之於蜜蜂一樣,對程式設計師散發著天然的吸引力。

        在 Java 世界裡,每一次 JDK 大版本的釋出,都伴隨著一場大規模的技術革新,而對 Java 程式編寫習慣改變最大的,無疑是 JDK 1.5 的釋出。自動裝箱、泛型、動態註解、列舉、變長引數、遍歷迴圈(foreach 迴圈)……事實上,在沒有這些語法特性的年代,Java 程式也照樣能寫,但是現在看來,上述每一種語法的改進幾乎都是 “必不可少” 的。就如同習慣了 24 寸液晶顯示器的程式設計師,很難習慣在 15 寸平顯示器上編寫程式碼。但假如 “不幸” 因為要保護現有投資、維持程式結構穩定等,必須使用 1.5 以前版本的 JDK 呢?我們沒有辦法把 15 寸顯示器變成 24 寸的,但卻可以跨越 JDK 版本之間的溝壑,把 JDK 1.5 中編寫的程式碼放到 JDK 1.4 或 1.3 的環境去部署使用。為了解決這個問題,一種名為 “Java 逆向移植” 的工具(Java Backporting Tools)應運而生,Retrotranslator 是這類工具中較出色的一個。

        Retrotranslator 的作用是將 JDK 1.5 編譯出來的 Class 檔案轉變為可以在 JDK 1.4 或 1.3 上部署的版本,它可以很好地支援自動裝箱、泛型、動態註解、列舉、變長引數、遍歷迴圈、靜態匯入這些語法特性,甚至還可以支援 JDK 1.5 中新增的集合改進、併發包以及對泛型、註解等的反射操作。瞭解了 Retrotranslator 這種逆向移植工具可以做什麼以後,現在關心的是它是怎樣做到的?

        要想知道 Retrotranslator 如何在舊版本 JDK 中模擬新版本 JDK 的功能,首先要弄清楚 JDK 升級中會提供哪些新的功能。JDK 每次升級新增的功能大致可以分為以下 4 類:

  • 編譯器層面做的改進。如自動裝箱拆箱,實際上就是編譯器在程式中使用到包裝物件的地方自動插入了很多 Integer.valueOf()、Float.valueOf() 之類的程式碼;變長引數在編譯之後就自動轉化成一個數組來完成引數傳遞;泛型的資訊則在編譯階段就已經擦除掉了(但是在元資料中還保留著),相應的地方被編譯器自動插入了型別轉換程式碼。
  • Java API 的程式碼增強。譬如 JDK 1.2 時代引入的 java.util.Collections 等一系列集合類,在 JDK 1.5 時代引入的 java.util.concurrent 併發包等。
  • 需要在位元組碼中進行支援的改動。如 JDK 1.7 裡面新加入的語法特性:動態語言支援,就需要在虛擬機器中新增一條 invokedynamic 位元組碼指令來實現相關的呼叫功能。不過位元組碼指令集一直處於相對比較穩定的狀態,這種需要在位元組碼層面直接進行的改動是比較少見的。
  • 虛擬機器內部的改進。如 JDK 1.5 中實現的 JSR-133 規範重新定義的 Java 記憶體模型(Java Memory Model,JMM)、CMS 收集器之類的改動,這類改動對於程式設計師編寫程式碼基本是透明的,但會對程式執行時產生影響。

        上述 4 類新功能中,Retrotranslator 只能模擬前兩類,對於後面兩類直接在虛擬機器內部實現的改進,一般所有的逆向移植工具都是無能為力的,至少不能完整地或者再可接受的效率上完成全部模擬,否則虛擬機器設計團隊也沒有必要捨近求遠地改動處於 JDK 底層的虛擬機器。在可以模擬的兩類功能中,第二類模擬相對更容易實現一些,如 JDK 1.5 引入的 java.util.concurrent 包,實際是由多執行緒大師 Doug Lea 開發的一套併發包,在 JDK 1.5 出現之前就已經存在(那時候名字叫做 dl.util.concurrent,引入 JDK 時由作者和 JDK 開發團隊共同做了一些改進),所以要在舊的 JDK 中支援這部分功能,以獨立類庫的方式便可實現。Retrotranslator 中附帶了一個名叫 “backport-util-concurrent.jar” 的類庫(由另一個名為 “Backport of JSR 166” 的專案所提供)來代替 JDK 1.5 的併發包。

        至於 JDK 在編譯階段進行處理的那些改進,Retrotranslator 則是使用 ASM 框架直接對位元組碼進行處理。由於組成 Class 檔案的位元組碼指令數量並沒有改變,所以無論是 JDK 1.3、JDK 1.4 還是 JDK 1.5,能用位元組碼錶達的語義範圍應該是一直的。當然,肯定不可能簡單地把 Class 的檔案版本號從 49.0 改回 48.0 就能解決問題了,雖然位元組碼指令的數量沒有變化,但是元資料資訊和一些語法支援的內容還是要做相應的修改。以列舉為例,在 JDK 1.5 中增加了 enum 關鍵字,但是 Class 檔案常量池的 CONSTANT_Class_info 型別常量並沒有發生任何語義變化,仍然是代表一個類或介面的符號引用,沒有加入列舉,也沒有增加過 “CONSTANT_Enum_info” 之類的 “列舉符號引用” 常量。所以使用 enum 關鍵字定義常量,雖然從 Java 語法上看起來與使用 class 關鍵字定義類、使用 interface 關鍵字定義介面是同一層次的,但實際上這是由 Javac 編譯器做出來的假象,從位元組碼的角度來看,列舉僅僅是一個繼承於 java.lang.Enum、自動生成了 values() 和 valueOf() 方法的普通 Java 類而已。

        Retrotranslator 對列舉所做的主要處理就是把列舉類的父類從 “java.lang.Enum” 替換位它執行時類庫中包含的 “net.sf.retrotranslator.runtime.java.lang.Enum_”,然後再在類和欄位的訪問標誌中抹去 ACC_ENUM 標誌位。當然,這只是處理的總體思路,具體的實現要比上面說的複雜得多。可以想象既然兩個父類實現都不一樣,values() 和 valueOf() 的方法自然需要重寫,常量池需要引入大量新的來自父類的符號引用,這些都是實現細節。圖 9-3 是一個使用 JDK 1.5 編譯的列舉類與被 Retrotranslator 轉換處理後的位元組碼的對比圖。

實戰:自己動手實現遠端執行功能

        不知道讀者在做程式維護的時候是否遇到過這類情形:排查問題的過程中,想檢視記憶體中的一些引數值,卻又沒有方法把這些值輸出到介面或日誌中,又或者定位到某個快取資料有問題,但缺少快取的同一管理介面,不得不重啟服務才能清理這個快取。類似的需求又一個共同的特點,那就是隻要在服務中執行一段程式程式碼,就可以定位或排除問題,但就是偏偏找不到可以讓伺服器執行臨時程式碼的途徑,這時候就會希望 Java 伺服器中也有提供類似 Groovy Console 的功能。

        JDK 1.6 之後提供了 Compiler API,可以動態地編譯 Java 程式,雖然這樣達不到動態語言的靈活度,但讓伺服器執行臨時程式碼的需求就可以得到解決了。在 JDK 1.6 之前,也可以通過其他方式來做到,譬如寫一個 JSP 檔案上傳到伺服器,然後在瀏覽器中執行它,或者在伺服器端程式中加入一個 BeanShell ScriptJavaScript 等的執行引擎(如 Mozilla Rhino)去執行動態指令碼。在本章的實戰部分,我們將使用前面學到的關於類載入及虛擬機器執行子系統的知識去實現在服務端執行臨時程式碼的功能。

目標

        首先,在實現 “在服務端執行臨時程式碼” 這個需求之前,先來明確一下本次實戰的具體目標,我們希望最終的產品是這樣的:

  • 不依賴 JDK 版本,能在目前還普遍使用的 JDK 中部署,也就是使用 JDK 1.4 ~ JDK 1.7 都可以執行。
  • 不改變原有服務端程式的部署,不依賴任何第三方類庫。
  • 不侵入原有程式,即無須改動原程式的任何程式碼,也不會對原有程式的執行帶來任何影響。
  • 考到 BeanShell Script 或 JavaScript 等指令碼編寫起來不太方便,“臨時程式碼” 需要直接支援 Java 語言。
  • “臨時程式碼” 應當具備足夠的自由度,不需要依賴特定的類或實現特定的介面。這裡寫的是 “不需要” 而不是 “不可以”,當 “臨時程式碼” 需要引用其他類庫時也沒有限制,只要服務端程式能使用的,臨時程式碼應當都能直接引用。
  • “臨時程式碼” 的執行結果能返回客戶端,執行結果可以包括程式中輸出的資訊及丟擲的異常等。

        看完上面列出的目標,你覺得完成這個需求需要做多少工作呢?也許答案比大多數人所想的都要簡單一些:5 個類,250 行程式碼(含註釋),大約一個半小時左右的開發時間久可以了,現在就開始編寫程式吧!

思路

        在程式實現的過程中,我們需要解決以下 3 個問題:

  • 如何編譯提交到伺服器的 Java 程式碼?
  • 如何執行編譯之後的 Java 程式碼?
  • 如何收集 Java 程式碼的執行結果?

        對於第一個問題,我們有兩種思路可以選擇,一種是使用 tools.jar 包(在 Sun JDK/lib 目錄下)中的 com.sun.tools.javac.Main 類來編譯 Java 檔案,這其實和使用 javac 命令編譯是一樣的。這種思路的缺點的引入了額外的 JAR 包,而且把程式 “綁死” 在 Sun 的 JDK 上了,要部署到其他公司的 JDK 中還得把 tools.jar 帶上(雖然 JRockit 和 J9 虛擬機器也有這個 JAR 包,但它總不是標準所規定必須存在的)。另外一種思路是直接在客戶端編譯好,把位元組碼而不是 Java 程式碼傳到服務端,這聽起來好像有點投機取巧,一般來說確實不應該假定客戶端一定具有編譯程式碼的能力,但是既然程式設計師會寫 Java 程式碼去給服務端排查問題,那麼很難想象他的機器上會連編譯 Java 程式的環境都沒有。

        對於第二個問題,簡單地一想:要執行編譯後的 Java 程式碼,讓類載入器載入這個類生成一個 Class 物件,然後反射呼叫一下某個方法就可以了(因為不實現任何介面,我們可以借用一下 Java 中人人皆知的 “main()” 方法)。但我們還應該考慮得更周全些:一段程式往往不是編寫、執行一次就能達到效果,同一個類可能要反覆地修改、提交、執行。另外,提交上去的類要能訪問服務端的其他類庫才行。還有,既然提交的是臨時程式碼,那提交的 Java 類在執行完成後就應當能解除安裝和回收。

        最後的一個問題,我們想把程式往標準輸出(System.out)和標準錯誤輸出(System.err)中列印的資訊收集起來,但標準輸出裝置是整個虛擬機器程序全域性共享的資源,如果使用 System.setOut()/System.setErr() 方法把輸出流重定向到自己定義的 PrintStream 物件上固然可以收集輸出資訊,但也會對原有程式產生影響:會把其他執行緒向標準輸出中列印的資訊也收集了。雖然這些並不是不能解決的問題,不過為了達到完全不影響原程式的目的,我們可以採用另外一種辦法,即直接在執行的類中把對 System.out 的符號引用替換為我們準備的 PrintStream 的符號引用,依賴前面學習的只是,做到這一點並不困難。

實現

        在程式實現部分,我們主要看一下程式碼及其註釋。首先看看實現過程中需要用到的 4 個支援類。第一個類用於實現 “同一個類的程式碼可以被多次載入” 這個需求,具體程式如程式碼清單 9-3 所示。

程式碼清單 9-3  HotSwapClassLoader 的實現


  
  1. /**
  2. * 為了多次載入執行類而加入的載入器 <br>
  3. * 把 defineClass 方法開放出來,只有外部顯式呼叫的時候才會使用到 loadByte 方法