1. 程式人生 > >[深入理解Java虛擬機器]第九章 位元組碼執行引擎-類載入及執行子系統的案例與實戰

[深入理解Java虛擬機器]第九章 位元組碼執行引擎-類載入及執行子系統的案例與實戰

概述

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

案例分析

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生成類的熱替換,當然也有“非主流”的 ,如執行在生產模式( Production Mode ) 下的WebLogic伺服器預設就不會處理JSP檔案的變化。

由於存在上述問題,在部署Web應用時 ,單獨的一個ClassPath就無法滿足需求了,所以各種Web伺服器都“不約而同”地提供了好幾個ClassPath路徑供使用者存放第三方類庫,這些路徑一般都以“lib”或“classes”命名。被放置到不同路徑中的類庫,具備不同的訪問範圍和服務物件,通常,每一個目錄都會有一個相應的自定義類載入器去載入放置在裡面的Java類庫。 現在 ,筆者就以Tomcat伺服器為例,看一看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個載入器的作用在第7章中已經介紹過了。而CommonClassLoader、CatalinaClassLoader、SharedClassLoader和WebappClassLoader則是Tomcat自己定義的類載入器,它們分別載入/common/*、/server/*、 /shared/*和/WebApp/WEB-INF/*中的Java類庫。其中WebApp類載入器和Jsp類載入器通常會存在多個例項,每一個Web應用程式對應一個WebApp類載入器,每一個JSP檔案對應一個Jsp類載入器。

從圖9-1的委派關係可以看出,CommonClassLoader能載入的類都可以被Catalina ClassLoader和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如何訪問棄不在其載入範圍內的使用者程式呢?如果讀過本書第7章的相關內容,相信讀者可以很容易地回答這個問題。

Tomcat是Apache基金會中的一款開源的Java Web伺服器 ,主頁地址為: http://tomcat.apache.org。本案例中選用的是Tomcat 5.x伺服器的目錄和類載入器結構,在Tomcat6.x的預設配置下,/common、/server和/shared三個目錄已經合併到一起了。

OSGi :靈活的類載入器架構

Java程式社群中流傳著這麼一個觀點:“學習JEE規 範 ,去看JBoss源 碼 ;學習類載入器, 就去看OSGi原始碼”。儘管“JEE規範”和“類載入器的知識”並不是一個對等的概念,不 過 ,既然 這個觀點能在程式設計師中流傳開來,也從側面說明了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伺服器、jBossOSGi等。

OSGi中的每個模組(稱為Bundle)與普通的Java類庫區別並不太大,兩者一般都以JAR格式進行封裝,並且內部儲存的都是Java Package和Class。但是一個Bundle可以宣告它所依賴的Java Package(通過Import-Packagel描述),也可以宣告它允許匯出釋出的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在提供強大功能的同時,也引入了額外的複雜度,帶來了執行緒死鎖和記憶體洩漏的風險。

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

“位元組碼生成”並不是什麼高深的技術,讀者在看到“位元組碼生成”這個標題時也先不必去想諸如Javassist、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 動態代理的簡單示例

public class DynamicProxyTest {

    interface IHello {
        void sayHello();
    }

    static class Hello implements IHello {
        @Override
        public void sayHello() {
            System.out.println("hello world");
        }
    }

    static class DynamicProxy implements InvocationHandler {

        Object originalObj;

        Object bind(Object originalObj) {
            this.originalObj = originalObj;
            return Proxy.newProxyInstance(originalObj.getClass().getClassLoader(), originalObj.getClass().getInterfaces(), this);
        }

        @Override
        public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
            System.out.println("welcome");
            return method.invoke(originalObj, args);
        }
    }

    public static void main(String[] args) {
        IHello hello = (IHello) new DynamicProxy().bind(new Hello());
        hello.sayHello();
    }
}

執行結果如下:

welcome 
hello world

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


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

加入這句程式碼後再次執行程式,磁碟中將會產生一個名為“$Proxy0.class”的代理類Class檔案 ,反編譯後可以看見如程式碼清單9-2所示的內容。

加入這句程式碼後再次執行程式,磁碟中將會產生一個名為“$Proxy0.class”的代理類Class檔案 ,反編譯後可以看見如程式碼清單9-2所示的內容。

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

package org.fenixsoft.bytecode;

import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.lang.reflect.UndeclaredThrowableException;

public final class $Proxy0 extends Proxy
  implements DynamicProxyTest.IHello
{
  private static Method m3;
  private static Method m1;
  private static Method m0;
  private static Method m2;

  public $Proxy0(InvocationHandler paramInvocationHandler)
    throws 
  {
    super(paramInvocationHandler);
  }

  public final void sayHello()
    throws 
  {
    try
    {
      this.h.invoke(this, m3, null);
      return;
    }
    catch (RuntimeException localRuntimeException)
    {
      throw localRuntimeException;
    }
    catch (Throwable localThrowable)
    {
      throw new UndeclaredThrowableException(localThrowable);
    }
  }

  // 此處由於版面原因,省略equals()、hashCode()、toString()三個方法的程式碼
  // 這3個方法的內容與sayHello()非常相似。

  static
  {
    try
    {
      m3 = Class.forName("org.fenixsoft.bytecode.DynamicProxyTest$IHello").getMethod("sayHello", new Class[0]);
      m1 = Class.forName("java.lang.Object").getMethod("equals", new Class[] { Class.forName("java.lang.Object") });
      m0 = Class.forName("java.lang.Object").getMethod("hashCode", new Class[0]);
      m2 = Class.forName("java.lang.Object").getMethod("toString", new Class[0]);
      return;
    }
    catch (NoSuchMethodException localNoSuchMethodException)
    {
      throw new NoSuchMethodError(localNoSuchMethodException.getMessage());
    }
    catch (ClassNotFoundException localClassNotFoundException)
    {
      throw new NoClassDefFoundError(localClassNotFoundException.getMessage());
    }
  }
}

這個代理類的實現程式碼也很簡單,它為傳入介面中的每一個方法,以及從 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”的類庫來代替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()方法的普通類而已。

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 Script、JavaScript等的執行引擎(如Mozilla Rhino)去執行動態指令碼。在本章的實戰部分,我們將使用前面學到的關於類載入及虛擬機器執行子系統的知識去實現在服務端執行臨時程式碼的功能。

目標

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

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

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

思路

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

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

對於第一個問題,我們有兩種思路可以選擇,一種是使用tools.jar包 (在SunJDK/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類在執行完後就應當能解除安裝和回收。

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

實現

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

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

/**
 * 為了多次載入執行類而加入的載入器<br>
 * 把defineClass方法開放出來,只有外部顯式呼叫的時候才會使用到loadByte方法
 * 由虛擬機器呼叫時,仍然按照原有的雙親委派規則使用loadClass方法進行類載入
 *
 * @author zzm
 */
