这个四部分构成的文章系列研究 Java™ 的类装入问题,帮助应用程序开发人员理解和调试可能遇到的问题。
清单 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 类有一个到其定义类装入器的引用,所以它也不可能完整地取消引用。
为了避免这个问题,凡是要进行序列化的类,都应当由不需要被垃圾收集的类装入器装入 —— 例如由系统类装入器装入。