|
|
| 內容: |
|
| 用二進位表示的類 |
| 位元組碼和堆棧 |
| 裝入類 |
| 結束語 |
| 參考資料 |
| 關於作者 |
| 對本文的評價 |
|
|
| 相關內容: |
|
| 教程:瞭解 Java ClassLoader |
| 衡量 Java 原生編譯 |
| 如何封鎖您的(或開啟別人的) Java 代碼 |
| The Jikes Research Virtual Machine (RVM) |
| developerWorks Toolbox 訂閱 |
|
|
研究類以及 JVM 裝入類時所發生的情況 層級:中級 Dennis M. Sosnoski(dms@sosnoski.com) 總裁,Sosnoski Software Solutions, Inc. 2003 年 6 月
這一有關 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/S0050: 7472 696e 673b 2956 0c00 0700 0807 0014 tring;)V........0060: 0c00 1500 1601 000d 4865 6c6c 6f2c 2057 ........Hello, W0070: 6f72 6c64 2107 0017 0c00 1800 1901 0005 orld!...........0080: 4865 6c6c 6f01 0010 6a61 7661 2f6c 616e Hello...java/lan0090: 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 部分以瞭解有效反射的訣竅和利弊。 參考資料
- 直接到 The Java Virtual Machine Specification 的出處,以瞭解二進位類格式、類的裝入以及實際的 Java 位元組碼等細節。
- 閱讀 Greg Travis 編寫的教程“瞭解 Java ClassLoader”(developerWorks,2001 年 4 月)瞭解構建您自己的特殊類裝入器的所有細節。
- Martyn Honeyford 廣受歡迎的“衡量 Java 原生編譯”一文(developerWorks,2002 年 1 月)提供了有關 Java 語言機器碼編譯問題及利弊的更多詳細資料。
- 二進位類格式包含大量重要的資訊,通常這些資訊甚至足夠讓您重新構造原始碼(注釋除外)。在 Greg Travis 的“How to lock down your Java code (or open up someone else's)”一文(developerWorks,2001 年 5 月)中,他向您顯示了可以如何使用這些資訊。
- 擷取有關 Jikes Research Virtual Machine (RVM) 的細節,它是用 Java 語言實現的,並是自我託管的(即,它的 Java 代碼是依靠自身啟動並執行,不需要另一個虛擬機器)。
- 通過 Java 規範請求 175(Java Specification Request 175,JSR 175)的 A Metadata Facility for the Java Programming Language,緊跟使屬性可用於 Java 開發人員的發展。
- 瞭解 Apache Software Foundation 的 Apache Tomcat Java 語言 Web 服務器項目的細節,包括 Tomcat 類裝入器用法的細節。
- 在 developerWorks Java 技術專區可以找到數百篇 Java 技術參考資料。
關於作者 Dennis Sosnoski 是西雅圖地區 Java 諮詢公司 Sosnoski Software Solutions, Inc. 的創始人和首席顧問,他是 J2EE、XML 和 Web 服務支援方面的專家。他已經有 30 多年專業軟體開發經驗,最近幾年他集中研究伺服器端的 Java 技術。Dennis 經常在全國性的會議上就 XML 和 Java 技術發表演講,他還是 Seattle Java-XML SIG 的主席。可以通過 dms@sosnoski.com 與 Dennis 聯絡。 |
|