類載入是java語言提供的最強大的機制之一。儘管類載入並不是討論的熱點話題,但所有的編程人員都應該瞭解其工作機制,明白如何做才能讓其滿足我們的需要。這能有效節省我們的編碼時間,從不斷調試ClassNotFoundException, ClassCastException的工作中解脫出來。
這篇文章從基礎講起,比如代碼與資料的不同之處是什麼,他們是如何構成一個執行個體或對象的。然後深入探討java虛擬機器(JVM)是如何利用類載入器讀取代碼,以及java中類載入器的主要類型。接著用一個類載入的基本演算法看一下類載入器如何載入一個內部類。本文的下一節示範一段代碼來說明擴充和開發屬於自己的類載入器的必要性。緊接著解釋如何使用定製的類載入器來完成一個一般意義上的任務,使其可以載入任意遠端客戶的代碼,在JVM中定義,執行個體化並執行它。本文包括了J2EE關於類載入的規範——事實上這已經成為了J2EE的標準之一。
類與資料
一個類代表要執行的代碼,而資料則表示其相關狀態。狀態時常改變,而代碼則不會。當我們將一個特定的狀態與一個類相對應起來,也就意味著將一個類案例化。儘管相同的類對應的執行個體其狀態千差萬別,但其本質都對應著同一段代碼。在JAVA中,一個類通常有著一個.class檔案,但也有例外。在JAVA的運行時環境中(Java runtime),每一個類都有一個以第一類(first-class)的Java對象所表現出現的代碼,其是java.lang.Class的執行個體。我們編譯一個JAVA檔案,編譯器都會嵌入一個public, static, final修飾的類型為java.lang.Class,名稱為class的域變數在其位元組碼檔案中。因為使用了public修飾,我們可以採用如下的形式對其訪問:
java.lang.Class klass = Myclass.class;
一旦一個類被載入JVM中,同一個類就不會被再次載入了(切記,同一個類)。這裡存在一個問題就是什麼是“同一個類”?正如一個對象有一個具體的狀態,即標識,一個對象始終和其代碼(類)相關聯。同理,載入JVM的類也有一個具體的標識,我們接下來看。
在JAVA中,一個類用其完全符合類名(fully qualified class name)作為標識,這裡指的完全符合類名包括包名和類名。但在JVM中一個類用其全名和一個載入類ClassLoader的執行個體作為唯一標識。因此,如果一個名為Pg的包中,有一個名為Cl的類,被類載入器KlassLoader的一個執行個體kl1載入,Cl的執行個體,即C1.class在JVM中表示為(Cl, Pg, kl1)。這意味著兩個類載入器的執行個體(Cl, Pg, kl1) 和 (Cl, Pg, kl2)是不同的,被它們所載入的類也因此完全不同,互不相容的。那麼在JVM中到底有多少種類載入器的執行個體?下一節我們揭示答案。
類載入器
在JVM中,每一個類都被java.lang.ClassLoader的一些執行個體來載入.類ClassLoader是在包中java.lang裡,開發人員可以自由地繼承它並添加自己的功能來載入類。
無論何時我們鍵入java MyMainClass來開始運行一個新的JVM,“引導類載入器(bootstrap class loader)”負責將一些關鍵的Java類,如java.lang.Object和其他一些運行時代碼先載入進記憶體中。運行時的類在JRE\lib\rt.jar包檔案中。因為這屬於系統底層執行動作,我們無法在JAVA文檔中找到引導類載入器的工作細節。基於同樣的原因,引導類載入器的行為在各JVM之間也是大相徑庭。
同理,如果我們按照如下方式:
log(java.lang.String.class.getClassLoader());
來擷取java的核心運行時類的載入器,就會得到null。
接下來介紹java的擴充類載入器。擴充庫提供比java運行代碼更多的特性,我們可以把擴充庫儲存在由java.ext.dirs屬性提供的路徑中。
(編輯注:java.ext.dirs屬性指的是系統屬性下的一個key,所有的系統屬性可以通過System.getProperties()方法獲得。在編者的系統中,java.ext.dirs的value是” C:\Program Files\Java\jdk1.5.0_04\jre\lib\ext”。下面將要談到的如java.class.path也同屬系統屬性的一個key。)
類ExtClassLoader專門用來載入所有java.ext.dirs下的.jar檔案。開發人員可以通過把自己的.jar檔案或庫檔案加入到擴充目錄的classpath,使其可以被擴充類載入器讀取。
從開發人員的角度,第三種同樣也是最重要的一種類載入器是AppClassLoader。這種類載入器用來讀取所有的對應在java.class.path系統屬性的路徑下的類。
Sun的java指南中,文章“理解擴充類載入”(Understanding Extension Class Loading)對以上三個類載入器路徑有更詳盡的解釋,這是其他幾個JDK中的類載入器
●java.net.URLClassLoader
●java.security.SecureClassLoader
●java.rmi.server.RMIClassLoader
●sun.applet.AppletClassLoader
java.lang.Thread,包含了public ClassLoader getContextClassLoader()方法,這一方法返回針對一具體線程的上下文環境類載入器。此類載入器由線程的建立者提供,以供此線程中啟動並執行代碼在需要載入類或資源時使用。如果此載入器未被建立,預設是其父線程的上下文類載入器。原始的類載入器一般由讀取應用程式的類載入器建立。
類載入器如何工作?
除了引導類載入器,所有的類載入器都有一個父類載入器,不僅如此,所有的類載入器也都是java.lang.ClassLoader類型。以上兩種類載入器是不同的,而且對於開發人員自訂製的類載入器的正常運行也至關重要。最重要的方面是正確設定父類載入器。任何類載入器,其父類載入器是載入該類載入器的類載入器執行個體。(記住,類載入器本身也是一個類!)
使用loadClass()方法可以從類載入器中獲得該類。我們可以通過java.lang.ClassLoader的原始碼來瞭解該方法工作的細節,如下:
protected synchronized Class<?> loadClass
(String name, boolean resolve)
throws ClassNotFoundException{
// First check if the class is already loaded
Class c = findLoadedClass(name);
if (c == null) {
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClass0(name);
}
} catch (ClassNotFoundException e) {
// If still not found, then invoke
// findClass to find the class.
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
我們可以使用ClassLoader的兩種構造方法來設定父類載入器:
public class MyClassLoader extends ClassLoader{
public MyClassLoader(){
super(MyClassLoader.class.getClassLoader());
}
}
或
public class MyClassLoader extends ClassLoader{
public MyClassLoader(){
super(getClass().getClassLoader());
}
}
第一種方式較為常用,因為通常不建議在構造方法裡調用getClass()方法,因為對象的初始化只是在構造方法的出口處才完全完成。因此,如果父類載入器被正確建立,當要示從一個類載入器的執行個體獲得一個類時,如果它不能找到這個類,它應該首先去訪問其父類。如果父類不能找到它(即其父類也不能找不這個類,等等),而且如果findBootstrapClass0()方法也失敗了,則調用findClass()方法。findClass()方法的預設實現會拋出ClassNotFoundException,當它們繼承java.lang.ClassLoader來訂製類載入器時開發人員需要實現這個方法。findClass()的預設實現方式如下:
protected Class<?> findClass(String name)
throws ClassNotFoundException {
throw new ClassNotFoundException(name);
}
在findClass()方法內部,類載入器需要擷取任意來源的位元組碼。來源可以是檔案系統,URL,資料庫,可以產生位元組碼的另一個應用程式,及其他類似的可以產生java規範的位元組碼的來源。你甚至可以使用BCEL (Byte Code Engineering Library:位元組碼工程庫),它提供了運行時建立類的捷徑。BCEL已經被成功地使用在以下方面:編譯器,最佳化器,混淆器,代碼產生器及其他分析工具。一旦位元組碼被檢索,此方法就會調用defineClass()方法,此行為對不同的類載入執行個體是有差異的。因此,如果兩個類載入執行個體從同一個來源定義一個類,所定義的結果是不同的。
JAVA語言規範(Java language specification)詳細解釋了JAVA執行引擎中的類或介面的載入(loading),連結(linking)或初始化(initialization)過程。
圖一顯示了一個主類稱為MyMainClass的應用程式。依照之前的闡述,MyMainClass.class會被AppClassLoader載入。 MyMainClass建立了兩個類載入器的執行個體:CustomClassLoader1 和 CustomClassLoader2,他們可以從某資料來源(比如網路)擷取名為Target的位元組碼。這表示類Target的類定義不在應用程式類路徑或擴充類路徑。在這種情況下,如果MyMainClass想要用自訂的類載入器載入Target類,CustomClassLoader1和CustomClassLoader2會分別獨立地載入並定義Target.class類。這在java中有重要的意義。如果Target類有一些靜態初始化代碼,並且假設我們只希望這些代碼在JVM中只執行一次,而這些代碼在我們目前的步驟中會執行兩次——分別被不同的CustomClassLoaders載入並執行。如果類Target被兩個CustomClassLoaders載入並建立兩個執行個體Target1和Target2,一顯示,它們不是類型相容的。換句話說,在JVM中無法執行以下代碼:
Target target3 = (Target) target2;
以上代碼會拋出一個ClassCastException。這是因為JVM把他們視為分別不同的類,因為他們被不同的類載入器所定義。這種情況當我們不是使用兩個不同的類載入器CustomClassLoader1 和 CustomClassLoader2,而是使用同一個類載入器CustomClassLoader的不同執行個體時,也會出現同樣的錯誤。這些會在本文後邊用具體代碼說明。
圖1. 在同一個JVM中多個類載入器載入同一個目標類
關於類載入、定義和連結的更多解釋,請參考Andreas Schaefer的"Inside Class Loaders."
為什麼我們需要我們自己的類載入器
原因之一為開發人員寫自己的類載入器來控制JVM中的類載入行為,java中的類靠其包名和類名來標識,對於實現了java.io.Serializable介面的類,serialVersionUID扮演了一個標識類版本的重要角色。這個唯一標識是一個類名、介面名、成員方法及屬性等組成的一個64位的雜湊欄位,而且也沒有其他快捷的方式來標識一個類的版本。嚴格說來,如果以上的都匹配,那麼則屬於同一個類。
但是讓我們思考如下情況:我們需要開發一個通用的執行引擎。可以執行實現某一特定介面的任何任務。當任務被提交到這個引擎,首先需要載入這個任務的代碼。假設不同的客戶對此引擎提交了不同的任務,湊巧,這些所有的任務都有一個相同的類名和包名。現在面臨的問題就是這個引擎是否可以針對不同的使用者所提交的資訊而做出不同的反應。這一情況在下文的參考一節有可供下載的代碼範例,samepath 和 differentversions,這兩個目錄分別示範了這一概念。
圖2 顯示了檔案目錄結構,有三個子目錄samepath, differentversions, 和 differentversionspush,裡邊是例子:
圖2. 檔案夾結構組織樣本
在samepath 中,類version.Version儲存在v1和v2兩個子目錄裡,兩個類具有同樣的類名和包名,唯一不同的是下邊這行:
public void fx(){
log("this = " + this + "; Version.fx(1).");
}
V1中,日誌記錄中有Version.fx(1),而在v2中則是Version.fx(2)。把這個兩個存在