類裝入問題解密,第 3 部分: 處理更少見的類裝入問題
理解類裝入並解決微妙的異常
術中心Team Dev, IBM Hursley 實驗室
2006 年 1 月 16 日
這個四部分構成的文章系列研究 Java 的類裝入問題,輔助應用程式開發人員理解和調試可能遇到的問題。在第 3 部分中,來自 IBM Hursley 實驗室的作者 Lakshmi Shankar 和 Simon Burns 在本系列前兩部分的基礎之上,詳細介紹了不同種類的類裝入問題,包括與類路徑、類可視性和垃圾收集有關的問題。
本文是本系列中四篇文章的第三篇,它研究了在 Java 開發過程中的一些更複雜、更少見的類裝入問題。造成這些問題的原因通常無法直接從癥狀得出;所以,解決起來既困難又費時。與本系列以前的文章一樣,我們仍然提供樣本來示範問題,然後討論各種解決技術。
在開始這篇文章之前,應當熟悉類裝入委託模型,以及類連結的階段和過程。我們強烈建議您從閱讀本系列的 第一篇文章 開始。
與類路徑有關的問題
有一個非常簡單的問題通常與使用者佈建的類路徑有關。清單 1 和清單 2 中的樣本示範了這個問題。
測試案例建立了兩個類裝入器,每個類裝入器使用的類路徑看起來相同。但是,有一個微小但是卻重大的區別:一個類路徑末尾有 /,而另一個沒有。在這兩個類路徑中的一個名為 cp 的子目錄中有一個類 Z(在清單 2 中)。兩個類裝入器都試圖裝入 Z:
清單 1. ClasspathTest.java
import java.net.URL;import java.net.URLClassLoader;public class ClasspathTest { String userDir; URL withSlash; URL withoutSlash; ClasspathTest() { try { userDir = System.getProperty("user.dir"); withSlash = new URL("file://C:/CL_Article/ClasspathIssues/cp/"); withoutSlash = new URL("file://C:/CL_Article/ClasspathIssues/cp"); } catch (Exception e) { e.printStackTrace(); } } void run() { try { System.out.println(withSlash); URLClassLoader cl1 = new URLClassLoader(new URL[] { withSlash }); Class c1 = cl1.loadClass("Z"); System.out.println("Class Z loaded."); System.out.println(withoutSlash); URLClassLoader cl2 = new URLClassLoader(new URL[] { withoutSlash }); Class c2 = cl2.loadClass("Z"); System.out.println("Class Z loaded."); } catch (Exception e) { e.printStackTrace(); } } public static void main(String[] args) { new ClasspathTest().run(); }}
|
清單 2. Z.java
這個測試案例產生以下輸出:
file://C:/CL_Article/ClasspathIssues/cp/Class Z loaded.file://C:/CL_Article/ClasspathIssues/cpjava.lang.ClassNotFoundException: Z at java.net.URLClassLoader.findClass(URLClassLoader.java:376) at java.lang.ClassLoader.loadClass(ClassLoader.java:572) at java.lang.ClassLoader.loadClass(ClassLoader.java:504) at ClasspathTest.run(ClasspathTest.java:28) at ClasspathTest.main(ClasspathTest.java:36)
|
可以看到,傳遞給每個 URLClassloader 的參數略有不同。提供給第一個類裝入器 cl1 的類路徑末尾有 /。提供給第二個類裝入器 cl2 的類路徑末尾沒有 /。這個區別是顯著的,因為類裝入器假設不以 / 結尾的路徑指向的是 JAR 檔案。只有以 / 結尾的路徑才被假定為指向目錄。
因為 cl1 的類路徑被當作目錄,所以這個類裝入器能夠找到在這個位置的類 Z,並能夠裝入它。cl2 的類路徑被假定為 JAR 檔案;這個類裝入器不能發現類 Z ,因為沒有這個檔案。所以,cl2.loadClass() 拋出 ClassNotFoundException。
顯然,修複這個問題的方法是確保指向目錄的路徑以 / 結尾。
與類的可視性有關的問題
在系統中,可能有許多類裝入器看不到的類。這是因為類裝入器只能看到它自己裝入的類,或者它有引用(直接或間接)的其他類裝入器裝入的類。在標準的類裝入委託模型中,類裝入器能看到的類被限制在它自己裝入的那些類上,或者它的雙親和祖先類裝入器裝入的類 —— 換句話說,類裝入器不能向下看。
圖 1 示範了這類問題的樣本:
圖 1. 可視性樣本
類 A 在系統類別裝入器的類路徑中,而 A 的超類 B,在使用者自訂的類裝入器的類路徑中,這個類裝入器是系統類別裝入器的孩子。當系統類別裝入器試圖裝入類 A 時,裝入失敗,因為它看不到類 B。這是因為 B 不在系統類別裝入器或者它的雙親或祖先類裝入器的類路徑中。
清單 3 到 5 的測試案例實現了這個情境:
清單 3. VisibilityTest.java
import java.net.*;public class VisibilityTest { public static void main(String[] args) { try { URLClassLoader mycl = new URLClassLoader(new URL[] { new URL( "file://C:/CL_Article/VisibilityTest/cp/") }); Class c2 = mycl.loadClass("A"); } catch (Exception e) { e.printStackTrace(); } }}
|
清單 4. A.java
public class A extends B { public static void method1() { System.out.println("HELLO!"); }}
|
清單 5. B.java
這個測試案例產生以下輸出:
Exception in thread "main" java.lang.NoClassDefFoundError: B at java.lang.ClassLoader.defineClass0(Native Method) at java.lang.ClassLoader.defineClass(ClassLoader.java:810) at java.security.SecureClassLoader.defineClass(SecureClassLoader.java:147) at java.net.URLClassLoader.defineClass(URLClassLoader.java:475) at java.net.URLClassLoader.access$500(URLClassLoader.java:109) at java.net.URLClassLoader$ClassFinder.run(URLClassLoader.java:848) at java.security.AccessController.doPrivileged1(Native Method) at java.security.AccessController.doPrivileged(AccessController.java:389) at java.net.URLClassLoader.findClass(URLClassLoader.java:371) at java.lang.ClassLoader.loadClass(ClassLoader.java:572) at sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:442) at java.lang.ClassLoader.loadClass(ClassLoader.java:563) at java.lang.ClassLoader.loadClass(ClassLoader.java:504) at VisibilityTest.main(VisibilityTest.java:9) at VisibilityTest.main(VisibilityTest.java:10)
|
解決類可視性問題的惟一方法是確保所有的應用程式類都可見。為了確保類的可視性而如何確切安放類,取決於使用的不同的類裝入模型。但是,如果正在使用標準的類裝入委託模型,那麼類的可視性就是一個簡單的問題:所要做的全部工作就是確保沒有引用指向更低的類空間。例如,在系統類別裝入器類空間中的類,不應當指向孩子或子孫類裝入器的類空間中的類。
重載 loadClass() 時的問題
如果類裝入器只使用標準委託模型,那麼就不需要重載 loadClass() 方法。但是,如果需要不同的模型,那麼就必須重載 loadClass(),在這種情況下,必須重視一些特殊的考慮因素。
委託
清單 6 是 loadClass() 的簡單實現:
清單 6. 簡單的 loadClass() 實現
public Class loadClass(String name) throws ClassNotFoundException { return findClass(name);}
|
雖然這看起來合理,但是對這個方法的調用會導致以下異常:
Exception in thread "main" java.lang.NoClassDefFoundError: java/lang/Object
|
這個異常的拋出,是因為重載的 loadClass() 方法不再委託給它的雙親。這個實現假設所有需要的類都在這個類裝入器的類路徑中。這個實現從基本上來說是有缺陷的,因為所有的類(隱式地)都擴充了 java.lang.Object,而後者必須是引導類裝入器所裝入的版本。
可以通過修改 loadClass() 的實現來修複這個問題,如清單 7 所示:
清單 7. 改進的 loadClass() 實現
public Class loadClass(String name) throws ClassNotFoundException { Class c = null; try { c = getParent().loadClass(name); } catch (ClassNotFoundException e) { } if(c == null) c = findClass(name); return c;}
|
方法現在在試圖自己找到類之前,先委託給自己的雙親類裝入器。這意味著它現在找到了通過引導類裝入器裝入的 java.lang.Object。
緩衝
雖然清單 7 提供的 loadClass() 委託實現解決了這個問題,但是實現仍然是不完整的。在使用這個版本的 loadClass() 時,還會出現另一個問題。下面就是該異常在 IBM JVM 中看起來的樣子:
Exception in thread "main" java.lang.LinkageError: JVMCL048:redefine of class A (&name=44CA3B08). old_cb=ACEE80, new_cb=ACED50, (&old_name=44CA3B08) old_name=A
|
下面是在 Sun JVM 中的樣子:
Exception in thread "main" java.lang.LinkageError: duplicate class definition: A
|
這個異常發生的原因是,應用程式要求類裝入器裝入同一個類兩次,而 loadClass() 則試圖從頭開始重新裝入類。這造成了兩個版本之間的衝突。這個問題可以在 loadClass() 中處理,先檢查類裝入器的緩衝。如果在緩衝中發現了類,那麼就返回這個版本。這個邏輯被添加到了清單 8 中的 loadClass() 方法版本中:
清單 8. loadClass(),進一步細化
public Class loadClass(String name) throws ClassNotFoundException { Class c = findLoadedClass(name); if(c == null) { try { c = getParent().loadClass(name); } catch (ClassNotFoundException e) { } if(c == null) c = findClass(name); } return c;}
|
這個方法現在工作得很好;但是,它現在遵循的是標準類裝入委託(緩衝、雙親、磁碟)。當然,如果要求標準委託模型,那麼不需要首先重載 loadClass()。可以編寫不符合標準委託模型的有用的 loadClass() 方法,後果是可能會出現潛在的問題,但是這超出了本系列的範圍。
與垃圾收集和序列化有關的問題
垃圾收集器與類裝入器的互動很密切。在眾多的事情當中,收集器檢查類裝入器的資料結構,來判斷哪個類是活動的 —— 也就是說,不應當被當作垃圾收集的。這通常會帶來一些意料之外的問題。
圖 2 示範了一個情境,在這個情境中,序列化以一種意料之外的方式影響了類的垃圾收集(GC):
圖 2. 序列化樣本
在這個樣本中,SerializationTest 執行個體化了一個 URLClassLoader,叫做 loader。在裝入 SerializationClass 之後,對類裝入器的引用被取消。想法是希望這樣可以允許類裝入器裝入的類被垃圾收集掉。這些類的代碼如清單 9 和 10 所示:
清單 9. SerializationTest.java
import java.net.MalformedURLException;import java.net.URL;import java.net.URLClassLoader;public class SerializationTest extends ClassLoader { public static void main(String args[]) { try { URLClassLoader loader = new URLClassLoader(new URL[] { new URL( "file://C:/CL_Article/Serialization/dir1/") }); System.out.println("Loading SerializationClass"); Class c = loader.loadClass("SerializationClass"); System.out.println("Creating an instance of SerializationClass"); c.newInstance(); System.out.println("Dereferencing the class loader"); c = null; loader = null; System.out.println("Running GC..."); System.gc(); System.out.println("Triggering a Javadump"); com.ibm.jvm.Dump.JavaDump(); } catch (MalformedURLException e) { e.printStackTrace(); } catch (InstantiationException e) { e.printStackTrace(); } catch (IllegalAccessException e) { e.printStackTrace(); } catch (ClassNotFoundException e) { e.printStackTrace(); } }}
|
清單 10. SerializationClass.java
import java.io.File;import java.io.FileOutputStream;import java.io.ObjectOutputStream;import java.io.Serializable;public class SerializationClass implements Serializable { private static final long serialVersionUID = 5024741671582526226L; public SerializationClass() { try { File file = new File("C:/CL_Article/Serialization/test.txt"); FileOutputStream fos = new FileOutputStream(file); ObjectOutputStream oos = new ObjectOutputStream(fos); oos.writeObject(this); oos.reset(); oos.close(); fos.close(); oos = null; fos = null; file = null; } catch (Exception e) { e.printStackTrace(); } }}
|
使用 Javadump,可以發現類裝入器是否被垃圾收集了。(關於使用 Javadump 的更多資訊,請參閱本系列的第一篇文章。)如果在類裝入器的列表中出現以下部分,就說明它沒有被收集:
------a- Loader java/net/URLClassLoader(0x44DC6DE0), Shadow 0x00ADB6D8, Parent sun/misc/Launcher$AppClassLoader(0x00ADB7B0) Number of loaded classes 1 Number of cached classes 11 Allocation used for loaded classes 1 Package owner 0x00ADB6D8
|
雖然取消對使用者定義的類裝入器的引用看起來像是一種確保類被垃圾收集的方法,但實際並不是這回事。在前面的樣本中,由於 java.io.ObjectOutputStream.writeObject(Object obj) 的使用以及它對 GC 的影響,所以產生了問題。
在調用 writeObject() 時(用來序列化 SerializationClass),對這個類對象的引用就在內部被傳遞給 ObjectStreamClass 並儲存在一個查詢表中(也就是內部緩衝)。儲存這個引用是為了加快日後對同一個類的序列化。
當取消對類裝入器的引用時,它裝入的類就變成無法進行垃圾收集的了。這是因為在 ObjectStreamClass 查詢表中,沒有了對 SerializationClass 類的活動引用。ObjectStreamClass 是一個原始類,所以永遠不會被垃圾收集。查詢表是從 ObjectStreamClass 中的靜態欄位引用的,而且儲存在類本身之中,而不是儲存在執行個體中。所以,對 SerializationClass 的引用存在於 JVM 的生命週期中,所以類就不能被垃圾收集。重要的是,SerializationClass 類有一個到其定義類裝入器的引用,所以它也不可能完整地取值 (Dereference)。
為了避免這個問題,凡是要進行序列化的類,都應當由不需要被垃圾收集的類裝入器裝入 —— 例如由系統類別裝入器裝入。
下期預報
在這篇文章中,學習了一些在類裝入中可能發生的更複雜的問題。在本系列的最後一篇文章中,將研究兩個可能發生的最複雜的問題:死結和違反約束。
參考資料
學習
- 您可以參閱本文在 developerWorks 全球網站上的 英文原文。
- Demystifying class loading problems:閱讀完整系列。
- “瞭解 Java ClassLoader”(Greg Travis developerWorks,2001 年 4 月):類裝入介紹。
- “Java 編程的動態性,第 1 部分: 類和類裝入”(Dennis Sosnoski developerWorks,2003 年 4 月):理解各種類裝入問題,範圍從運行簡單 Java 應用程式所需要的大量的類一直到可能在 J2EE 和類似的複雜架構中造成問題的類裝入器衝突。
- JVM specification:從起源開始全面介紹了 JVM 類檔案的格式和指令集。
- IBM Diagnostics Guides:學習關於調試的更多知識。
- Persistent Reusable JVM:線上圖書,介紹 Persistent Reusable JVM 的概念,並提供編寫在它們上面啟動並執行中介軟體和應用程式的技術指南。
- Java 技術專區:數百份 Java 編程各方面的文章。
獲得產品和技術
- IBM Java developer kits:在 IBM 的一些最流行的平台上建立和運行 J2SE 應用程式。
討論
- 加入本文的論壇 。(您也可以通過點擊文章頂部或者底部的論壇連結參加討論。)
- developerWorks blogs:加入 developerWorks 社區。
作者簡介
|
|
Lakshmi Shankar 是英國 IBM Hursley 實驗室的軟體工程師。他為 IBM 工作超過兩年了,有廣泛的經驗,一直在 Hursley 實驗室從事 Java 效能、測試和開發工作。他目前是 IBM Java 技術的類裝入組件的所有人。他現在是資訊管理團隊的一名開發人員。 |
|
|
Simon Burns 是 Shiraz(可重設 JVM 和 IBM Java 共用類)組件的所有人,也是 IBM Hursley 實驗室的 Java 技術團隊負責人。他在 JVM 開發上工作了三年,專攻 Shiraz 組件和 z/OS 平台。他和 CICS 緊密合作,協助他們利用這項技術。Simon 開發的 OSGi 架構是開放源碼的 Eclipse Equinox 項目的一部分,已經整合到 Eclipse 3.1 中。他現在進行中組件化的工作。 |