public class HotSwapClassLoader extends ClassLoader {

    public HotSwapClassLoader() {
        super(HotSwapClassLoader.class.getClassLoader());
    }

    public Class loadByte(byte[] classByte) {
        return defineClass(null, classByte, 0, classByte.length);
    }

}

HotSwapClassLoader所做的事情僅僅是公開父類(即java.lang.ClassLoader ) 中的protected方法defineClass() ,我們將會使用這個方法把提交執行的Java類的byte[]陣列轉變為Class物件。HotSwapClassLoader中並沒有重寫loadClass() 或findClass() 方法 ,因此如果不算外部手工呼叫loadByte() 方法的話,這個類載入器的類查詢範圍與它的父類載入器是完全一致的,在被虛擬機器呼叫時,它會按照雙親委派模型交給父類載入。建構函式中指定為載入HotSwapClassLoader類的類載入器也為父類載入器,這一步是實現提交的執行程式碼可以訪問服務端引用類庫的關鍵,下面我們來看看程式碼清單9-3。

第二個類是實現將java.lang.System替換為我們自己定義的HackSystem類的過程,它直接修改符合Class檔案格式的byte[]陣列中的常量池部分,將常量池中指定內容的 CONSTANT_UtfB_info常量替換為新的字串,具體程式碼如程式碼清單9-4所示。 ClassModifier中涉及對byte[]陣列操作的部分,主要是將byte[]與int和String互相轉換,以及把對byte[]資料的替換操作封裝在程式碼清單9-5所示的ByteUtils中。

程式碼清單9-4 ClassModifier的實現

/**
 * 修改Class檔案,暫時只提供修改常量池常量的功能
 * @author zzm 
 */
public class ClassModifier {

    /**
     * Class檔案中常量池的起始偏移
     */
    private static final int CONSTANT_POOL_COUNT_INDEX = 8;

