類加載是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中多個類加載器加載同一個目標類
|
<三>
為什么我們需要我們自己的類加載器
原因之一為開發者寫自己的類加載器來控制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)。把這個兩個存在細微不同的類放在一個classpath下,然后運行Test類:
set CLASSPATH=.;%CURRENT_ROOT%\v1;%CURRENT_ROOT%\v2
%JAVA_HOME%\bin\java
Test |
圖3顯示了控制臺輸出。我們可以看到對應著Version.fx(1)的代碼被執行了,因為類加載器在classpath首先看到此版本的代碼。

圖3.
在類路徑中samepath測試排在最前面的version
1
|
再次運行,類路徑做如下微小改動。
set CLASSPATH=.;%CURRENT_ROOT%\v2;%CURRENT_ROOT%\v1
%JAVA_HOME%\bin\java
Test |
控制臺的輸出變為圖4。對應著Version.fx(2)的代碼被加載,因為類加載器在classpath中首先找到它的路徑。

圖4.
在類路徑中samepath測試排在最前面的version
2
|
根據以上例子可以很明顯地看出,類加載器加載在類路徑中被首先找到的元素。如果我們在v1和v2中刪除了version.Version,做一個非version.Version形式的.jar文件,如myextension.jar,把它放到對應java.ext.dirs的路徑下,再次執行后看到version.Version不再被AppClassLoader加載,而是被擴展類加載器加載。如圖5所示。

