Lakshmi Shankar , Java 技术中心开发团队, IBM Hursley 实验室
Simon Burns , Java 技术中心开发团队, IBM Hursley 实验室
类装入器负责把类装入 Java 虚拟机(JVM)。简单的应用程序可以用 Java 平台内置的类装入工具装入类;更复杂的应用程序则倾向于定义自己定制的类装入器。但是,不论使用哪种类装入器,在类装入过程中都可能发生许多问题。如果想避免这类问题,需要理解类装入的基本机制。当问题发生时,对于可用的诊断特性和调试技术的了解会有助于解决问题。
在这个系列的文章中,我们将深入研究类装入的问题,并用丰富的示例演示它们。这份介绍性的文章的第一节描述类装入的基础;第二节介绍一些 JVM 调试特性。系列中剩下的三篇文章将侧重于解决类装入异常,并演示一些可能会碰到的更复杂的类装入问题。
这一节描述类装入的核心概念,为系列剩下的部分提供知识基础。
类装入器委托模型 是把装入请求相互传给对方的类装入器图。引导 类装入器是这个图的根。用单一委托父类 创建类装入器,并在以下位置寻找类:
- 缓存(Cache)
- 父类(Parent)
- 自己(Self)
类装入器首先判断要求它装入的类是否与过去装入的类相同。如果相同,就返回上次返回的类(即保存在缓存中的类)。如果不是,就把装入类的机会交给父类。这两步递归地以深度优先的方式重复。如果父类返回 null(或抛出 ClassNotFoundException
),那么类装入器会在自己的路径中寻找类的源。
因为父类类装入器总是先得到装入类的机会,所以类装入器装入的类最靠近根。这意味着所有核心引导类都是由引导装入器装入的,这就保证装入了类(例如 java.lang.Object
)的正确版本。这也可以让类装入器看到自己或父类或祖先装入的类,但是不能看到子女装入的类。
图 1 显示了三个标准的类装入器:
与其他类装入器不同,引导类装入器(也称作基本(primordial) 类装入器)不能由 Java 代码实例化。(通常是因为它是作为 VM 本身的一部分实现的。)这个类装入器可以从启动的类路径装入核心系统类,通常是位于 jre/lib 目录的 JAR 文件。但是不能用 -Xbootclasspath
命令行选项修改这个类路径(稍后介绍)。
扩展(extension) 类装入器(也称作标准扩展 类装入器)是引导类装入器的一个孩子。它的主要职责是从扩展目录装入类,通常位于 jre/lib/ext 目录。这提供了简单地访问新扩展的能力,例如不同的安全扩展,不需要修改用户的类路径即可实现。
系统(system) 类装入器(也称作应用程序 类装入器)负责从 CLASSPATH
环境变量指定的路径装入代码。默认情况下,这个类装入器是用户创建的任何类装入器的父类。这也是 ClassLoader.getSystemClassLoader()
方法返回的类装入器。
表 1 总结了设置三个标准类装入器的类路径的命令行选项:
命令行选项 | 解释 | 涉及的类装入器 |
---|---|---|
-Xbootclasspath:<用 ; 或 : 分隔的目录和 zip/JAR 文件> | 设置引导类和资源的搜索路径。 | 引导 |
-Xbootclasspath/a:<用 ; 或 : 分隔的目录和 zip/JAR 文件> | 把路径添加到启动类路径的末尾。 | 引导 |
-Xbootclasspath/p:<用 ; 或 : 分隔的目录和 zip/JAR 文件> | 把路径添加到启动类路径的前面。 | 引导 |
-Dibm.jvm.bootclasspath=<用 ; 或 : 分隔的目录和 zip/JAR 文件> | 这个属性的值被用作额外的搜索路径,它被插到 -Xbootclasspath/p: 定义的值和启动类路径之间。启动类路径或者是默认值,或者是 -Xbootclasspath: 选项定义的值。 | 引导 |
-Djava.ext.dirs=<用 ; 或 : 分隔的目录和 zip/JAR 文件> | 指定扩展类和资源的搜索路径。 | 扩展 |
-cp or -classpath <用 ; 或 : 分隔的目录和 zip/JAR 文件> | 设置应用程序类和资源的搜索路径。 | 系统 |
-Djava.class.path=<用 ; 或 : 分隔的目录和 zip/JAR 文件> | 设置应用程序类和资源的搜索路径。 | 系统 |
类的装入实际上可以分成三个阶段:装入、链接和初始化。
虽然不是所有的问题,但至少大多数与类装入有关的问题都可以追溯到在这三个阶段中发生的某个问题。所以,对于每一阶段的深入理解有助于对类装入问题的诊断。图 2 显示了这三个阶段:
装入 阶段包括:找到必要的类(通过查找每个类路径)并装入字节码。在 JVM 中,装入阶段为类对象提供了非常基本的内存结构。在这一阶段不处理方法、字段和引用的其他类。所以,类还不能使用。
链接 是三个阶段中最复杂的一个。可以把它分成三个主要阶段:
- 字节码验证。 类装入器对于类的字节码要做许多检测,以确保格式正确、行为正确。
- 类准备。 这个阶段准备代表每个类中定义的字段、方法和实现接口所必需的数据结构。
- 解析。 在这个阶段,类装入器装入类所引用的其他所有类。可以用许多方式引用类:
- 超类
- 接口
- 字段
- 方法签名
- 方法中使用的本地变量
在初始化 阶段,类中包含的静态初始化器都被执行。在这一阶段末尾,静态字段被初始化成默认值。
在这三个阶段末尾,类被完整地装入,可以使用了。请注意可以用惰性方式执行类装入,所以类装入过程的某些部分可能在第一次使用类的时候才执行,而不是在装入时执行。
类装入的方式有两种 —— 显式 或 隐式,两者之间有些细微差异。
显式 类装入发生在使用以下方法调用装入的类的时候:
cl.loadClass()
(cl
是java.lang.ClassLoader
的实例)Class.forName()
(启动的类装入器是当前类定义的类装入器)
当调用其中一个方法的时候,指定的类(以类名为参数)由类装入器装入。如果类已经装入,那么只是返回一个引用;否则,装入器会通过委托模型装入类。
隐式 类装入发生在由于引用、实例化或继承导致装入类的时候(不是通过显式方法调用)。在每种情况下,装入都是在幕后启动的,JVM 会解析必要的引用并装入类。与显式类装入一样,如果类已经装入了,那么只是返回一个引用;否则,装入器会通过委托模型装入类。
类的装入通常组合了显式和隐式类装入。例如,类装入器可能先显式地装入一个类,然后再隐式地装入它引用的所有类。
前面一节介绍了类装入的基本原则。这一节介绍 IBM JVM 中内置的帮助调试的特性。其他 JVM 也有类似的调试特性;请参阅相关文档来了解细节。
可以用 -verbose
命令行选项打开 IBM JVM 的详细输出。当某些事件发生的时候(例如,类装入时),详细输出会在控制台上显示信息。要想得到额外的类装入信息,可以用详细类输出。可以用 -verbose:class
选项启动这个模式。
解释详细输出
详细输出列出已经打开的所有 JAR 文件,包括到这些 JAR 的完整路径。下面是一个示例:
|
所有装入的类都已经列出,同时还指出它们是从哪个 JAR 文件或目录装入的。例如:
|
详细类输出显示额外信息,例如在装入超类的时候,在运行静态初始化器的时候。下面是一些示例输出:
|
详细输出还显示一些内部抛出的异常(如果发生的话),包含堆栈跟踪。
用 -verbose 解决问题
详细输出有助于解决类路径问题,例如没有打开 JAR 文件(因此不在类路径中)或从错误的位置装入了类。
知道类装入器在哪里寻找类、特定的类是由哪个类装入器装入的,通常很有用。可以用 IBM 详细类装入命令行选项得到这个信息:-Dibm.cl.verbose=<class name>
。可以用正则表达式声明类的名称,例如 Hello*
会跟踪所有以 Hello
开头的名称。
这个选项也可用于用户定义的类装入器,只要它们直接或间接地扩展了 java.net.URLClassLoader
。
解释 IBM 详细类装入的输出
IBM 详细类装入的输出显示了要装入指定类的类装入器以及它们查找的位置。例如,假设用以下命令行:
|
在这里,MainClass
在它的主方法中引用了 ClassToTrace
。这会形成像 这里 一样的输出。
在列出类装入器的时候,父类在子女之前列出,因为标准的委托模型的工作方式是父类优先。
请注意,引导类装入器没有输出。只有扩展了 java.net.URLClassLoader
的类装入器才有输出。还请注意,类装入器列在它们的类名之下;如果类装入器有两个实例,那么可能无法区分它们。
用 IBM 详细类装入解决问题
IBM 详细类装入选项是检查所有类装入器设置的类路径的好方法。它还可以指出指定类是由哪个类装入器装入的、从哪里装入的。这样就可以容易地看出是否装入了类的正确版本。
Javadump(也称为 Javacore)是另一个很有用的 IBM 诊断工具;要了解它,请参阅 IBM Diagnostics Guides(请参阅 参考资料 中的链接)。当发生以下事件时,JVM 会生成 Javadump:
- 发生致命的本机异常
- JVM 用光了堆空间
- 向 JVM 发送了一个信号(例如,在 Windows 上按下了 Control-Break 或在 Linux 上按下了 Control-/)
- 调用了
com.ibm.jvm.Dump.JavaDump()
方法
触发 Javadump 的时候,会把详细信息记录到在当前工作目录下保存的一个有日期戳的文本文件中。信息包括线程、锁、堆栈等方面的数据,以及关于系统中类装入器的丰富信息。
解释 Javadump 中的类装入部分
Javadump 文件中提供的确切信息取决于 JVM 在哪个平台上运行。类装入器部分包括:
- 定义的类装入器和它们之间的关系
- 每个类装入器装入的类的列表
以下是从 Javadump 提取的类装入器信息的快照:
|
在这个示例中,只有三个标准类装入器:
- 系统类装入器(
sun/misc/Launcher$AppClassLoader
) - 扩展类装入器(
sun/misc/Launcher$ExtClassLoader
) - 引导类装入器(
*System*
)
Classloader 汇总部分提供了系统中每个类装入器的细节。在这个系列的文章中,感兴趣的类型是基本、扩展、系统、应用程序 和 委托(用在反射中)。其他类型(共享的、中间件 和信任的)用在 Persistent Reusable JVM 中,它们超出了这个文章系列的范围(请参阅 Persistent Reusable JVM User Guide 以获得更多信息;在下面的 参考资料 一节中有一个链接)。这个汇总部分还显示了父类类装入器:系统类装入器的父类是 sun/misc/Launcher$ExtClass loader(0x00ADB830)
。这个父类地址对应于父类类装入器的原始数据结构(叫作 shadow)。
类装入器装入的类部分列出了每个类装入器装入的类。在这个示例中,系统类装入器只装入了一个类 HelloWorld
(在地址 0x00ACF0E0
上)。
用 Javadump 解决问题
使用 Javadump 提供的信息,可以确定系统中存在哪些类装入器。这包括任何用户自定义的类装入器。从装入的类列表中,可以找出特定的类是由哪个类装入器装入的。如果找不到某个类,说明系统中的任何一个类装入器都没有装入它(通常会形成 ClassNotFoundException
异常)。
可以用 Javadump 诊断的其他类型的问题包括:
- 类装入器命名空间问题。 类装入器的命名空间是类装入器和它装入的所有类的组合。例如,如果某个类存在,但是由错误的类装入器装入(有时会造成
NoClassDefFoundError
异常),那么命名空间就是错误的 —— 也就是说,类在错误的类路径中。为了纠正这种问题,可以试着把类放到不同的位置(例如放在正常的 Java 类路径中),并确保由系统类装入器装入它。 - 类装入器约束问题。 在这个系列的最后一篇文章中将讨论这种问题的一个示例。
IBM JVM 有一个内置的方法跟踪工具。这样,不需要修改 Java 代码,就可以跟踪任何 Java 代码(包括核心系统)中的方法。因为这个工具可以提供大量数据,所以可以控制跟踪的级别,只获取需要的信息。
启动跟踪的选项取决于 JVM 的发行版。关于这些选项的细节,请参阅 IBM Diagnostics Guides(请参阅 参考资料 中的链接)。
下面是一些命令行示例:
在 IBM Java 1.4.2 中运行 HelloWorld
时要跟踪所有 java.lang.ClassLoader
:
|
跟踪 ClassLoader
中的 loadClass()
方法和 HelloWorld
中的方法,也在 IBM Java 1.4.2 中:
|
解释方法跟踪的输出
这里 是方法跟踪输出的一个示例(用前面一段的第二个命令行)。
跟踪的每一行都提供了比上面显示的更多的信息。我们来完整地看看上面的一行:
|
这个跟踪包括:
12:57:41.277
:方法进入或退出的时间戳。0x002A23C0
:线程 ID。04000D
:某些高级诊断使用的内部 JVM 跟踪点。- 余下的信息显示是进入(
>
)还是退出了(<
)方法,后面跟着方法的细节。
用方法跟踪解决问题
可以用方法跟踪解决不同类型的问题,包括:
- 性能热点: 使用时间戳,可以发现需要花费相当多时间来执行的方法。
- 挂起: 最后的方法项通常是很好的线索,可以指明应用程序是否挂起。
- 错误对象: 使用地址,通过与对象的构建函数调用的地址进行比对,可以检查出是不是在正确的对象上调用方法 。
- 意外的代码路径: 通过跟踪进入和退出点,可以看出程序是否采用了意外的代码路径。
- 其他错误: 最后的方法项是对错误发生位置的良好提示。
类装入问题解密(一)- 类装入和调试工具介绍
类装入问题解密(二)-基本的类装入异常
类装入问题解密(三)- 处理更少见的类装入问题
类装入问题解密(四)-死锁和约束