    /**
     * CONSTANT_Utf8_info常量的tag標誌
     */
    private static final int CONSTANT_Utf8_info = 1;

    /**
     * 常量池中11種常量所佔的長度,CONSTANT_Utf8_info型常量除外,因為它不是定長的
     */
    private static final int[] CONSTANT_ITEM_LENGTH = { -1, -1, -1, 5, 5, 9, 9, 3, 3, 5, 5, 5, 5 };

    private static final int u1 = 1;
    private static final int u2 = 2;

    private byte[] classByte;

    public ClassModifier(byte[] classByte) {
        this.classByte = classByte;
    }

    /**
     * 修改常量池中CONSTANT_Utf8_info常量的內容
     * @param oldStr 修改前的字串
     * @param newStr 修改後的字串
     * @return 修改結果
     */
    public byte[] modifyUTF8Constant(String oldStr, String newStr) {
        int cpc = getConstantPoolCount();
        int offset = CONSTANT_POOL_COUNT_INDEX + u2;
        for (int i = 0; i < cpc; i++) {
            int tag = ByteUtils.bytes2Int(classByte, offset, u1);
            if (tag == CONSTANT_Utf8_info) {
                int len = ByteUtils.bytes2Int(classByte, offset + u1, u2);
                offset += (u1 + u2);
                String str = ByteUtils.bytes2String(classByte, offset, len);
                if (str.equalsIgnoreCase(oldStr)) {
                    byte[] strBytes = ByteUtils.string2Bytes(newStr);
                    byte[] strLen = ByteUtils.int2Bytes(newStr.length(), u2);
                    classByte = ByteUtils.bytesReplace(classByte, offset - u2, u2, strLen);
                    classByte = ByteUtils.bytesReplace(classByte, offset, len, strBytes);
                    return classByte;
                } else {
                    offset += len;
                }
            } else {
                offset += CONSTANT_ITEM_LENGTH[tag];
            }
        }
        return classByte;
    }

    /**
     * 獲取常量池中常量的數量
     * @return 常量池數量
     */
    public int getConstantPoolCount() {
        return ByteUtils.bytes2Int(classByte, CONSTANT_POOL_COUNT_INDEX, u2);
    }
}

程式碼清單9-5 ByteUtils的實現

/**
 * Bytes陣列處理工具
 * @author
 */
public class ByteUtils {

    public static int bytes2Int(byte[] b, int start, int len) {
        int sum = 0;
        int end = start + len;
        for (int i = start; i < end; i++) {
            int n = ((int) b[i]) & 0xff;
            n <<= (--len) * 8;
            sum = n + sum;
        }
        return sum;
    }

    public static byte[] int2Bytes(int value, int len) {
        byte[] b = new byte[len];
        for (int i = 0; i < len; i++) {
            b[len - i - 1] = (byte) ((value >> 8 * i) & 0xff);
        }
        return b;
    }

    public static String bytes2String(byte[] b, int start, int len) {
        return new String(b, start, len);
    }

    public static byte[] string2Bytes(String str) {
        return str.getBytes();
    }

    public static byte[] bytesReplace(byte[] originalBytes, int offset, int len, byte[] replaceBytes) {
        byte[] newBytes = new byte[originalBytes.length + (replaceBytes.length - len)];
        System.arraycopy(originalBytes, 0, newBytes, 0, offset);
        System.arraycopy(replaceBytes, 0, newBytes, offset, replaceBytes.length);
        System.arraycopy(originalBytes, offset + len, newBytes, offset + replaceBytes.length, originalBytes.length - offset - len);
        return newBytes;
    }
}

經過ClassModifier處理後的byte[]陣列才會傳給HotSwapClassLoader.loadByte()方法進行類載入,byte[]陣列在這裡替換符號引用之後,與客戶端直接在Java程式碼中引用HackSystem類再編譯生成的Class是完全一樣的。這樣的實現既避免了客戶端編寫臨時執行程式碼時要依賴特定的類(不然無法引入HackSystem) ,又避免了服務端修改標準輸出後影響到其他程式的 輸出。下面我們來看看程式碼清單9-4和程式碼清單9-5。

/**
 * 為JavaClass劫持java.lang.System提供支援
 * 除了out和err外,其餘的都直接轉發給System處理
 * 
 * @author zzm
 */
public class HackSystem {

    public final static InputStream in = System.in;

    private static ByteArrayOutputStream buffer = new ByteArrayOutputStream();