圖5.
AppClassLoader及ExtClassLoader
|
繼續這個例子,文件夾differentversions包含了一個RMI執行引擎,客戶端可以提供給執行引擎任何實現了common.TaskIntf接口的任務。子文件夾client1
和 client2包含了類client.TaskImpl有個細微不同的兩個版本。兩個類的區別在以下幾行:
static
{
log("client.TaskImpl.class.getClassLoader (v1) : " +
TaskImpl.class.getClassLoader());
}
public void execute(){ log("this =
" + this + "; execute(1)");
} |
在client1和client2里分別有getClassLoader(v1) 與
execute(1)和getClassLoader(v2) 與
execute(2)的的log語句。并且,在開始執行引擎RMI服務器的代碼中,我們隨意地將client2的任務實現放在類路徑的前面。
CLASSPATH=%CURRENT_ROOT%\common;%CURRENT_ROOT%\server;
%CURRENT_ROOT%\client2;%CURRENT_ROOT%\client1
%JAVA_HOME%\bin\java
server.Server |
如圖6,7,8的屏幕截圖,在客戶端VM,各自的client.TaskImpl類被加載、實例化,并發送到服務端的VM來執行。從服務端的控制臺,可以明顯看到client.TaskImpl代碼只被服務端的VM執行一次,這個單一的代碼版本在服務端多次生成了許多實例,并執行任務。

圖6.
執行引擎服務器控制臺
|
圖6顯示了服務端的控制臺,加載并執行兩個不同的客戶端的請求,如圖7、8所示。需要注意的是,代碼只被加載了一次(從靜態初始化塊的日志中也可以明顯看出),但對于客戶端的調用這個方法被執行了兩次。

圖7.
執行引擎客戶端
1控制臺
|
圖7中,客戶端VM加載了含有client.TaskImpl.class.getClassLoader(v1)的日志內容的類TaskImpl的代碼,并提供給服務端的執行引擎。圖8的客戶端VM加載了另一個TaskImpl的代碼,并發送給服務端。

圖8.
執行引擎客戶端
2控制臺
|
在客戶端的VM中,類client.TaskImpl被分別加載,初始化,并發送到服務端執行。圖6還揭示了client.TaskImpl的代碼只在服務端的VM中加載了一次,但這“唯一的一次”卻在服務端創造了許多實例并執行。或許客戶端1該不高興了因為并不是它的client.TaskImpl(v1)的方法調用被服務端執行了,而是其他的一些代碼。如何解決這一問題?答案就是實現定制的類加載器。
<四>
定制類加載器
要較好地控制類的加載,就要實現定制的類加載器。所有自定義的類加載器都應繼承自java.lang.ClassLoader。而且在構造方法中,我們也應該設置父類加載器。然后重寫findClass()方法。differentversionspush文件夾包含了一個叫做FileSystemClassLoader的自訂制的類加載器。其結構如圖9所示。

圖9.
定制類加載器關系
|
以下是在common.FileSystemClassLoader實現的主方法:
public byte[] findClassBytes(String
className)
{
try
{
String pathName = currentRoot +
File.separatorChar + className. replace(’.’, File.separatorChar) +
".class";
FileInputStream inFile = new FileInputStream(pathName);
byte[] classBytes = new
byte[inFile.available()];
inFile.read(classBytes);
return classBytes;
}
catch (java.io.IOException ioEx)
{
return null;
}
}
public Class findClass(String name)throws
ClassNotFoundException
{
byte[] classBytes = findClassBytes(name);
if (classBytes==null)
{
throw new ClassNotFoundException();
}
else{
return defineClass(name, classBytes, 0,
classBytes.length);
}
}
public Class findClass(String name, byte[]
classBytes)throws ClassNotFoundException
{
if
(classBytes==null)
{
throw new ClassNotFoundException(
"(classBytes==null)");
} else{
return defineClass(name, classBytes, 0,
classBytes.length);
}
}
public void execute(String codeName,
byte[] code)
{
Class klass = null;
try
{
klass =
findClass(codeName, code);
TaskIntf task = (TaskIntf)
klass.newInstance();
task.execute();
} catch(Exception
exception){
exception.printStackTrace();
}
} |
這個類供客戶端把client.TaskImpl(v1)轉換成字節數組,之后此字節數組被發送到RMI服務端。在服務端,一個同樣的類用來把字節數組的內容轉換回代碼。客戶端代碼如下:
public class Client
{
public static void main (String[]
args)
{
try{ byte[] code = getClassDefinition ("client.TaskImpl");
serverIntf.execute("client.TaskImpl",
code);
}
catch(RemoteException
remoteException)
{
remoteException.printStackTrace();
}
}
private
static byte[] getClassDefinition (String codeName)
{
String userDir =
System.getProperties(). getProperty("BytePath");
FileSystemClassLoader fscl1
= null;
try
{
fscl1 = new FileSystemClassLoader
(userDir);
}
catch(FileNotFoundException
fileNotFoundException)
{
fileNotFoundException.printStackTrace();
}
return
fscl1.findClassBytes(codeName);
}
} |
在執行引擎中,從客戶端收到的代碼被送到定制的類加載器中。定制的類加載器把其從字節數組定義成類,實例化并執行。需要指出的是,對每一個客戶請求,我們用類FileSystemClassLoader的不同實例來定義客戶端提交的client.TaskImpl。而且,client.TaskImpl并不在服務端的類路徑中。這也就意味著當我們在FileSystemClassLoader調用findClass()方法時,findClass()調用內在的defineClass()方法。類client.TaskImpl被特定的類加載器實例所定義。因此,當FileSystemClassLoader的一個新的實例被使用,類又被重新定義為字節數組。因此,對每個客戶端請求類client.TaskImpl被多次定義,我們就可以在相同執行引擎JVM中執行不同的client.TaskImpl的代碼。
public void execute(String codeName, byte[] code)throws
RemoteException
{
FileSystemClassLoader fileSystemClassLoader =
null;
try
{
fileSystemClassLoader = new
FileSystemClassLoader();
fileSystemClassLoader.execute(codeName,
code);
}
catch(Exception exception)
{
throw new
RemoteException(exception.getMessage());
}
} |
示例在differentversionspush文件夾下。服務端和客戶端的控制臺界面分別如圖10,11,12所示:

圖10.
定制類加載器執行引擎
|
圖10顯示的是定制的類加載器控制臺。我們可以看到client.TaskImpl的代碼被多次加載。實際上針對每一個客戶端,類都被加載并初始化。

圖11.
定制類加載器,客戶端1
|
圖11中,含有client.TaskImpl.class.getClassLoader(v1)的日志記錄的類TaskImpl的代碼被客戶端的VM加載,然后送到服務端。圖12
另一個客戶端把包含有client.TaskImpl.class.getClassLoader(v1)的類代碼加載并送往服務端。

圖12.
定制類加載器,客戶端1
|
這段代碼演示了我們如何利用不同的類加載器實例來在同一個VM上執行不同版本的代碼。
J2EE的類加載器
J2EE的服務器傾向于以一定間隔頻率,丟棄原有的類并重新載入新的類。在某些情況下會這樣執行,而有些情況則不。同樣,對于一個web服務器如果要丟棄一個servlet實例,可能是服務器管理員的手動操作,也可能是此實例長時間未相應。當一個JSP頁面被首次請求,容器會把此JSP頁面翻譯成一個具有特定形式的servlet代碼。一旦servlet代碼被創建,容器就會把這個servlet翻譯成class文件等待被使用。對于提交給容器的每次請求,容器都會首先檢查這個JSP文件是否剛被修改過。是的話就重新翻譯此文件,這可以確保每次的請求都是及時更新的。企業級的部署方案以.ear,
.war,
.rar等形式的文件,同樣需要重復加載,可能是隨意的也可能是依照某種配置方案定期執行。對所有的這些情況——類的加載、卸載、重新加載……全部都是建立在我們控制應用服務器的類加載機制的基礎上的。實現這些需要擴展的類加載器,它可以執行由其自身所定義的類。Brett
Peterson已經在他的文章 Understanding J2EE Application Server Class Loading
Architectures給出了J2EE應用服務器的類加載方案的詳細說明,詳見網站TheServerSide.com。
結束語
本文探討了類載入到虛擬機是如何進行唯一標識的,以及類如果存在同樣的類名和包名時所產生的問題。因為沒有一個直接可用的類版本管理機制,所以如果我們要按自己的意愿來加載類時,需要自己訂制類加載器來擴展其行為。我們可以利用許多J2EE服務器所提供的“熱部署”功能來重新加載一個新版本的類,而不改動服務器的VM。即使不涉及應用服務器,我們也可以利用定制類加載器來控制java應用程序載入類時的具體行為。Ted
Neward的書Server-Based Java Programming中詳細闡述java的類加載,J2EE的API以及使用他們的最佳途徑。