研究類以及 JVM 裝入類時(shí)所發(fā)生的情況
總裁, Sosnoski Software Solutions, Inc.
2003 年 6 月 06 日
這一有關(guān) Java 編程動(dòng)態(tài)方面的新的系列文章研究了執(zhí)行 Java 應(yīng)用程序時(shí)幕后所發(fā)生的事情。企業(yè) Java專家 Dennis Sosnoski 提供了 Java 二進(jìn)制類格式以及在 JVM 內(nèi)部類所發(fā)生的情況的內(nèi)幕。接著,他將討論類裝入問題,其范圍涉及從運(yùn)行簡(jiǎn)單的 Java 應(yīng)用程序所需的類的數(shù)量到可能造成 J2EE 及類似的復(fù)雜體系結(jié)構(gòu)出現(xiàn)問題的類裝入器沖突。
本文是這個(gè)新系列文章的第一篇,該系列文章將討論我稱之為 Java 編程的動(dòng)態(tài)性的一系列主題。這些主題的范圍從 Java 二進(jìn)制類文件格式的基本結(jié)構(gòu),以及使用反射進(jìn)行運(yùn)行時(shí)元數(shù)據(jù)訪問,一直到在運(yùn)行時(shí)修改和構(gòu)造新類。貫穿整篇文章的公共線索是這樣一種思想:在 Java 平臺(tái)上編程要比使用直接編譯成本機(jī)代碼的語言更具動(dòng)態(tài)性。如果您理解了這些動(dòng)態(tài)方面,就可以使用 Java 編程完成那些在任何其它主流編程語言中不能完成的事情。
本文中,我將討論一些基本概念,它們是這些 Java 平臺(tái)動(dòng)態(tài)特性的基礎(chǔ)。這些概念的核心是用于表示 Java 類的二進(jìn)制格式,包括這些類裝入到 JVM 時(shí)所發(fā)生的情況。本文不僅是本系列其余幾篇文章的基礎(chǔ),而且還演示了開發(fā)人員在使用 Java 平臺(tái)時(shí)碰到的一些非常實(shí)際的問題。
用二進(jìn)制表示的類
使用 Java 語言的開發(fā)人員在用編譯器編譯他們的源代碼時(shí),通常不必關(guān)心對(duì)這些源代碼做了些什么這樣的細(xì)節(jié)。但是本系列文章中,我將討論從源代碼到執(zhí)行程序所涉及的許多幕后細(xì)節(jié),因此我將首先探討由編譯器生成的二進(jìn)制類。
二進(jìn)制類格式實(shí)際上是由 JVM 規(guī)范定義的。通常這些類表示是由編譯器從 Java 語言源代碼生成的,而且它們通常存儲(chǔ)在擴(kuò)展名為 .class
的文件中。但是,這些特性都無關(guān)緊要。已經(jīng)開發(fā)了可以使用 Java 二進(jìn)制類格式的其它一些編程語言,而且出于某些目的,還構(gòu)建了新的類表示,并被立即裝入到運(yùn)行中的 JVM。就 JVM 而言,重要的部分不是源代碼以及如何存儲(chǔ)源代碼,而是格式本身。
那么這個(gè)類格式實(shí)際看上去是什么樣呢?清單 1 提供了一個(gè)(非常)簡(jiǎn)短的類的源代碼,還附帶了由編譯器輸出的類文件的部分十六進(jìn)制顯示:
清單 1. Hello.java 的源代碼和(部分)二進(jìn)制類文件
|
二進(jìn)制類文件的內(nèi)幕
清單 1 顯示的二進(jìn)制類表示中首先是“cafe babe”特征符,它標(biāo)識(shí) Java 二進(jìn)制類格式(并順便作為一個(gè)永久的 ― 但在很大程度上未被認(rèn)識(shí)到的 ― 禮物送給努力工作的 barista,他們本著開發(fā)人員所具備的精神構(gòu)建 Java 平臺(tái))。這個(gè)特征符恰好是一種驗(yàn)證一個(gè)數(shù)據(jù)塊 確實(shí)聲明成 Java 類格式的一個(gè)實(shí)例的簡(jiǎn)單方法。任何 Java 二進(jìn)制類(甚至是文件系統(tǒng)中沒有出現(xiàn)的類)都需要以這四個(gè)字節(jié)作為開始。
該數(shù)據(jù)的其余部分不太吸引人。該特征符之后是一對(duì)類格式版本號(hào)(本例中,是由 1.4.1 javac 生成的次版本 0 和主版本 46 ― 用十六進(jìn)制表示就是 0x2e),接著是常量池中項(xiàng)的總數(shù)。項(xiàng)總數(shù)(本例中,是 26,或 0x001a)后面是實(shí)際的常量池?cái)?shù)據(jù)。這里放著類定義所用的所有常量。它包括類名和方法名、特征符以及字符串(您可以在十六進(jìn)制轉(zhuǎn)儲(chǔ)右側(cè)的文本解釋中識(shí)別它們),還有各種二進(jìn)制值。
常量池中各項(xiàng)的長(zhǎng)度是可變的,每項(xiàng)的第一個(gè)字節(jié)標(biāo)識(shí)項(xiàng)的類型以及對(duì)它解碼的方式。這里我不詳細(xì)探究所有這些內(nèi)容的細(xì)節(jié),如果感興趣,有許多可用的的參考資料,從實(shí)際的 JVM 規(guī)范開始。關(guān)鍵之處在于常量池包含對(duì)該類所用的其它類和方法的所有引用,還包含了該類及其方法的實(shí)際定義。常量池往往占到二進(jìn)制類大小的一半或更多,但平均下來可能要少一些。
常量池后面還有幾項(xiàng),它們引用了類本身、其超類以及接口的常量池項(xiàng)。這些項(xiàng)后面是有關(guān)字段和方法的信息,它們本身用復(fù)雜結(jié)構(gòu)表示。方法的可執(zhí)行代碼以包含在方法定義中的 代碼屬性的形式出現(xiàn)。用 JVM 的指令形式表示該代碼,一般稱為 字節(jié)碼,這是下一節(jié)要討論的主題之一。
在 Java 類格式中, 屬性被用于幾個(gè)已定義的用途,包括已提到的字節(jié)碼、字段的常量值、異常處理以及調(diào)試信息。但是屬性并非只可能用于這些用途。從一開始,JVM 規(guī)范就已經(jīng)要求 JVM 忽略未知類型的屬性。這一要求所帶來的靈活性使得將來可以擴(kuò)展屬性的用法以滿足其它用途,例如提供使用用戶類的框架所需的元信息,這種方法在 Java 派生的 C# 語言中已廣泛使用。遺憾的是,對(duì)于在用戶級(jí)利用這一靈活性還沒有提供任何掛鉤。
字節(jié)碼和堆棧
構(gòu)成類文件可執(zhí)行部分的字節(jié)碼實(shí)際上是針對(duì)特定類型的計(jì)算機(jī) ― JVM ― 的機(jī)器碼。它被稱為 虛擬機(jī),因?yàn)樗辉O(shè)計(jì)成用軟件來實(shí)現(xiàn),而不是用硬件來實(shí)現(xiàn)。每個(gè)用于運(yùn)行 Java 平臺(tái)應(yīng)用程序的 JVM 都是圍繞該機(jī)器的實(shí)現(xiàn)而被構(gòu)建的。
這個(gè)虛擬機(jī)實(shí)際上相當(dāng)簡(jiǎn)單。它使用堆棧體系結(jié)構(gòu),這意味著在使用指令操作數(shù)之前要先將它們裝入內(nèi)部堆棧。指令集包含所有的常規(guī)算術(shù)和邏輯運(yùn)算,以及條件轉(zhuǎn)移和無條件轉(zhuǎn)移、裝入/存儲(chǔ)、調(diào)用/返回、堆棧操作和幾種特殊類型的指令。有些指令包含立即操作數(shù)值,它們被直接編碼到指令中。其它指令直接引用常量池中的值。
盡管虛擬機(jī)很簡(jiǎn)單,但實(shí)現(xiàn)卻并非如此。早期的(第一代)JVM 基本上是虛擬機(jī)字節(jié)碼的解釋器。這些虛擬機(jī)實(shí)際上 的確相對(duì)簡(jiǎn)單,但存在嚴(yán)重的性能問題 ― 解釋代碼的時(shí)間總是會(huì)比執(zhí)行本機(jī)代碼的時(shí)間長(zhǎng)。為了減少這些性能問題,第二代 JVM 添加了 即時(shí)(just-in-time,JIT)轉(zhuǎn)換。在第一次執(zhí)行 Java 字節(jié)碼之前,JIT 技術(shù)將它編譯成本機(jī)代碼,從而對(duì)于重復(fù)執(zhí)行提供了更好的性能。當(dāng)代 JVM 的性能甚至還要好得多,因?yàn)槭褂昧诉m應(yīng)性技術(shù)來監(jiān)控程序的執(zhí)行并有選擇地優(yōu)化頻繁使用的代碼。
裝入類
諸如 C 和 C++ 這些編譯成本機(jī)代碼的語言通常在編譯完源代碼之后需要鏈接這個(gè)步驟。這一鏈接過程將來自獨(dú)立編譯好的各個(gè)源文件的代碼和共享庫(kù)代碼合并起來,從而形成了一個(gè)可執(zhí)行程序。Java 語言就不同。使用 Java 語言,由編譯器生成的類在被裝入到 JVM 之前通常保持原狀。即使從類文件構(gòu)建 JAR 文件也不會(huì)改變這一點(diǎn) ― JAR 只是類文件的容器。
鏈接類不是一個(gè)獨(dú)立步驟,它是在 JVM 將這些類裝入到內(nèi)存時(shí)所執(zhí)行作業(yè)的一部分。在最初裝入類時(shí)這一步會(huì)增加一些開銷,但也為 Java 應(yīng)用程序提供了高度靈活性。例如,在編寫應(yīng)用程序以使用接口時(shí),可以到運(yùn)行時(shí)才指定其實(shí)際實(shí)現(xiàn)。這個(gè)用于組裝應(yīng)用程序的 后聯(lián)編方法廣泛用于 Java 平臺(tái),servlet 就是一個(gè)常見示例。
JVM 規(guī)范中詳細(xì)描述了裝入類的規(guī)則。其基本原則是只在需要時(shí)才裝入類(或者至少看上去是這樣裝入 ― JVM 在實(shí)際裝入時(shí)有一些靈活性,但必須保持固定的類初始化順序)。每個(gè)裝入的類都可能擁有其它所依賴的類,所以裝入過程是遞歸的。清單 2 中的類顯示了這一遞歸裝入的工作方式。 Demo
類包含一個(gè)簡(jiǎn)單的 main
方法,它創(chuàng)建了 Greeter
的實(shí)例,并調(diào)用 greet
方法。 Greeter
構(gòu)造函數(shù)創(chuàng)建了 Message
的實(shí)例,隨后會(huì)在 greet
方法調(diào)用中使用它。
|
在 java
命令行上設(shè)置參數(shù) -verbose:class
會(huì)打印類裝入過程的跟蹤記錄。清單 3 顯示了使用這一參數(shù)運(yùn)行清單 2 程序的部分輸出:
|
這只列出了輸出中最重要的部分 ― 完整的跟蹤記錄由 294 行組成,我刪除了其中大部分,形成了這個(gè)清單。最初的一組類裝入(本例中是 279 個(gè))都是在嘗試裝入 Demo
類時(shí)觸發(fā)的。這些類是每個(gè) Java 程序(不管有多小)都要使用的核心類。即使刪除 Demo main
方法的所有代碼也不會(huì)影響這個(gè)初始的裝入順序。但是不同版本的類庫(kù)所涉及的類數(shù)量和名稱都不同。
在上面這個(gè)清單中,裝入 Demo
類之后的部分更有趣。這里的順序顯示了只有在準(zhǔn)備創(chuàng)建 Greeter
類的實(shí)例時(shí)才會(huì)裝入該類。不過, Greeter
類使用了 Message
類的靜態(tài)實(shí)例,所以在可以創(chuàng)建 Greeter
類的實(shí)例之前,還必須先裝入 Message
類。
在裝入并初始化類時(shí),JVM 內(nèi)部會(huì)完成許多操作,包括解碼二進(jìn)制類格式、檢查與其它類的兼容性、驗(yàn)證字節(jié)碼操作的順序以及最終構(gòu)造 java.lang.Class
實(shí)例來表示新類。這個(gè) Class
對(duì)象成了 JVM 創(chuàng)建新類的所有實(shí)例的基礎(chǔ)。它還是已裝入類本身的標(biāo)識(shí) ― 對(duì)于裝入到 JVM 的同一個(gè)二進(jìn)制類,可以有多個(gè)副本,每個(gè)副本都有其自己的 Class
實(shí)例。即使這些副本都共享同一個(gè)類名,但對(duì) JVM 而言它們都是獨(dú)立的類。
非常規(guī)(類)路徑
裝入到 JVM 的類是由 類裝入器控制的。JVM 中構(gòu)建了一個(gè) 引導(dǎo)程序類裝入器,它負(fù)責(zé)裝入基本的 Java 類庫(kù)類。這個(gè)特殊的類裝入器有一些專門的特性。首先,它只裝入在引導(dǎo)類路徑上找到的類。因?yàn)檫@些是可信的系統(tǒng)類,所以引導(dǎo)程序裝入器跳過了對(duì)常規(guī)(不可信)類所做的大量驗(yàn)證。
引導(dǎo)程序不是唯一的類裝入器。對(duì)于初學(xué)者而言,JVM 為裝入標(biāo)準(zhǔn) Java 擴(kuò)展 API 中的類定義了一個(gè) 擴(kuò)展類裝入器,并為裝入一般類路徑上的類(包括應(yīng)用程序類)定義了一個(gè) 系統(tǒng)類裝入器。應(yīng)用程序還可以定義它們自己的用于特殊用途(例如運(yùn)行時(shí)類的重新裝入)的類裝入器。這樣添加的類裝入器派生自 java.lang.ClassLoader
類(可能是間接派生的),該類對(duì)從字節(jié)數(shù)組構(gòu)建內(nèi)部類表示( java.lang.Class
實(shí)例)提供了核心支持。每個(gè)構(gòu)造好的類在某種意義上是由裝入它的類裝入器所“擁有”。類裝入器通常保留它們所裝入類的映射,從而當(dāng)再次請(qǐng)求某個(gè)類時(shí),能通過名稱找到該類。
每個(gè)類裝入器還保留對(duì)父類裝入器的引用,這樣就定義了類裝入器樹,樹根為引導(dǎo)程序裝入器。在需要某個(gè)特定類的實(shí)例(由名稱來標(biāo)識(shí))時(shí),無論哪個(gè)類裝入器最初處理該請(qǐng)求,在嘗試直接裝入該類之前,一般都會(huì)先檢查其父類裝入器。如果存在多層類裝入器,那么會(huì)遞歸執(zhí)行這一步,所以這意味著通常不僅在裝入該類的類裝入器中該類是 可見的,而且對(duì)于所有后代類裝入器也都是可見的。這還意味著如果一條鏈上有多個(gè)類裝入器可以裝入某個(gè)類,那么該樹最上端的那個(gè)類裝入器會(huì)是實(shí)際裝入該類的類裝入器。
在許多環(huán)境中,Java 程序會(huì)使用多個(gè)應(yīng)用程序類裝入器。J2EE 框架就是一個(gè)示例。該框架裝入的每個(gè) J2EE 應(yīng)用程序都需要擁有一個(gè)獨(dú)立的類裝入器以防止一個(gè)應(yīng)用程序中的類干擾其它應(yīng)用程序。該框架代碼本身也將使用一個(gè)或多個(gè)其它類裝入器,同樣用來防止對(duì)應(yīng)用程序產(chǎn)生的或來自應(yīng)用程序的干擾。整個(gè)類裝入器集合形成了樹狀結(jié)構(gòu)的層次結(jié)構(gòu),在其每個(gè)層次上都可裝入不同類型的類。
裝入器樹
作為類裝入器層次結(jié)構(gòu)的實(shí)際示例,圖 1 顯示了 Tomcat servlet 引擎定義的類裝入器層次結(jié)構(gòu)。這里 Common 類裝入器從 Tomcat 安裝的某個(gè)特定目錄的 JAR 文件進(jìn)行裝入,旨在用于在服務(wù)器和所有 Web 應(yīng)用程序之間共享代碼。Catalina 裝入器用于裝入 Tomcat 自己的類,而 Shared 裝入器用于裝入 Web 應(yīng)用程序之間共享的類。最后,每個(gè) Web 應(yīng)用程序有自己的裝入器用于其私有類。
在這種環(huán)境中,跟蹤合適的裝入器以用于請(qǐng)求新類會(huì)很混亂。為此,在 Java 2 平臺(tái)中將 setContextClassLoader
方法和 getContextClassLoader
方法添加到了 java.lang.Thread
類中。這些方法允許該框架設(shè)置類裝入器,使得在運(yùn)行每個(gè)應(yīng)用程序中的代碼時(shí)可以將類裝入器用于該應(yīng)用程序。
能裝入獨(dú)立的類集合這一靈活性是 Java 平臺(tái)的一個(gè)重要特性。盡管這個(gè)特性很有用,但是它在某些情況中會(huì)產(chǎn)生混淆。一個(gè)令人混淆的方面是處理 JVM 類路徑這樣的老問題。例如,在圖 1 顯示的 Tomcat 類裝入器層次結(jié)構(gòu)中,由 Common 類裝入器裝入的類決不能(根據(jù)名稱)直接訪問由 Web 應(yīng)用程序裝入的類。使這些類聯(lián)系在一起的唯一方法是通過使用這兩個(gè)類集都可見的接口。在這個(gè)例子中,就是包含由 Java servlet 實(shí)現(xiàn)的 javax.servlet.Servlet
。
無論何種原因在類裝入器之間移動(dòng)代碼時(shí)都會(huì)出現(xiàn)問題。例如,當(dāng) J2SE 1.4 將用于 XML 處理的 JAXP API 移到標(biāo)準(zhǔn)分發(fā)版中時(shí),在許多環(huán)境中都產(chǎn)生了問題,因?yàn)檫@些環(huán)境中的應(yīng)用程序以前是依賴于裝入它們自己選擇的 XML API 實(shí)現(xiàn)的。使用 J2SE 1.3,只要在用戶類路徑中包含合適的 JAR 文件就可以解決該問題。在 J2SE 1.4 中,這些 API 的標(biāo)準(zhǔn)版現(xiàn)在位于擴(kuò)展的類路徑中,所以它們通常將覆蓋用戶類路徑中出現(xiàn)的任何實(shí)現(xiàn)。
使用多個(gè)類裝入器還可能引起其它類型的混淆。圖 2 顯示了 類身份危機(jī)(class identity crisis)的示例,它是在兩個(gè)獨(dú)立類裝入器都裝入一個(gè)接口及其相關(guān)的實(shí)現(xiàn)時(shí)產(chǎn)生的危機(jī)。即使接口和類的名稱和二進(jìn)制實(shí)現(xiàn)都相同,但是來自一個(gè)裝入器的類的實(shí)例不能被認(rèn)為是實(shí)現(xiàn)了來自另一個(gè)裝入器的接口。圖 2 中通過將接口類 I
移至 System 類裝入器的空間就可以解除這種混淆。類 A
仍然有兩個(gè)獨(dú)立的實(shí)例,但它們都實(shí)現(xiàn)了同一個(gè)接口 I
。
結(jié)束語
Java 類定義和 JVM 規(guī)范一起為運(yùn)行時(shí)組裝代碼定義了功能極其強(qiáng)大的框架。通過使用類裝入器,Java 應(yīng)用程序能使用多個(gè)版本的類,否則這些類就會(huì)引起沖突。類裝入器的靈活性甚至允許動(dòng)態(tài)地重新裝入已修改的代碼,同時(shí)應(yīng)用程序繼續(xù)執(zhí)行。
這里,Java 平臺(tái)靈活性在某種程度上是以啟動(dòng)應(yīng)用程序時(shí)較高的開銷作為代價(jià)的。在 JVM 可以開始執(zhí)行甚至最簡(jiǎn)單的應(yīng)用程序代碼之前,它都必須裝入數(shù)百個(gè)獨(dú)立的類。相對(duì)于頻繁使用的小程序,這個(gè)啟動(dòng)成本通常使 Java 平臺(tái)更適合于長(zhǎng)時(shí)間運(yùn)行的服務(wù)器類型的應(yīng)用程序。服務(wù)器應(yīng)用程序還最大程度地受益于代碼在運(yùn)行時(shí)進(jìn)行組裝這種靈活性,所以對(duì)于這種開發(fā),Java 平臺(tái)正日益受寵也就不足為奇了。
在本系列文章的第 2 部分中,我將介紹使用 Java 平臺(tái)動(dòng)態(tài)基礎(chǔ)的另一個(gè)方面:反射 API(Reflection API)。反射使執(zhí)行代碼能夠訪問內(nèi)部類信息。這可能是構(gòu)建靈活代碼的極佳工具,可以不使用類之間任何源代碼鏈接就能夠在運(yùn)行時(shí)將代碼掛接在一起。但象使用大多數(shù)工具一樣,您必須知道何時(shí)及如何使用它以獲得最大利益。請(qǐng)閱讀 Java 編程的動(dòng)態(tài)性第 2 部分以了解有效反射的訣竅和利弊。
- 您可以參閱本文在 developerWorks 全球站點(diǎn)上的 英文原文.
- 直接到 The Java Virtual Machine Specification 的出處,以了解二進(jìn)制類格式、類的裝入以及實(shí)際的 Java 字節(jié)碼等細(xì)節(jié)。
- 閱讀 Greg Travis 編寫的教程“ 了解 Java ClassLoader”( developerWorks,2001 年 4 月)了解構(gòu)建您自己的特殊類裝入器的所有細(xì)節(jié)。
- Martyn Honeyford 廣受歡迎的“ 衡量 Java 本機(jī)編譯”一文( developerWorks,2002 年 1 月)提供了有關(guān) Java 語言本機(jī)代碼編譯問題及利弊的更多詳細(xì)信息。
- 二進(jìn)制類格式包含大量重要的信息,通常這些信息甚至足夠讓您重新構(gòu)造源代碼(注釋除外)。在 Greg Travis 的“ How to lock down your Java code (or open up someone else's)”一文( developerWorks,2001 年 5 月)中,他向您顯示了可以如何使用這些信息。
- 獲取有關(guān) Jikes Research Virtual Machine (RVM)的細(xì)節(jié),它是用 Java 語言實(shí)現(xiàn)的,并是自我托管的(即,它的 Java 代碼是依靠自身運(yùn)行的,不需要另一個(gè)虛擬機(jī))。
- 通過 Java 規(guī)范請(qǐng)求 175(Java Specification Request 175,JSR 175)的 A Metadata Facility for the Java Programming Language,緊跟使屬性可用于 Java 開發(fā)人員的發(fā)展。
- 了解 Apache Software Foundation 的 Apache TomcatJava 語言 Web 服務(wù)器項(xiàng)目的細(xì)節(jié),包括 Tomcat 類裝入器用法的細(xì)節(jié)。
- 在 developerWorksJava 技術(shù)專區(qū) 可以找到數(shù)百篇 Java 技術(shù)參考資料。
關(guān)于作者![]() |
