这个四部分构成的文章系列研究 Java™ 的类装入问题,帮助应用程序开发人员理解和调试可能遇到的问题。在最后这一期中,来自 IBM Hursley 实验室的作者 Lakshmi Shankar 和 Simon Burns 在本系列前三部分的基础之上,研究这个领域中可能遇到的两个最有趣和最复杂的问题:死锁和约束。
本文是本系列中的四篇文章的最后一篇,它研究了类装入器死锁和约束违反。这两类问题不仅难于理解,更难解决。与本系列中以前的文章中一样,我们还是提供示例来演示问题,然后讨论各种解决技术。
在开始这篇文章之前,应当熟悉类装入委托模型,以及类链接的阶段和过程。我们强烈建议您从阅读本系列的 第一篇文章 开始。
类装入器死锁
当两个线程在两个不同的类装入器上都拥有各自的锁,同时又都等候对方拥有的锁的时候,就会发生类装入器死锁。两个线程都会无限期地等候另一个类装入器上的锁,所以它们就变成了死锁的。这些死锁可以发生在多线程环境中当常用的委托模型被忽略时。请考虑图 1 所描述的情况:
图 1. 类装入器死锁示例
在这里有两个用户定义的类装入器,即 mcl1 和 mcl2。mcl1 是系统类装入器的孩子,mcl2 是 mcl1 的孩子。类 A 和 B 在 mcl1 的类路径上,而类 C 在 mcl2 的类路径上。类 A 扩展了 C,类 C 扩展了 B。
一般在这种情况下,在试图装入 C(A 的超类)时,mcl1 会抛出 NoClassDefFoundError,因为 mcl1 不能向下看,而 C 只能被 mcl1 下面的类装入器装入。但是,在这种特殊的情况下,mcl1 向下委托它的孩子类装入器去装入特定包(package2)中的类,而类 C 就在这个包中。
清单 1 到 6 的测试用例实现了这个场景:
清单 1. ClassLoaderDeadlockTest.java
import java.net.URL;
public class ClassLoaderDeadlockTest {
MyClassLoader1 mycl1;
MyClassLoader2 mycl2;
public static void main(String[] args) {
new ClassLoaderDeadlockTest().test();
}
public void test() {
try {
mycl1 = new MyClassLoader1(new URL[] { new URL(
"file://C:/CL_Article/ClassloaderDeadlocks/cp1/") });
mycl2 = new MyClassLoader2(new URL[] { new URL(
"file://C:/CL_Article/ClassloaderDeadlocks/cp2/") }, mycl1);
} catch (Exception e) {
e.printStackTrace();
}
Thread t1 = new Thread(new Runnable() {
public void run() {
try {
System.out.println("About to load class A with mycl1");
mycl1.loadClass("package1.A");
System.out.println("Loaded Class A with mycl1");
} catch (Exception e) {
e.printStackTrace();
}
}
});
t1.start();
try {
System.out.println("About to load class C with mycl2");
mycl2.loadClass("package2.C");
System.out.println("Loaded Class C with mycl2");
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
}
|
清单 2. MyClassLoader1.java
import java.net.URL;
import java.net.URLClassLoader;
public class MyClassLoader1 extends URLClassLoader {
MyClassLoader1(URL[] urls) {
super(urls);
}
public Class loadClass(String name) throws ClassNotFoundException{
if (name.startsWith("package2."))
return MyClassLoader2.getClassLoader().loadClass(name);
else
return findClass(name);
}
}
|
清单 3. MyClassLoader2.java
import java.net.URL;
import java.net.URLClassLoader;
public class MyClassLoader2 extends URLClassLoader{
static ClassLoader loader;
MyClassLoader2(URL[] urls, ClassLoader parent) {
super(urls, parent);
loader = this;
}
public static ClassLoader getClassLoader() {
return loader;
}
}
|
清单 4. package1/A.java
package package1;
public class A extends package2.C {
}
|
清单 5. package1/B.java
package package1;
public class B {
}
|
清单 6. package2/C.java
package package2;
public class C extends package1.B {
}
|
在运行以上测试用例时,生成以下输出,然后应用程序就挂起:
About to load class C with mycl2
About to load class A with mycl1
|
应用程序之所以挂起,是因为每个线程都拥有一个类装入器上的锁,并想得到另一个类装入器上的锁,如图 2 的时间线图所示:
图 2. 类装入器死锁时间线
线程 2(t2)首先调用 mcl2 上同步的 loadClass() 方法以装入 package2.C,这造成 t2 得到 mcl2 上的锁。然后线程 1 (t1)启动,并调用 mcl1 上的 loadClass() 以装入 package1.A,这造成 t1 得到 mcl1 上的锁。因为 package1.A 扩展了 package2.C,所以 mcl1 开始装入超类。因为 C 在 package2 中,所以 mcl1 向下委托 mcl2 进行装入,就像前面描述的那样。这造成 t1 请求 mcl2 上的锁,并一直等候到可以得到锁为止。现在 t2 想用 mcl1 装入 package2.C 的超类(即 package1.B),因而想要得到 mcl1 上的锁。
因为每个线程都在等候对方持有的锁,所以就发生了死锁。
这种性质的死锁可以用本系列的第一篇文章中描述的某些调试特性来解决。在运行这个程序时设置 IBM 的 Verbose 类装入选项(-Dibm.cl.verbose),会有助于理解导致这个死锁的类装入顺序。这里是输出。
为了让这个清单更容易阅读,t2 的输出用粗体文本表示,t1 的输出用正常文本。可以看出,t2 到达的点已经装入了类 C,而 t1 已经装入了类 A。
这个问题最有价值的信息可以在 Javadump 中找到,用本系列的 第 1 部分 中描述的机制获得。JVM 通常能够探测到已经发生的死锁,并在 Javadump 中报告死锁,如下所示。(在这里,t2 被标识为 main,t1 被标识为 Thread-0):
...
Deadlock detected!!!
---------------------
Thread "Thread-0" (0x44DFE1E8)
is waiting for:
sys_mon_t:0x002A26D0 infl_mon_t: 0x00000000:
MyClassLoader2@ADB658/ADB660:
which is owned by:
Thread "main" (0x2A1750)
which is waiting for
sys_mon_t:0x002A2718 infl_mon_t: 0x00000000:
MyClassLoader1@ADB6D8/ADB6E0:
which is owned by:
Thread "Thread-0" (0x44DFE1E8)
...
|
这一节向我们展示了死锁中包含的线程,以及它们持有的锁和正在等候的锁。就在这一节下面,Javadump 显示了这些线程在死锁时的堆栈跟踪。不出所料,两个类装入器都在试图装入类:
"Thread-0" (TID:0xADB600, sys_thread_t:0x44DC72D0, state:CW, native ID:0x9DC) prio=5
at java.lang.ClassLoader.loadClass(ClassLoader.java:577)
at java.lang.ClassLoader.loadClass(ClassLoader.java:504)
at MyClassLoader1.loadClass(MyClassLoader1.java:12)
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 MyClassLoader1.loadClass(MyClassLoader1.java:13)
at ClassLoaderDeadlockTest$1.run(ClassLoaderDeadlockTest.java:29)
at java.lang.Thread.run(Thread.java:568)
"main" (TID:0xADB9B8, sys_thread_t:0x2A2028, state:CW, native ID:0x18C) prio=5
at java.lang.ClassLoader.loadClass(ClassLoader.java:577)
at java.lang.ClassLoader.loadClass(ClassLoader.java:563)
at java.lang.ClassLoader.loadClass(ClassLoader.java:504)
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 java.lang.ClassLoader.loadClass(ClassLoader.java:504)
at ClassLoaderDeadlockTest.test(ClassLoaderDeadlockTest.java:42)
at ClassLoaderDeadlockTest.main(ClassLoaderDeadlockTest.java:10)
|
Javadump 还显示了这些类装入器装入的类:
ClassLoader loaded classes
Loader MyClassLoader2(0x452DA0F0)
package2/C(0x00ACEAF0)
Loader MyClassLoader1(0x452DA7F8)
package1/A(0x00ACEC20)
Loader sun/misc/Launcher$AppClassLoader(0x44D7C7B8)
ClassLoaderDeadlockTest$1(0x00ACED50)
MyClassLoader1(0x00ACEFB0)
ClassLoaderDeadlockTest(0x00ACF0E0)
MyClassLoader2(0x00ACEE80)
Loader sun/misc/Launcher$ExtClassLoader(0x44D73D78)
Loader *System*(0x00352A08)
sun/reflect/UnsafeFieldAccessorFactory(0x44D40998)
java/lang/Class$1(0x002CF128)
java/io/InputStream(0x002C9818)
java/lang/Integer$1(0x002C83E8)
...
|
有了这些信息,就应当可以解决死锁问题了。因为死锁的类装入器的标识已知,所以可以检查这些类装入器使用的委托模型。在本例中,委托模型是一个正确的图(带有循环),所以死锁的发生可能是使用特定的类关系和线程的结果。在这里,类关系是类 A 扩展了类 C,从而触发了循环的委托模型。
类装入器约束违反
类装入器约束保证了类空间在 JVM 中的一致性。换句话说,当两个类装入器用相同的名称装入不同的类时(也就是不同的字节码),类装入器约束保证了它们之间不会有类型不匹配。
根据 JVM 规范,当满足以下四个条件时,就违反了类装入器约束:
- 有一个类装入器
L,Java 虚拟机把 L 记录成名为 N 的类 C 的初始装入器
- 有一个类装入器
L',Java 虚拟机把 L' 记录成名为 N 的类 C' 的初始装入器
- 实施的约束集合(的传递封包)所定义的等价关系表明:
N L = N L'
C != C'
解释这些条件的最简单方法是用一个示例。请考虑图 3 的场景:
图 3. 类装入器约束
类 A 有一个静态方法 methodA(),它用类 C 的实例作为参数。类 B 有一个静态方法 methodB(),它调用类 A 中的 methodA(),以 C 的一个实例作为参数。主程序调用类 B 中的 methodB()。
现在把这与 JVM 规范中定义的四个条件关联起来:
L = mycl1。C = mycl1 装入的类 C。N = C。
L' = mycl2。C' = mycl2 装入的类 C。N = C。
- 从
B 到 A 的方法调用中,传递了 C 的一个实例,这一传递所隐含的约束建立了等价关系。
- 类
C != 类 C'
因为四个条件全部成立,所以这种情况会导致类装入器约束违反。
清单 7 到 12 的测试用例实现了这个场景:
清单 7. ConstraintViolationTest.java
import java.lang.reflect.Method;
import java.net.URL;
public class ConstraintViolationTest {
MyClassLoader1 mycl1;
MyClassLoader2 mycl2;
public static void main(String[] args) {
new ConstraintViolationTest().test();
}
public void test() {
try {
mycl1 = new MyClassLoader1(new URL[] { new URL(
"file://C:/CL_Article/ConstraintViolation/cp1/") });
mycl2 = new MyClassLoader2(new URL[] { new URL(
"file://C:/CL_Article/ConstraintViolation/cp2/") }, mycl1);
System.out.println("About to load class A with mycl1");
mycl2.loadClass("A");
System.out.println("Loaded Class A with mycl1");
System.out.println("About to load class B with mycl2");
Class myB = mycl2.loadClass("B");
Method aMethod = myB.getMethod("methodB", new Class[] {});
aMethod.invoke(null, new Object[] {});
System.out.println("Loaded Class B with mycl2");
} catch (Exception e) {
e.printStackTrace();
}
}
}
|
清单 8. MyClassLoader1.java
import java.net.URL;
import java.net.URLClassLoader;
public class MyClassLoader1 extends URLClassLoader {
MyClassLoader1(URL[] urls) {
super(urls);
}
}
|
清单 9. MyClassLoader2.java
import java.net.URL;
import java.net.URLClassLoader;
public class MyClassLoader2 extends URLClassLoader {
static ClassLoader loader;
MyClassLoader2(URL[] urls, ClassLoader parent) {
super(urls, parent);
}
public Class loadClass(String name) throws ClassNotFoundException {
Class aClass = findLoadedClass(name);
if (aClass != null)
return aClass;
if (name.startsWith("C"))
return findClass(name);
else
return super.loadClass(name);
}
}
|
清单 10. A.java
public class A extends C {
public static void methodA(C c){
}
}
|
清单 11. B.java
public class B extends C {
static A a = new A();
public static void methodB() {
A.methodA(new C());
}
}
|
清单 12. C.java
在 mcl1 和 mcl2 的类路径中,都必须放入类 C 的一个副本。这个测试用例产生 以下输出。
解决类装入器约束违反
许多开发人员发现约束违反是一种很难解决的类装入问题。这主要是因为对于第一次遇到这个问题的开发人员来说,异常的消息看起来可能很奇怪。
解决这个问题的一个良好起点是,检查包含的类。可以从 IBM 的冗余输出或从 Javadump 中确认这些信息。
从上面的输出中,可以看出违反装入器约束的类是 C。如果想使用 IBM 的冗余输出检查包含的类,应当使用命令行选项 -Dibm.cl.verbose=C。输出会显示两个装入 C 的不同类装入器。
查看这个问题的更清楚的方式是生成 Javadump。这个场景的 Javadump 的类装入区看起来应当像这样:
ClassLoader loaded classes
Loader MyClassLoader2(0x44DC93A8)
C(0x00ACE9C0)
B(0x00ACEAF0)
Loader MyClassLoader1(0x44DC64B8)
C(0x00ACEC20)
A(0x00ACED50)
Loader sun/misc/Launcher$AppClassLoader(0x44D7C7B8)
MyClassLoader1(0x00ACEFB0)
ConstraintViolationTest(0x00ACF0E0)
MyClassLoader2(0x00ACEE80)
Loader sun/misc/Launcher$ExtClassLoader(0x44D73D78)
Loader *System*(0x00352A08)
sun/net/TransferProtocolClient(0x44D4AB18)
sun/reflect/UnsafeFieldAccessorFactory(0x44D40998)
java/lang/Class$1(0x002CF128)
java/io/InputStream(0x002C9818)
...
|
可以看到,类 C 已经由 MyClassLoader1 的实例(mcl1)装入,还由 MyClassLoader2 的实例(mcl2)装入。重要的是,两个类的地址(在括号中显示)不同。这意味着字节码来自不同的文件。
解决这个问题的最简单方法是确保在系统中只有类的一个副本 —— 也就是说,类只出现在一个类装入器的类路径中。但是,如果有必要拥有同一个类的两个副本,那么重要的是要确保在引用它们的类之间没有交互。
避免类装入器约束违反
虽然避免类装入器约束违反的最简单方法是在系统中只有类的一个副本,但有时拥有多个版本也是有必要的。
在部署类的多个版本时避免约束违反的一个可行方法是,使用对等类装入 模型,如图 4 所示。对等类装入不遵循传统的类装入器层次委托结构。相反,它有一组类装入器,彼此互不相关,但是有共同的双亲(通常是系统类装入器)。这些类装入器不仅可以委托给它们的双亲,还能委托给它们的对等体。
图 4. 对等类装入
这类类装入器结构允许在一个 JVM 中存在离散的类空间;所以,对于运行组件化的产品来说非常有用。这种类装入结构的示例就是 OSGi 框架,例如 Eclipse 构建于其上的框架。
结束语
本系列对使用 Java 类装入器时可能遇到的潜在问题提供了一般性的概述。我们介绍了可能发生的不同种类的异常,以及如何解决它们。我们还研究了在使用隐式或显式类装入器时可能出现的其他一些问题。另外,我们还介绍了 IBM JVM 的各种调试特性,并介绍了如何把它们应用到各种问题上。
我们希望这些文章提供的知识能够让您更好地理解类装入,并在应用程序中更好地利用类装入器。