本文是這個新系列文章的第一篇,該系列文章將討論我稱之為 Java 編程的動態性的一系 列主題。這些主題的范圍從 Java 二進制類文件格式的基本結構,以及使用反射進行運行時 元數據訪問,一直到在運行時修改和構造新類。貫穿整篇文章的公共線索是這樣一種思想: 在 Java 平台上編程要比使用直接編譯成本機代碼的語言更具動態性。如果您理解了這些動 態方面,就可以使用 Java 編程完成那些在任何其它主流編程語言中不能完成的事情。
本文中,我將討論一些基本概念,它們是這些 Java 平台動態特性的基礎。這些概念的核 心是用於表示 Java 類的二進制格式,包括這些類裝入到 JVM 時所發生的情況。本文不僅是 本系列其余幾篇文章的基礎,而且還演示了開發人員在使用 Java 平台時碰到的一些非常實 際的問題。
用二進制表示的類
使用 Java 語言的開發人員在用編譯器編譯他們的源代碼時,通常不必關心對這些源代碼 做了些什麼這樣的細節。但是本系列文章中,我將討論從源代碼到執行程序所涉及的許多幕 後細節,因此我將首先探討由編譯器生成的二進制類。
二進制類格式實際上是由 JVM 規范定義的。通常這些類表示是由編譯器從 Java 語言源 代碼生成的,而且它們通常存儲在擴展名為 .class 的文件中。但是,這些特性都無關緊要 。已經開發了可以使用 Java 二進制類格式的其它一些編程語言,而且出於某些目的,還構 建了新的類表示,並被立即裝入到運行中的 JVM。就 JVM 而言,重要的部分不是源代碼以及 如何存儲源代碼,而是格式本身。
那麼這個類格式實際看上去是什麼樣呢?清單 1 提供了一個(非常)簡短的類的源代碼 ,還附帶了由編譯器輸出的類文件的部分十六進制顯示:
清單 1. Hello.java 的源代碼和(部分)二進制類文件
public class Hello
{
public static void main(String[] args) {
System.out.println("Hello, World!");
}
}
0000: cafe babe 0000 002e 001a 0a00 0600 0c09 ................
0010: 000d 000e 0800 0f0a 0010 0011 0700 1207 ................
0020: 0013 0100 063c 696e 6974 3e01 0003 2829 .....<init>...()
0030: 5601 0004 436f 6465 0100 046d 6169 6e01 V...Code...main.
0040: 0016 285b 4c6a 6176 612f 6c61 6e67 2f53 ..([Ljava/lang/S
0050: 7472 696e 673b 2956 0c00 0700 0807 0014 tring;)V........
0060: 0c00 1500 1601 000d 4865 6c6c 6f2c 2057 ........Hello, W
0070: 6f72 6c64 2107 0017 0c00 1800 1901 0005 orld!...........
0080: 4865 6c6c 6f01 0010 6a61 7661 2f6c 616e Hello...java/lan
0090: 672f 4f62 6a65 6374 0100 106a 6176 612f g/Object...java/
00a0: 6c61 6e67 2f53 7973 7465 6d01 0003 6f75 lang/System...ou
...
二進制類文件的內幕
清單 1 顯示的二進制類表示中首先是“cafe babe”特征符,它標識 Java 二進制類格式 (並順便作為一個永久的 ― 但在很大程度上未被認識到的 ― 禮物送給努力工作的 barista,他們本著開發人員所具備的精神構建 Java 平台)。這個特征符恰好是一種驗證一 個數據塊 確實聲明成 Java 類格式的一個實例的簡單方法。任何 Java 二進制類(甚至是文 件系統中沒有出現的類)都需要以這四個字節作為開始。
該數據的其余部分不太吸引人。該特征符之後是一對類格式版本號(本例中,是由 1.4.1 javac 生成的次版本 0 和主版本 46 ― 用十六進制表示就是 0x2e),接著是常量池中項的 總數。項總數(本例中,是 26,或 0x001a)後面是實際的常量池數據。這裡放著類定義所 用的所有常量。它包括類名和方法名、特征符以及字符串(您可以在十六進制轉儲右側的文 本解釋中識別它們),還有各種二進制值。
常量池中各項的長度是可變的,每項的第一個字節標識項的類型以及對它解碼的方式。這 裡我不詳細探究所有這些內容的細節,如果感興趣,有許多可用的的參考資料,從實際的 JVM 規范開始。關鍵之處在於常量池包含對該類所用的其它類和方法的所有引用,還包含了 該類及其方法的實際定義。常量池往往占到二進制類大小的一半或更多,但平均下來可能要 少一些。
常量池後面還有幾項,它們引用了類本身、其超類以及接口的常量池項。這些項後面是有 關字段和方法的信息,它們本身用復雜結構表示。方法的可執行代碼以包含在方法定義中的 代碼屬性的形式出現。用 JVM 的指令形式表示該代碼,一般稱為 字節碼,這是下一節要討 論的主題之一。
在 Java 類格式中, 屬性被用於幾個已定義的用途,包括已提到的字節碼、字段的常量 值、異常處理以及調試信息。但是屬性並非只可能用於這些用途。從一開始,JVM 規范就已 經要求 JVM 忽略未知類型的屬性。這一要求所帶來的靈活性使得將來可以擴展屬性的用法以 滿足其它用途,例如提供使用用戶類的框架所需的元信息,這種方法在 Java 派生的 C# 語 言中已廣泛使用。遺憾的是,對於在用戶級利用這一靈活性還沒有提供任何掛鉤。
字節碼和堆棧
構成類文件可執行部分的字節碼實際上是針對特定類型的計算機 ― JVM ― 的機器碼。 它被稱為 虛擬機,因為它被設計成用軟件來實現,而不是用硬件來實現。每個用於運行 Java 平台應用程序的 JVM 都是圍繞該機器的實現而被構建的。
這個虛擬機實際上相當簡單。它使用堆棧體系結構,這意味著在使用指令操作數之前要先 將它們裝入內部堆棧。指令集包含所有的常規算術和邏輯運算,以及條件轉移和無條件轉移 、裝入/存儲、調用/返回、堆棧操作和幾種特殊類型的指令。有些指令包含立即操作數值 ,它們被直接編碼到指令中。其它指令直接引用常量池中的值。
盡管虛擬機很簡單,但實現卻並非如此。早期的(第一代)JVM 基本上是虛擬機字節碼的 解釋器。這些虛擬機實際上 的確相對簡單,但存在嚴重的性能問題 ― 解釋代碼的時間總是 會比執行本機代碼的時間長。為了減少這些性能問題,第二代 JVM 添加了 即時(just-in- time,JIT)轉換。在第一次執行 Java 字節碼之前,JIT 技術將它編譯成本機代碼,從而對 於重復執行提供了更好的性能。當代 JVM 的性能甚至還要好得多,因為使用了適應性技術來 監控程序的執行並有選擇地優化頻繁使用的代碼。
裝入類
諸如 C 和 C++ 這些編譯成本機代碼的語言通常在編譯完源代碼之後需要鏈接這個步驟。 這一鏈接過程將來自獨立編譯好的各個源文件的代碼和共享庫代碼合並起來,從而形成了一 個可執行程序。Java 語言就不同。使用 Java 語言,由編譯器生成的類在被裝入到 JVM 之 前通常保持原狀。即使從類文件構建 JAR 文件也不會改變這一點 ― JAR 只是類文件的容器 。
鏈接類不是一個獨立步驟,它是在 JVM 將這些類裝入到內存時所執行作業的一部分。在 最初裝入類時這一步會增加一些開銷,但也為 Java 應用程序提供了高度靈活性。例如,在 編寫應用程序以使用接口時,可以到運行時才指定其實際實現。這個用於組裝應用程序的 後 聯編方法廣泛用於 Java 平台,servlet 就是一個常見示例。
JVM 規范中詳細描述了裝入類的規則。其基本原則是只在需要時才裝入類(或者至少看上 去是這樣裝入 ― JVM 在實際裝入時有一些靈活性,但必須保持固定的類初始化順序)。每 個裝入的類都可能擁有其它所依賴的類,所以裝入過程是遞歸的。清單 2 中的類顯示了這一 遞歸裝入的工作方式。 Demo 類包含一個簡單的 main 方法,它創建了 Greeter 的實例,並 調用 greet 方法。 Greeter 構造函數創建了 Message 的實例,隨後會在 greet 方法調用 中使用它。
清單 2. 類裝入演示的源代碼
public class Demo
{
public static void main(String[] args) {
System.out.println("**beginning execution**");
Greeter greeter = new Greeter();
System.out.println("**created Greeter**");
greeter.greet();
}
}
public class Greeter
{
private static Message s_message = new Message("Hello, World!");
public void greet() {
s_message.print(System.out);
}
}
public class Message
{
private String m_text;
public Message(String text) {
m_text = text;
}
public void print(java.io.PrintStream ps) {
ps.println(m_text);
}
}
在 java 命令行上設置參數 -verbose:class 會打印類裝入過程的跟蹤記錄。清單 3 顯 示了使用這一參數運行清單 2 程序的部分輸出:
清單 3. -verbose:class 的部分輸出
[Opened /usr/java/j2sdk1.4.1/jre/lib/rt.jar]
[Opened /usr/java/j2sdk1.4.1/jre/lib/sunrsasign.jar]
[Opened /usr/java/j2sdk1.4.1/jre/lib/jsse.jar]
[Opened /usr/java/j2sdk1.4.1/jre/lib/jce.jar]
[Opened /usr/java/j2sdk1.4.1/jre/lib/charsets.jar]
[Loaded java.lang.Object from /usr/java/j2sdk1.4.1/jre/lib/rt.jar]
[Loaded java.io.Serializable from /usr/java/j2sdk1.4.1/jre/lib/rt.jar]
[Loaded java.lang.Comparable from /usr/java/j2sdk1.4.1/jre/lib/rt.jar]
[Loaded java.lang.CharSequence from /usr/java/j2sdk1.4.1/jre/lib/rt.jar]
[Loaded java.lang.String from /usr/java/j2sdk1.4.1/jre/lib/rt.jar]
...
[Loaded java.security.Principal from /usr/java/j2sdk1.4.1/jre/lib/rt.jar]
[Loaded java.security.cert.Certificate
from /usr/java/j2sdk1.4.1/jre/lib/rt.jar]
[Loaded Demo]
**beginning execution**
[Loaded Greeter]
[Loaded Message]
**created Greeter**
Hello, World!
[Loaded java.util.HashMap$KeySet
from /usr/java/j2sdk1.4.1/jre/lib/rt.jar]
[Loaded java.util.HashMap$KeyIterator
from /usr/java/j2sdk1.4.1/jre/lib/rt.jar]
這只列出了輸出中最重要的部分 ― 完整的跟蹤記錄由 294 行組成,我刪除了其中大部 分,形成了這個清單。最初的一組類裝入(本例中是 279 個)都是在嘗試裝入 Demo 類時觸 發的。這些類是每個 Java 程序(不管有多小)都要使用的核心類。即使刪除 Demo main 方 法的所有代碼也不會影響這個初始的裝入順序。但是不同版本的類庫所涉及的類數量和名稱 都不同。
在上面這個清單中,裝入 Demo 類之後的部分更有趣。這裡的順序顯示了只有在准備創建 Greeter 類的實例時才會裝入該類。不過, Greeter 類使用了 Message 類的靜態實例,所 以在可以創建 Greeter 類的實例之前,還必須先裝入 Message 類。
在裝入並初始化類時,JVM 內部會完成許多操作,包括解碼二進制類格式、檢查與其它類 的兼容性、驗證字節碼操作的順序以及最終構造 java.lang.Class 實例來表示新類。這個 Class 對象成了 JVM 創建新類的所有實例的基礎。它還是已裝入類本身的標識 ― 對於裝入 到 JVM 的同一個二進制類,可以有多個副本,每個副本都有其自己的 Class 實例。即使這 些副本都共享同一個類名,但對 JVM 而言它們都是獨立的類。
非常規(類)路徑
裝入到 JVM 的類是由 類裝入器控制的。JVM 中構建了一個 引導程序類裝入器,它負責 裝入基本的 Java 類庫類。這個特殊的類裝入器有一些專門的特性。首先,它只裝入在引導 類路徑上找到的類。因為這些是可信的系統類,所以引導程序裝入器跳過了對常規(不可信 )類所做的大量驗證。
引導程序不是唯一的類裝入器。對於初學者而言,JVM 為裝入標准 Java 擴展 API 中的 類定義了一個 擴展類裝入器,並為裝入一般類路徑上的類(包括應用程序類)定義了一個 系統類裝入器。應用程序還可以定義它們自己的用於特殊用途(例如運行時類的重新裝入) 的類裝入器。這樣添加的類裝入器派生自 java.lang.ClassLoader 類(可能是間接派生的) ,該類對從字節數組構建內部類表示( java.lang.Class 實例)提供了核心支持。每個構造 好的類在某種意義上是由裝入它的類裝入器所“擁有”。類裝入器通常保留它們所裝入類的 映射,從而當再次請求某個類時,能通過名稱找到該類。
每個類裝入器還保留對父類裝入器的引用,這樣就定義了類裝入器樹,樹根為引導程序裝 入器。在需要某個特定類的實例(由名稱來標識)時,無論哪個類裝入器最初處理該請求, 在嘗試直接裝入該類之前,一般都會先檢查其父類裝入器。如果存在多層類裝入器,那麼會 遞歸執行這一步,所以這意味著通常不僅在裝入該類的類裝入器中該類是 可見的,而且對於 所有後代類裝入器也都是可見的。這還意味著如果一條鏈上有多個類裝入器可以裝入某個類 ,那麼該樹最上端的那個類裝入器會是實際裝入該類的類裝入器。
在許多環境中,Java 程序會使用多個應用程序類裝入器。J2EE 框架就是一個示例。該框 架裝入的每個 J2EE 應用程序都需要擁有一個獨立的類裝入器以防止一個應用程序中的類干 擾其它應用程序。該框架代碼本身也將使用一個或多個其它類裝入器,同樣用來防止對應用 程序產生的或來自應用程序的干擾。整個類裝入器集合形成了樹狀結構的層次結構,在其每 個層次上都可裝入不同類型的類。
裝入器樹
作為類裝入器層次結構的實際示例,圖 1 顯示了 Tomcat servlet 引擎定義的類裝入器 層次結構。這裡 Common 類裝入器從 Tomcat 安裝的某個特定目錄的 JAR 文件進行裝入,旨 在用於在服務器和所有 Web 應用程序之間共享代碼。Catalina 裝入器用於裝入 Tomcat 自 己的類,而 Shared 裝入器用於裝入 Web 應用程序之間共享的類。最後,每個 Web 應用程 序有自己的裝入器用於其私有類。
圖 1. Tomcat 類裝入器
在這種環境中,跟蹤合適的裝入器以用於請求新類會很混亂。為此,在 Java 2 平台中將 setContextClassLoader 方法和 getContextClassLoader 方法添加到了 java.lang.Thread 類中。這些方法允許該框架設置類裝入器,使得在運行每個應用程序中的代碼時可以將類裝 入器用於該應用程序。
能裝入獨立的類集合這一靈活性是 Java 平台的一個重要特性。盡管這個特性很有用,但 是它在某些情況中會產生混淆。一個令人混淆的方面是處理 JVM 類路徑這樣的老問題。例如 ,在圖 1 顯示的 Tomcat 類裝入器層次結構中,由 Common 類裝入器裝入的類決不能(根據 名稱)直接訪問由 Web 應用程序裝入的類。使這些類聯系在一起的唯一方法是通過使用這兩 個類集都可見的接口。在這個例子中,就是包含由 Java servlet 實現的 javax.servlet.Servlet 。
無論何種原因在類裝入器之間移動代碼時都會出現問題。例如,當 J2SE 1.4 將用於 XML 處理的 JAXP API 移到標准分發版中時,在許多環境中都產生了問題,因為這些環境中的應 用程序以前是依賴於裝入它們自己選擇的 XML API 實現的。使用 J2SE 1.3,只要在用戶類 路徑中包含合適的 JAR 文件就可以解決該問題。在 J2SE 1.4 中,這些 API 的標准版現在 位於擴展的類路徑中,所以它們通常將覆蓋用戶類路徑中出現的任何實現。
使用多個類裝入器還可能引起其它類型的混淆。圖 2 顯示了 類身份危機(class identity crisis)的示例,它是在兩個獨立類裝入器都裝入一個接口及其相關的實現時產生 的危機。即使接口和類的名稱和二進制實現都相同,但是來自一個裝入器的類的實例不能被 認為是實現了來自另一個裝入器的接口。圖 2 中通過將接口類 I 移至 System 類裝入器的 空間就可以解除這種混淆。類 A 仍然有兩個獨立的實例,但它們都實現了同一個接口 I 。
圖 2. 類身份危機
結束語
Java 類定義和 JVM 規范一起為運行時組裝代碼定義了功能極其強大的框架。通過使用類 裝入器,Java 應用程序能使用多個版本的類,否則這些類就會引起沖突。類裝入器的靈活性 甚至允許動態地重新裝入已修改的代碼,同時應用程序繼續執行。
這裡,Java 平台靈活性在某種程度上是以啟動應用程序時較高的開銷作為代價的。在 JVM 可以開始執行甚至最簡單的應用程序代碼之前,它都必須裝入數百個獨立的類。相對於 頻繁使用的小程序,這個啟動成本通常使 Java 平台更適合於長時間運行的服務器類型的應 用程序。服務器應用程序還最大程度地受益於代碼在運行時進行組裝這種靈活性,所以對於 這種開發,Java 平台正日益受寵也就不足為奇了。
在本系列文章的第 2 部分中,我將介紹使用 Java 平台動態基礎的另一個方面:反射 API(Reflection API)。反射使執行代碼能夠訪問內部類信息。這可能是構建靈活代碼的極 佳工具,可以不使用類之間任何源代碼鏈接就能夠在運行時將代碼掛接在一起。但象使用大 多數工具一樣,您必須知道何時及如何使用它以獲得最大利益。請閱讀 Java 編程的動態性 第 2 部分以了解有效反射的訣竅和利弊。