http://www-128.ibm.com/developerworks/cn/java/j-dyn0429/index.html
研究類以及 JVM 裝入類時所發生的情況
Dennis M. Sosnoski
總裁, Sosnoski Software Solutions, Inc.
2003 年 6 月 06 日
這一有關 Java 編程動態方面的新的系列文章研究了執行 Java 應用程序時幕后所發生的事情。企業 Java專家 Dennis Sosnoski 提供了 Java 二進制類格式以及在 JVM 內部類所發生的情況的內幕。接著,他將討論類裝入問題,其范圍涉及從運行簡單的 Java 應用程序所需的類的數量到可能造成 J2EE 及類似的復雜體系結構出現問題的類裝入器沖突。
本文是這個新系列文章的第一篇,該系列文章將討論我稱之為 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 部分以了解有效反射的訣竅和利弊。
參考資料