    public final static PrintStream out = new PrintStream(buffer);

    public final static PrintStream err = out;

    public static String getBufferString() {
        return buffer.toString();
    }

    public static void clearBuffer() {
        buffer.reset();
    }

    public static void setSecurityManager(final SecurityManager s) {
        System.setSecurityManager(s);
    }

    public static SecurityManager getSecurityManager() {
        return System.getSecurityManager();
    }

    public static long currentTimeMillis() {
        return System.currentTimeMillis();
    }

    public static void arraycopy(Object src, int srcPos, Object dest, int destPos, int length) {
        System.arraycopy(src, srcPos, dest, destPos, length);
    }

    public static int identityHashCode(Object x) {
        return System.identityHashCode(x);
    }

    // 下面所有的方法都與java.lang.System的名稱一樣
    // 實現都是位元組轉調System的對應方法
    // 因版面原因,省略了其他方法
}

至此, 4個支援類已經講解完畢,我們來看看最後一個類JavaClassExecuter , 它是提供給外部呼叫的入口,呼叫前面幾個支援類組裝邏輯,完成類載入工作。JavaClassExecuter只有一個execute()方法,用輸入的符合Class檔案格式的byte[]陣列替換java.lang.System的符號引用後,使用HotSwapClassLoader載入生成一個Class物件,由於每次執行execute()方法都會生成一個新的類載入器例項,因此同一個類可以實現重複載入。然後,反射呼叫這個Class物件的main()方法,如果期間出現任何異常,將異常資訊列印到HackSystemout中,最後把緩衝區中的資訊、作為方法的結果返回。JavaClassExecuter的實現程式碼如代運清單9- 7所示。

程式碼清單9-7 JavaClassExecuter的實現

/**
 * JavaClass執行工具
 *
 * @author zzm
 */
public class JavaClassExecuter {

    /**
     * 執行外部傳過來的代表一個Java類的Byte陣列<br>
     * 將輸入類的byte陣列中代表java.lang.System的CONSTANT_Utf8_info常量修改為劫持後的HackSystem類
     * 執行方法為該類的static main(String[] args)方法,輸出結果為該類向System.out/err輸出的資訊
     * @param classByte 代表一個Java類的Byte陣列
     * @return 執行結果
     */
    public static String execute(byte[] classByte) {
        HackSystem.clearBuffer();
        ClassModifier cm = new ClassModifier(classByte);
        byte[] modiBytes = cm.modifyUTF8Constant("java/lang/System", "org/fenixsoft/classloading/execute/HackSystem");
        HotSwapClassLoader loader = new HotSwapClassLoader();
        Class clazz = loader.loadByte(modiBytes);
        try {
            Method method = clazz.getMethod("main", new Class[] { String[].class });
            method.invoke(null, new String[] { null });
        } catch (Throwable e) {
            e.printStackTrace(HackSystem.out);
        }
        return HackSystem.getBufferString();
    }
}

驗證

遠端執行功能的編碼到此就完成了,接下來就要檢驗一下我們的勞動成果了。如果只是測試的話,那麼可以任意寫一個Java類 ,內容無所謂,只要向System.out輸出資訊即可,取名為TestClass, 同時放到伺服器C盤的根目錄中。然後,建立一個JSP檔案並加入如程式碼清單9- 8所示的內容,就可以在瀏覽器中看到這個類的執行結果了。

<%@ page import="java.lang.*" %>
<%@ page import="java.io.*" %>
<%@ page import="org.fenixsoft.classloading.execute.*" %>
<%
    InputStream is = new FileInputStream("c:/TestClass.class");
    byte[] b = new byte[is.available()];
    is.read(b);
    is.close();

    out.println("<textarea style='width:1000;height=800'>");
    out.println(JavaClassExecuter.execute(b));
    out.println("</textarea>"); 
%>

當然 ,上面的做法只是用於測試和演示,實際使用這個JavaExecuter執行器的時候,如果還要手工複製一個Class檔案到伺服器上就沒有什麼意義了。筆者給這個執行器寫了一個“外殼”,是一個Eclipse外掛 ,可以把Java檔案編譯後傳輸到伺服器中,然後把執行器的返回結果輸到Eclipse的Console窗口裡,這樣就可以在有靈感的時候隨時寫幾行除錯程式碼, 放到測試環境的伺服器上立即運行了。雖然實現簡單,但效果很不錯,對除錯問題也非常有用 ,如圖9-4所示。