基本信息
每个开发人员对java.lang.ClassNotFoundException这个异常肯定都不陌生,这背后就涉及到了java技术体系中的类加载。Java的类加载机制是技术系中比较核心的部分,虽然和大部分开发人员直接打交道不多,但是对其背后的机理有一定理解有助于排查程序中出现的类加载失败等技术问题,对理解java虚拟机的连接模型和java语言的动态性都有很大帮助。
java虚拟机类加载器结构简述
JVM三种预定义类型加载器
我们首先看一下JVM预定义的三种类型类加载器,当一个JVM启动的时候,Java缺省开始使用如下三种类型类装入器:
- 启动(Bootstrap)类加载器:引导类装入器是用本地代码实现的类装入器,他负责将<Java_Runtime_Home>/lib下面的核心类库或-Xbootclasspath选项指定的jar包加载到内存中。由于引导类加载器涉及到虚拟机本地实现细节,开发者无法直接获取到启动类加载器的引用,所以不允许直接通过引用进行操作。
- 扩展(Extension)类加载器:扩展类加载器是由Sun的ExtClassLoader(sun.misc.Launcher$ExtClassLoader)实现的。他负责将<Java_Runtime_Home>/lib/ext或者由系统变量-DJava.ext.dir指定位置中的类库加载到内存中。开发者可以直接使用标准扩展类加载器。
- 系统(System)类加载器:系统类加载器是由Sun的AppClassLoader(sun.misc.Launcher$ExtClassLoader)实现的。他负责将系统类路径java -classpath或-DJava.class.path变量所指的目录下的类库加载到内存中。开发者可以直接使用系统类加载器。
- 除了以上列举的三种类加载器,还有一种比较特殊的类型就是线程上下文类加载器。
类加载双亲委派机制介绍和分析
在这里,需要着重说明的是,JVM在加载类时默认采用的是双亲委派机制。通俗的讲,就是某个特定的类加载器在接到加载类的请求时,首先将加载任务委托给父类加载器,依次递归,如果父类加载器可以完成类加载任务,就成功返回;只有父类加载器无法完成此加载任务时,才自己去加载。关于虚拟机默认的双亲委派机制,我们可以从系统类加载器和扩展类加载器为例作简单分析。
通过上图我们可以看出,类加载器均是继承自java.lang.ClassLoader抽象类。下面我们就看简要介绍一下java.lang.ClassLoader中几个最重要的方法:
通过进一步分析标准扩展类加载器(sun.misc.Launcher$ExtClassLoader)和系统类加载器(sun.misc.Launcher$AppClassLoader)的代码以及其公共父类(java.net.URLClassLoader和java.security.SecureClassLoader)的代码可以看出,都没有覆写java.lang.Classloader中默认的加载委派规则——loadClass(...)方法。既然这样,我们就可以通过分析java.lang.ClassLoader中的loadClass(String name)方法的代码就可以分析出虚拟机默认采用的双亲委派机制到底是什么模样:
public Class<?> loadClass(String name) throws ClassNotFoundException {
return loadClass(name, false);
}
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// 首先,判断该类型是否已经被加载
Class<?> c = findLoadedClass(name);
if (c == null) { // 如果没有被加载,就委托给父类加载或者委派给启动类加载其加载
// 获取最准确的可用系统计时器的当前值,以毫微秒为单位
long t0 = System.nanoTime();
try {
if (parent != null) {
// 如果存在父类加载器,就委派给父类加载器加载
// 使用父类的加载器来加载类。
c = parent.loadClass(name, false);
} else {
// 如果不存在父类加载器,就检查是否是由启动类加载器加载的类,通过调用本地方法findBootstrapClass
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 如果父类加载器和启动类加载器都不能完成加载任务,才调用自身的加载功能
}
if (c == null) {
// 如果仍然没有找到,然后调用findClass来查找该类。
long t1 = System.nanoTime();
c = findClass(name);
// 这是定义类加载器;记录数据
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) { // 链接指定的类。
resolveClass(c);
}
return c;
}
}
通过上面的代码分析,我们可以对JVM采用的双亲委派类加载机制有了更感性的认识,下面我们就接着分析一下启动类加载器、标准扩展类加载器和系统类加载器三者之间的关系。可能大家已经从各种资料上面看到了如下类似的一幅图片:
上面图片给人的直观印象是系统类加载器的父类加载器是标准扩展类加载器,标准扩展类加载器的父类加载器是启动类加载器,下面我们就用代码具体测试一下:
public class LoaderTest {
public static void main(String[] args) {
System.out.println(ClassLoader.getSystemClassLoader());
System.out.println(ClassLoader.getSystemClassLoader().getParent());
System.out.println(ClassLoader.getSystemClassLoader().getParent().getParent());
}
}
说明:通过java.lang.ClassLoader.getSystemClassLoader()可以直接获取系统类加载器。
代码输出如下:
通过以上的代码输出,我们可以判定系统类加载器的父加载器是标准扩展类加载器,但是我们试图获取标准扩展类加载器的父类加载器时得到了null,就是说标准扩展类加载器本身强制设定父类加载器为null。我们还是借助于代码分析一下。
我们首先看一下java.lang.ClassLoader抽象类中默认实现的两个构造函数:
我们再看一下ClassLoader抽象类中parent成员的声明:
private final ClassLoader parent;
声明为私有变量的同时并没有对外提供派生类访问的public或者Protected设置器接口(对应的setter方法),结合前面的测试代码的输出,我们可以推断出:
- 系统类加载器(AppClassLoader)调用ClassLoader(ClassLoader parent)构造函数将父类加载器设置为标准扩展类加载器(ExtClassLoader)。(因为如果不强制设置,默认会通过调用getSystemClassLoader()方法获取并设置成系统类加载器,这显然和测试输出结构不符。)
- 扩展类加载器(ExtClassLoader)调用ClassLoader(ClassLoader parent)构造函数将父类加载器设置为null。(因为如果不强制设置,默认会通过调用getSystemClassLoader()方法获取并设置成系统类加载器,这显然和测试输出结果不符。)
现在我们可能会有这样的疑问:扩展类加载器(ExtClassLoader)的父类加载器被强制设置为null,那么扩展类加载器为什么还能将加载任务委派给启动类加载器呢?
通过上图可以看出,标准扩展类加载器和系统类加载器及其父类(java.net.URLClassLoader和java.security.SecureClassLoader)都没有覆写java.lang.ClassLoader中默认的加载委派规则——loadClass(...)方法。有关java.lang.ClassLoader中默认的加载委派规则前面已经分析过,如果父加载器为null,则会调用本地方法进行启动类加载尝试。所以,在前面,启动类加载器、标准扩展类加载器和系统类加载器之间的委派关系事实上是仍就成立的。
类加载双亲委派示例
以上已经简要介绍了虚拟机默认使用得启动类加载器、标准扩展类加载器和系统类加载器,并以三者为例结合JDK代码对JVM默认使用的双亲委派类加载机制做了分析。下面我们就来看一个综合的例子。首先在IDE中建立一个简单的java应用工程,然后写一个简单地JavaBean如下:
public class TestBean {
public TestBean() {
}
}
建立一个测试类内容如下:
测试一
public class ClassLoaderTest {
public static void main(String[] args) {
try {
// 查看当前系统类路径中包含的路径条目
System.out.println(System.getProperty("java.class.path"));
// 调用加载当前类的类加载器(这里即为系统类加载器)加载TestBean
@SuppressWarnings("rawtypes")
Class typeLoaded = Class.forName("com.test.bean.TestBean");
// 查看被加载的TestBean类型是被那个类加载器加载的
System.out.println(typeLoaded.getClassLoader());
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
}
对应输出如下
说明:当前类路径默认的含有的一个条目就是工程的输出目录。
测试二
将当前工程输出目录下的TestBean.class打包进test.jar剪贴到<Java_Runtime_Home>/lib/ext目录下(现在工程输出目录下和JRE扩展目录下都有待加载类型的class文件)。再运行测试一测试代码,结果如下:
对比测试一和测试二,我们明显可以验证前面说的双亲委派机制,系统类加载器在接到加载classloader.test.bean.TestBean类型的请求时,首先将请求委派给父类加载器(标准扩展类加载器),标准扩展类加载器抢先完成了加载请求。
测试三
将test.jar拷贝一份到<Java_Runtime_Home>/lib下,运行测试代码,输出如下:
测试三和测试二输出结果一致。那就是说,放置到<Java_Runtime_Home>/lib目录下的TestBean对应的class字节码并没有被加载,这其实和前面讲的双亲委派机制并不矛盾。虚拟机处于安全等因素考虑,不会加载<Java_Runtime_Home>/lib存在的陌生类,开发者通过将要加载的非JDK自身的类放置到此目录下期待启动类加载器加载是不可能的。做个进一步验证,删除<Java_Runtime_Home>/lib/ext目录下和工程输出目录下的TestBean对应的class文件,然后再运行测试代码,则将会有ClassNotFoundException异常抛出。有关这个问题,大家可以在java.lang.ClassLoader中的loadClass(String name, boolean resolve)方法中设置相应断点运行测试三进行调试,会发现findBootStrapClass0()会抛出异常,然后在下面的findClass方法中被加载,当前运行的类加载器正式扩展类加载器(sun.misc.Launcher$ExtClassLoader),这一点可以通过JDT中变量视图查看验证。
Java程序动态扩展方式
Java的连接模型允许用户运行时扩展引用程序,既可以通过当前虚拟机中预定义的加载器加载编译时已知的类或者接口,又允许用户自行定义类装载器,在运行时动态扩展用户的程序。通过用户自定义的类装载器,你的程序可以装载在编译时并不知道或者尚未存在的类或者接口,并动态连接他们并进行有选择的解析。
运行时动态扩展java应用程序有如下两个途径:
调用java.lang.Class.forName(...)加载类
这个方法其实在前面已经讨论过,在后面的问题2解答中说明了该方法调用会触发哪个类加载器开始加载任务。这里需要说明的是多参数版本的forName(...)方法:
public static Class<?> forName(String name, boolean initialize,
ClassLoader loader)
throws ClassNotFoundException
这里的initialize参数是很重要的。他表示在加载同时是否完成初始化的工作(说明:单参数版本的forName方法默认是完成初始化的)。有些场景下需要将initialize设置为true来强制加载同时完成初始化。例如典型的就是利用DriverManager进行JDBC驱动程序类注册的问题。因为每一个JDBC驱动程序类的静态初始化方法都用DriverManager注册驱动程序,这样才能被应用程序使用。这就要求驱动程序类必须被初始化,而不单单被加载。Class.forName的一个很常见的用法就是在加载数据库驱动的时候。如Class.forName("org.apache.derby.jdbc.EmbeddedDriver").newInstance()用来加载Apache Derby数据库的驱动。
用户自定义类加载器
通过前面的分析,我们可以看出,除了和本地实现密切相关的启动类加载器之外,包括标准扩展类加载器和系统类加载器在内的所有其他类加载器我们都可以当作自定义加载器来对待,唯一区别是是否被虚拟机默认使用。前面的内容中已经对java.lang.ClassLoader抽象类中的几个重要的方法做了介绍,这里就简要叙述一下一般用户自定义类加载器的工作流程吧:
- 首先检查请求的类型是否已经被这个类装载器装载到命名空间中了,如果已经装载,直接返回;否则转入步骤2;
- 委派类加载请求给父类加载器(更准确的说应该是双亲类加载器,真实虚拟机中各种类加载器最终会呈现树状结构),如果父类加载器能够完成,则返回父类加载器加载的Class实例;否则转入步骤3;
- 调用本类加载器的findClass(...)方法,试图获取对应的字节码,如果获取到,则调用defineClass(...)导入类型到方法区;如果获取不到对应的字节码或者其他原因失败,返回异常给loadClass(...),loadClass(...)转而抛异常,终止加载过程(注意:这里的异常种类不止一种)。
说明:这里说的自定义类加载器是指JDK1.2以后版本的写法,即不覆写改变java.lang.loadClass(...)已有委派逻辑情况下。
整个加载类的过程如下图: