这是Java基础篇(JVM)的第二篇文章,紧接着上一篇字节码详解,这篇我们来详解Java的类加载机制,也就是如何把字节码代表的类信息加载进入内存中。
我们知道,不管是根据类新建对象,还是直接使用类变量/方法,都需要在类信息已经加载进入内存的前提下。在Java虚拟机规范中,类加载过程也就是类的生命周期包括7个部分:加载、验证、准备、解析、初始化、使用、卸载。不过我们先不写这几个阶段,先讲讲类加载器的知识,然后再来看具体的类加载过程。
1. 类加载器
关于类加载器,我主要关注两个方面,一是类加载器的作用,二是类加载器的双亲委托机制。
首先说第一个,类加载器在Java体系中有两个作用:
(1)在类生命周期的加载阶段,通过一个类的全限定名来获取此类的二进制字节流。在JVM规范中,没有强制规定类加载器为虚拟机的一部分,也就是说,类加载过程是可以放到JVM外部去实现的。说通俗一点,就是我们可以根据规范自己去实现加载器,如HotSpot实现中,启动类加载器是C++写的,是虚拟机的一部分,但其它类加载器都是Java写的,继承自java.lang.ClassLoader类。
这样规定有两个好处,一是二进制字节流的来源可以不限于Class文件,可从zip包获取(jar、war)、从网络获取(Applet)、运行时计算生成(动态代理)、从其它文件生成(JSP编译得到)等;第二个是我们可以自己实现类加载器,如OSGi就充分利用了类加载器的灵活实现(反双亲委托)、Tomcat等服务器也有自己的类加载器体系。
(2)在类的整个生命周期内,用来判定两个类是否相等。只有当类的全限定相等,且由同一个类加载器加载时,才认为两个类完全相等。这会影响到equals()方法、isAssignableFrom()方法、isInstance()方法(instanceOf)的执行结果。
这里我要问两个问题,一是普通类是和类加载器类相关联还是和它的实例相关联?二是它们是如何关联起来的?
第一个问题,应该是和类加载器的实例相关联。从需求出发,我们需要有多个不同的类加载器来加载类,这时候就不能使用静态的方法,而应用实例来加载;而且从结果来推过程:看ClassLoader等的源码,loadClass()方法都不是static的,所以应该是和类加载器的实例相关联。
第二个问题,我们看到ClassLoader类中维护了一个HashSet,这个集合中存储的是以该加载器作为初始加载器的类的全限定名,这称为类加载器的命名空间,这样,类和类加载器就联系起来了。
对Java程序员来说,类加载器的体系结构如图:
注意这里的父类/子类加载器并非继承关系,而是组合的关系:在ClassLoader类中,定义了一个变量parent。在使用类加载器时,会首先给这个变量赋值,如AppClassLoader类加载器,首先会将这个parent赋值为ExtClassLoader类型的变量。
类加载器真正的继承关系是之前提到的:启动类加载器是JVM的一部分,其它类加载器都继承自ClassLoader抽象类。
各个类加载器的作用是:
(1)启动类加载器:加载放在\lib中的、JVM能够识别的类库。Java程序不能直接引用启动类加载器。
(2)引导类加载器:加载放在\lib\ext中的所有类库,开发者可以直接使用扩展类加载器。
(3)应用类加载器:加载用户类路径(ClassPath)下的指定的类库,开发者可以直接使用自定义类加载器,通常我们自己编写的类都是由这个类加载器加载。
比较特殊的是自定义类加载器,通常来说有了上面的类加载器体系就够用了,但对于一些特殊的场合,还需要编写自定义加载器,比较常见的有我自己总结有两个:
1. 在双亲委托机制下,实现特殊的需要。如为了安全考虑,需要先将字节码加密,类加载器加载时需要先解密;或者需要从非标准的来源如网络获取二进制字节码进行加载等;
2. 破坏双亲委托机制,以实现诸如热部署等功能。
编写自定义类加载器的方法是:继承ClassLoader抽象类,并重写其findClass()方法,如何重写findClass()方法见下文。
再来看看第二个,类加载器的双亲委托机制:
类的加载采用双亲委托的机制,即:先由本加载器的父类加载器尝试加载,只有当父类加载器不能完成加载动作时,才由本类加载器进行加载(如果父类加载器为null,则由启动类加载器尝试加载)。默认是应用类加载器,逐级往上委托。
另外,类加载器还是全盘委托的,也就是说,与本类相关的(引用或继承等)类都以本类为初始加载器,并通过双亲委托机制确定其最终的加载器。
采用双亲委托机制的好处是,“Java类随着它的类加载器一起具备了一种带有优先级的层次关系”,可以保证一些基本的Java不会被破坏。如Object、String等。因为标志一个类除了类本身,还有加载它的类加载器。
这里我有个问题,我看到ClassLoader中的loadClass()等方法都不是static的,也就是说是类加载器类的实例进行的加载操作,那么对于我们一个普通程序而言,并没有显式地去新建一个类加载器类的对象,这个对象是虚拟机启动时就自动建好的吗?如果是,那加载ClassPath下的类的类加载器实例是同一个吗?
这个问题我找了很多地方都没有明白回答,我谈谈自己的理解:我们知道命名空间的规则是:同一个命名空间中类的全限定名不能重复;不同命名空间中的类不能相互访问。因为存的是以它作为初始类加载器的类,由全盘委托机制可得到,与之相关的类它都可以访问。以此看来,虚拟机启动时是为每个层次都新建了一个类加载器对象,如果没有显式地自己新建类加载器对象,那么所有的类都是由这几个默认的加载器实例加载。由同一层级类加载器实例加载的类,也就都在同一命名空间,可以相互访问。
前面提到了破坏双亲委托机制,这里再简要地说说这个点。破坏双亲委托机制通常有两种场合:
一是基础类需要回调用户的代码,这时由于基础类是由更上层的类加载器加载的(如启动类加载器),它不能加载用户代码中的类,如果还按照双亲委托,则这些类永远无法加载。如JNDI、JDBC等都是这种场合。这时候的解决方案是,通过引入“线程上下文类加载器”来加载用户代码中的类,这个线程上下文类加载器不是双亲委托机制体系下的类加载器,自然就不受双亲委托机制的约束了。
二是在要求程序动态性的场合,如需要代码热替换、模块热部署等。这时候类加载机制就不再是双亲委托机制中的树状结构,二是复杂的网状结构。这属于模块化这部分的知识,具体不是很清楚,可以先放一放以后再了解。
最后,关于类加载器,有个问题我一直没有搞明白,那就是类加载器到底是如何加载表示类信息的二进制字节流的?前面说到,我们自定义一个类加载器,Java规范推荐我们重写findClass()方法(而不是重写loadClass()方法,以避免破坏双亲委托机制),那么我们该如何重写findClass()方法呢?
我想,如果可以搞清楚扩展类加载器或者应用类加载器的findClass()方法,上面的疑问应该就可以搞清楚了。下面我们就通过AppClassLoader的源码,来分析分析应用类加载器的findClass()方法[1]。
首先来看AppClassLoader的继承结构:
可以看到,URLClassLoader继承了ClassLoader抽象类,AppClassLoader是sun.misc.Launcher的静态内部类,它继承了URLClassLoader类。
下面是AppClassLoader的loadClass()方法:
public synchronized Class loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
int i = name.lastIndexOf('.');
if (i != -1) {
SecurityManager sm = System.getSecurityManager();
if (sm != null) {
sm.checkPackageAccess(name.substring(0, i));
}
}
return (super.loadClass(name, resolve));
}
我们看到最后一行是调用super的loadClass()方法,由于它的直接父类URLClassLoader()没有重写loadClass()方法,最终这里是调用ClassLoader的loadClass()方法,仍然遵循双亲委托原则。下面是ClassLoader的loadClass方法:
protected synchronized Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
// 首先,检查该类是否已经被加载过了
Class c = findLoadedClass(name);
// 若没有被加载,则进行下面的操作
if (c == null) {
try {
if (parent != null) { // 如果有父类加载器,则先让父类加载器尝试加载该类
c = parent.loadClass(name, false);
} else { // 否则,让JVM启动类加载器加载
c = findBootstrapClass0(name);
}
} catch (ClassNotFoundException e) {
// 父类(和启动类)加载器无法加载,则使用本类加载器加载
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
对应用类加载器来说,加载类时还是调用它的loadClass()方法,紧接着调用ClassLoader的loadClass()方法,在该方法中,调用了findClass()方法。这个方法在ClassLoader类中没有给出具体实现,其具体实现在URLClassLoader中:
protected Class<?> findClass(final String name)
throws ClassNotFoundException
{
try {
return (Class)
AccessController.doPrivileged(new PrivilegedExceptionAction() {
public Object run() throws ClassNotFoundException {
String path = name.replace('.', '/').concat(".class");
Resource res = ucp.getResource(path, false);
if (res != null) {
try {
return defineClass(name, res);
} catch (IOException e) {
throw new ClassNotFoundException(name, e);
}
} else {
throw new ClassNotFoundException(name);
}
}
}, acc);
} catch (java.security.PrivilegedActionException pae) {
throw (ClassNotFoundException) pae.getException();
}
}
可以看到,findClass()方法的核心代码在defineClass()处,它是URLClassLoader中的方法。关于这个方法,官方的描述是:使用从特定源获取的字节码来构造一个Class对象(返回的Class对象在使用前必须先解析)。defineClass()的源码较长,这里选取其中比较核心的一段:
java.nio.ByteBuffer bb = res.getByteBuffer();
if (bb != null) {
// Use (direct) ByteBuffer:
CodeSigner[] signers = res.getCodeSigners();
CodeSource cs = new CodeSource(url, signers);
return defineClass(name, bb, cs);
} else {
byte[] b = res.getBytes();
// must read certificates AFTER reading bytes.
CodeSigner[] signers = res.getCodeSigners();
CodeSource cs = new CodeSource(url, signers);
return defineClass(name, b, 0, b.length, cs);
}
我们看到,这里使用了NIO的 (direct) ByteBuffer类来缓冲特定源的字节码,最终调用了ClassLoader类中的defineClass()方法。
本文暂时就分析到这个层次,因为目的是回答先前提出的两个问题,现在我们可以给出一个较为合适的答案:
问:1. 类加载器是如何加载二进制字节码的?
答:使用NIO的ByteBuffer类来缓冲并读入,接着调用defineClass()方法,只要字节码符合规范,这个方法就能够在内存中构造Class对象,并返回对其的引用。
问:2. 编写自定义类加载器时,如何重写findClass()方法?
答:首先,要考虑具体的需求,其次,常见的步骤是先用IO或者NIO读入字节码文件,再调用defineClass()方法。
2. 类加载过程
大致讲完了类加载器我关注的几点,现在正式来写类加载的过程。前面说到,在Java虚拟机规范中,类加载过程也就是类的生命周期包括7个部分:加载、验证、准备、解析、初始化、使用、卸载:
各个过程的作用简要介绍如下:
(1)加载。加载过程用到的就是我们前面讨论了那么长的类加载器,这个过程的主要目的是通过一个类的全限定名来获取这个类的二进制字节流,并将这个字节流代表的静态存储结构转化成方法区中运行时的数据结构,最后,在内存中生成这个类的Class对象。
加载阶段的结果是,方法区中存储了该类的信息,内存中也生成了相应的Class对象。
需要注意的是,在HotSpot虚拟机实现中,Class对象在方法区中,而不是在堆中。另外,数组类本身不由类加载器创建,而是由虚拟机直接创建,但是数组类的元素类型是类加载器创建的。最后,加载阶段可能并未完成,后面的连接阶段就已经开始。
(2)验证。Java语言本身相对安全,但是由于字节码文件来源不确定,所以必须验证其安全性,以免危害整个系统。
验证阶段主要的工作是:文件格式验证、元数据验证、字节码验证以及符号引用验证。
只有经过文件格式验证阶段的验证,字节流才会进入内存的方法区进行存储,而后面三个验证阶段都是基于方法区的存储结构进行。
元数据验证阶段是为了保证类信息符合Java语言规范。
字节码验证是为了确定程序语义合法、符合逻辑,最为复杂。
符号引用验证发生在虚拟机将符号引用转化为直接引用的时候(解析阶段),是进行对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验。
(3)准备。正式为类变量分配内存并赋予初值。这里有两个点需要注意,一是这个阶段只为类变量赋初值,二是这里的初值是程序默认的初值(null或0或false)。
(4)解析。将常量池中的符号引用转换为直接引用。符号是指Class文件中的各种常量,符号引用仅仅使用相应的符号来表示要引用的目标,并不要求所引用的目标都在内存当中。直接引用则不同,直接引用和内存布局相关,直接引用的对象一定是被加载到内存当中的。
(5)初始化。Java虚拟机规范没有明确规定字节码“加载”的时机,但却明确规定了初始化的时机,触发初始化则肯定先触发“加载”操作。
触发初始化的时机有5个:
- 遇到new关键字、读取或设置类的static变量、调用一个类的static方法时;
- 反射调用时;
- 初始化一个类,发现其父类未被初始化,则初始化其父类;
- 虚拟机启动时,包含main方法的类会先初始化;
- 动态语言支持(略)。
(6)最后详细说说卸载。类什么时候被卸载呢?当类对应的Class对象不再被引用时,类会被卸载,类在方法区中的数据也会被删除。问题就变成Class对象什么时候被卸载了。我们知道,Class对象始终会被其类加载器引用,那么也就是说,如果类是被启动类加载器、引导类加载器以及应用类加载器加载的,那么它始终不会被卸载。
嗯,这篇就先写到这里。其实很早就写完了,中间隔了一个月的时间去做毕设写论文,下周答完辩就算是硕士毕业了。
[1] 如果我们单是下载了Sun的JDK,那么是看不到AppClassLoader的源码的。这里需要去下载OpenJDK的源码,通过这个开源的项目,我们可以看到更多关于Java的源码,甚至还有JVM的源码。https://download.java.net/openjdk/jdk6
[2] 与传统的IO不同,NIO使用了临时存储区来缓冲数据,它基于块。ByteBuffer是NIO里用得最多的Buffer,它包含两个实现方式:HeapByteBuffer是基于Java堆的实现,而(direct)ByteBuffer则使用了sun.misc.Unsafe的API进行了堆外的实现。