JVM把描述类的数据从class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成JVM可以直接使用的Java类型的过程就是类加载机制。
1. 类加载的时机
类从被加载到虚拟机内存中开始,到卸载出内存为止,它的生命周期包括了:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)、卸载(Unloading)七个阶段,其中验证、准备、解析三个部分统称连接。顺序如下:
加载(装载)、验证、准备、初始化和卸载这五个阶段顺序是固定的,类的加载过程必须按照这种顺序开始,而解析阶段不一定;它在某些情况下可以在初始化之后再开始,这是为了运行时动态绑定特性。值得注意的是:这些阶段通常都是互相交叉的混合式进行的,通常会在一个阶段执行的过程中调用或激活另外一个阶段。
什么时候需要开始类加载过程的第一个阶段:加载。虚拟机规范中并没有强制约束,这点可以交给虚拟机的具体实现来自由把握。但是对于初始化阶段,虚拟机是严格规定了有且只有四种情况必须立即对类进行初始化(而加载、验证、准备自然需要在此之前开始):
- 使用new关键字实例化对象的时候。读取或设置一个类的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候,以及调用一个雷的静态方法的时候。
- 对类进行反射调用的时候,如果累没有进行初始化,则需要先触发其初始化。
- 当初始化一个类时,发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
- 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()的方法的那个类),虚拟机会先初始化这个主类。
对于这四种情况会触发类进行初始化的场景,虚拟机规范中使用了一个很强类的限定词:“有且只有”,这四种场景中的行为被称为一个类进行主动引用。除此之外所有引用类的方式,都不会触发初始化,被称为被动引用。下面举三个例子来说明被动引用:
- 对于静态字段,只有直接定义这个字段的类才会被初始化,因此通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会出发在诶的初始化。至于是否要触发子类的加载和验证,取决于虚拟机的具体实现。
package org.fenixsoft.classloading;
/**
* 被动使用类字段演示一:
* 通过子类引用父类的静态字段,不会导致子类初始化
**/
public class SuperClass {
static {
System.out.println("SuperClass init!");
}
public static int value = 123;
}
public class SubClass extends SuperClass {
static {
System.out.println("SubClass init!");
}
}
/**
* 非主动使用类字段演示
**/
public class NotInitialization {
public static void main(String[] args) {
System.out.println(SubClass.value);
}
}
运行结果:
SuperClass init!
123
- 通过数组来定义引用类,不会触发此类的初始化,但是这段代码里触发了另一个名为“[Lorg.fenixsoft.classloading.SuperClass”的类的初始化阶段,对于用户代码来说,这并不是一个合法的类名称,他是一个由虚拟机自动生成的、直接继承于java.lang.Object的子类,创建动作由字节码指令newarray触发。这个类代表了一个元素类型为org.fenixsoft.classloading.SuperClass的一维数组,数组中应有的属性和方法(用户可直接使用的只有被修饰为public的length属性和clone()方法)都实现在这个类里。这个类包装了数组元素的访问方法(准确的说,越界检查并不是封装在数组元素访问的类中,而是封装在数组访问的xload、xastore字节码指令中)。检查到发生数组越界时就会抛出ArrayIndexOutOfBoundException异常。
package org.fenixsoft.classloading;
/**
* 被动使用类字段演示二:
* 通过数组定义来引用类,不会触发此类的初始化
**/
public class NotInitialization {
public static void main(String[] args) {
SuperClass[] sca = new SuperClass[10];
}
}
- 常量在编译阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。
package org.fenixsoft.classloading;
/**
* 被动使用类字段演示三:
* 常量在编译阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。
**/
public class ConstClass {
static {
System.out.println("ConstClass init!");
}
public static final String HELLOWORLD = "hello world";
}
/**
* 非主动使用类字段演示
**/
public class NotInitialization {
public static void main(String[] args) {
System.out.println(ConstClass.HELLOWORLD);
}
}
运行结果:
hello world
- 接口的加载过程与类的加载过程稍有一些不同:
- 接口中不能是使用static{}语句块,但编译期仍然会为接口生成类构造器,用于初始化接口脏哦能所定义的成员变量。
- 主要不同为初始化场景中的第三种:一个接口在初始化时,并不要求其父类接口全部都完成了初始化,只有在真正使用到父类接口的时候(如引用接口中定义的常量)才会初始化。
2. 类加载过程
- 加载
在类的加载加载阶段,虚拟机需要完成以下三件事:
1) 通过一个类的全限定名来获取定义此类的二进制字节流。
2) 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
3) 在java堆中生成一个代表这个类的java.lang.Class对象,作为方法区这些数据的访问入口。
通过一个类的全限定名来获取定义此类的二进制流,并没有指明二进制字节流要从一个Class文件中获取,准确的说根本没有指明要从哪里获取及怎么样获取。相对于类加载过程的其他阶段,加载阶段的获取类的二进制流是开发期可控性最强的阶段,因为加载阶段既可以使用系统提供的类加载器来完成,也可以由用户自定义的类加载器去完成。加载阶段与连接阶段的部分内若能(如一部分字节码文件格式的验证动作)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,但这些夹在加载之中进行的动作,仍然属于连接阶段的内容,这两个阶段仍然保持着固定的顺序。
- 验证
验证是连接阶段的第一步,目的是确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。该阶段的工作量在虚拟机的类加载子系统中占了很大一部分。大致会完成下面四个阶段的检验过程:文件格式验证、元数据验证、字节码验证和符号引用验证。
- 文件格式验证:验证字节流是否符合Class文件格式的规范,并且能被被当前的虚拟机处理。主要包括以下验证点:
- 是否以魔数0xcafebabe开头。
- 主次版本号是否在当前虚拟机处理范围之内
- 常量池的常量中是否有不支持常量类型(检查常量tag标志)
- 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量
- ……
- 这个阶段的验证是基于字节流进行的,经过了这个阶段的验证之后,字节流才会进入内存的方法区中进行存储,所以后面的三个验证是基于方法区的存储结构进行的。
- 元数据验证
是对字节码描述的信息进行语义分析,以保证其描述的信息符合java语言规范的要求,是对元数据信息中的数据类型做校验。可能包括以下验证:
- 这个类是否有父类(除了Object之外,所有的类都应该有父类)
- 这个类是否继承了不允许被继承的类(被final修饰的类)
- 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法。
- 类中的字段、方法是否与父类产生了矛盾(如覆盖了父类的final方法)。
- 字节码验证
是整个验证过程中最复杂的一个阶段,主要是进行数据流和控制流的分析。对类的方法体进行校验分析,保证类的方法在运行时不会做出危害虚拟机的行为:
- 保证任何时刻操作数栈的数据类型与指令代码序列都能配合工作,不会出现像这样的情况:在操作栈中放置了一个int类型的数据,使用时却按long类型来加载如本地变量表中。
- 保证跳转指令不会跳转到方法体以外的字节码指令上。
- 保证方法体中的类型转换是有效的。
- ……
- 如果一个类方法体的字节码没有通过自己诶嘛验证,那肯定是有问题的,但是如果通过了字节码验证,也不能说明其一定是安全的。
- 符号引用验证
发生在虚拟机将符号引用转化为直接引用的是偶,这个转化动作将在连接的第三个阶段–解析阶段中发生。符号引用验证可以看做是对类自身以外(常量池中的各种符号引用)的信息进行匹配性的校验,保证解析能够正常执行。通常有一下内容:
- 符号引用中通过字符串描述的全限定名是否能找到对应的类。
- 在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段。
- 符号引用中的类、字段和方法的访问性是否可被当前类访问。
- ……
- 验证阶段不一定是必要的阶段,如果所运行的全部代码都已经被反复使用和验证过,在实施阶段就可以考虑使用-Xverfify:none参数来关闭大部分的类验证措施,以缩短虚拟机的类加载时间。
- 文件格式验证:验证字节流是否符合Class文件格式的规范,并且能被被当前的虚拟机处理。主要包括以下验证点:
- 准备
是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中进行分配。注意两点:
- 这时候进行内存分配的仅包括类变量(static修饰的变量),而不包括实例变量,实例变量会在对象实例化时随着对象一起分配在java堆中。
- 这里所说的初始值通常是数据的零值。假设定义一个类变量:
public static int value = 123;
那么变量value在准备阶段过后的初始值为0,而不是123,因为这个时候并未开始执行java的任何方法,而把value赋值为123的putstatic指令是程序在编译后,存放于类构造器方法之中,所以把value赋值为123的动作在初始化阶段才会被执行。
如果类字段的字段属性表中存在ConstantValue属性,那么在准备阶段就会被初始化为ConstantValue属性所指定的值。假设:
public static final int value = 123;
编译时Javac将会为value生成Constant属性,在准备阶段虚拟机就会根据ConstantValue的设置将value赋值为123。
- 解析
是虚拟机将常量池内的符号引用替换为直接引用的过程。
- 符号引用:符号引用是一组以符号来描述所引用的目标,符号可以使任何形式的字面量,只要使用是能无歧义地定位到目标即可。符号引用于虚拟机实现的内存布局无关,引用的目标并不一定已经在加载到内存中。
- 直接引用:可以是直接指向目标的指针、相对偏移量或一个能间接定位到目标的句柄。直接引用是与虚拟机实现的内存布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经存在内存中。
解析阶段发生的具体时间,只要求在执行anewarray、checkcat、getfield、getstatic、instanceof、invokeinterface、invokespcial、invoketatic、invokecirtual、multianewarray、new、putfield和putstatic这13个用于操作符号引用的字节码指令之前,先对他们所使用的符号引用进行解析。
解析动作主要针对类或接口、字段、类方法、接口方法四类符号引用进行。
- 类或接口的解析
假设当前代码所处的类为D,如果要把一个从未解析过的符号引用N解析为一个类或接口C的直接引用,那虚拟机完成整个解析的过程需要包括以下3个步骤:
1) 如果C不是一个数组类型,那虚拟机将会把代表N的权限定名传递给D的类加载器去加载这个类C。在加载过程中,由于元数据验证、字节码验证的需要,又将可能触发其它相关类的加载动作,例如加载这个类的父类或实现的接口。一旦出现任何异常将宣告失败。
2) 如果C是一个数组类型,并且数组的元素类型为对象,也就是N描述符会类似”[Ljava.lang.Integer”的形式,那将会按照第1点的规则加载数组元素类型。如果N的描述符如前面所假设的形式,需要加载的元素类型就是“java.lang.Integer”,接着由虚拟机生成一个代表此数组维度和元素的数组对象。
3) 如果上面的步骤没有出现任何异常,那么C在虚拟机中实际上已经称为一个有效的类或接口了,但在解析完成之前还要进行符号引用验证,确认C是否具备对D的访问权限。如果发现不具备访问权限,将抛出IllegalAccessError异常。
- 字段解析
首先将会对字段表内class_index项中索引的CONSTANT_Class_info符号引用进行解析,也就是字段所属的类或接口的符号引用。如果在解析类或接口符号引用的过程中出现了任何异常,都会导致字段符号引用解析失败。如果解析成功,拿奖这个字段所属的类或接口用C表示,虚拟机规范要求按照如下步骤对C进行后续字段的搜索:
1) 如果C本身就包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
2) 否则,如果在C中实现了接口,将会按照继承关系从上往下递归搜索各个接口和它的父接口,如果找到则返回这个字段的直接引用,查找结束。
3) 否则,如果C不是Object话,将会按照继承关系从上往下递归搜索其父类,如果在父类中包含,则查找结束。
4) 否则查找失败。抛出NoSuchFieldError异常。
如果有一个同名字段同时出现的C的接口和父类中,或者同时出现在自己或父类的多个接口中出现,那边一起将拒绝编译。
- 类方法解析
首先解析出所属的类,如果解析成功,用C表示该类,虚拟机会进行如下步骤:
1) 类方法和接口方法符号引用的常量类型定义是分开的,如果在类方法表中发现class_index中索引的C是个接口,直接抛异常。
2) 在类C中查找是否有简单名称和描述符斗鱼目标相匹配的方法,如果有则返回这个方法的直接引用,结束。
3) 否则,在类C的父类中查找。
4) 在类C实现的接口列表及他们的父接口之中查找是否有简单名称和描述符都与目标相匹配的方法,如果匹配,说明类是一个抽象类,抛出异常。
5) 否则,抛出NoSuchMethodError。
最后进行权限验证,如果不具备权限,抛出非法访问异常。
- 接口方法解析
解析所属的类或接口的符号引用,用C表示这个接口:
1) 如果在接口方法表中发现class_index项中的索引C是个类而不是接口,那就直接跑异常。
2) 否在在C中查找方法。
3) 否则在C的父接口中递归查找直到Object类。
4) 否则宣告失败。
由于接口中的所有方法都是默认是public的,所以不存在访问权限问题。
- 初始化
是类加载的最后一个阶段,前面的类加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。到了初始化阶段,才真正执行类中定义的Java程序代码(或者说字节码)
程序初始化阶段是执行类构造器方法的过程。
- 类构造器方法是由编译器自动收集类中所哟类变量的赋值动作和静态语句块中的语句合并产生的。编译器收集的顺序一定实现变量赋值在静态语句块,因此静态语句块可以访问到类变量的初始值。
- 类构造器方法与类的构造函数不同,它不需要显示的调用父类构造器,虚拟机会保证在子类的类构造方法执行之前,父类的类构造方法已经执行完毕。因此虚拟机中第一个被执行的类构造方法是Object。
- 由于父类的类构造器方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作。
- 类构造器方法对鱼类或接口来说并不是必须的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成类构造器方法。
- 接口中不能使用静态语句块,但仍有变量初始化的赋值操作,因此接口与类一样会生成类构造方法。但与类不同的是,执行接口的类构造方法,不需要先执行父类接口的类构造方法,另外接口的实现类在初始化时也一样不会执行接口的类构造方法。
- 虚拟机会保证一个类的类构造方法在多线程环境中被正确的加锁和同步。
3. 类加载器
- 类与类的加载器
比较两个类是否相等,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类是来源于同一个Class文件,只要加载他们的类加载器不同,那这两个类就必定不相等。所谓的“相等”指:代表类的Class对象的equals()方法、isAssignableFrom()方法、isInstance()方法也包括使用instanceof关键字作对象所属关系判断等情况。
/**
* 类加载器与instanceof关键字演示
*
* @author zzm
*/
public class ClassLoaderTest {
public static void main(String[] args) throws Exception {
ClassLoader myLoader = new ClassLoader() {
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
try {
String fileName = name.substring(name.lastIndexOf(".") + 1) + ".class";
InputStream is = getClass().getResourceAsStream(fileName);
if (is == null) {
return super.loadClass(name);
}
byte[] b = new byte[is.available()];
is.read(b);
return defineClass(name, b, 0, b.length);
} catch (IOException e) {
throw new ClassNotFoundException(name);
}
}
};
Object obj = myLoader.loadClass("org.fenixsoft.classloading.ClassLoaderTest").newInstance();
System.out.println(obj.getClass());
System.out.println(obj instanceof org.fenixsoft.classloading.ClassLoaderTest);
}
}
运行结果:
class org.fenixsoft.classloading.ClassLoaderTest
false
由结果可以看出,检查所属类型时,返回了false,原因是在虚拟机中存在了两个ClassLoaderTest类,一个是由系统应用程序类加载器加载的,另外一个是由我们自定义的类加载器加载的,虽然都来同一个Class文件,但依然是两个独立的类。
- 双亲委派模型
站在Java虚拟机的角度讲,只存在两种不同的类加载器:一种是启动类加载器,这个类加载器使用C++语言实现,是虚拟机自身的一部分;另外一种就是所有其他的类加载器,这些类加载器都由Java语言实现,独立于虚拟机外部,并且全部继承自抽象类ClassLoader。
从Java开发人员的角度看,类加载器还可以分的更细致一点,绝大部分Java程序都会使用到一下三种系统提供的类加载器:
- 启动类加载器(Bootstrap ClassLoader):负责将存放在<\JAVA_HOME>\lib\ext目录中的,或者被-Xbootclasspath参数所指定的路径中的,并且是虚拟机识别的(按照文件名识别,如rt.jar,名字不符合的类库即使放在目录下也不会加载)类库加载到虚拟机内存中。启动类加载器无法被Java程序直接引用。
- 扩展类加载器(Extension ClassLoader):这个加载器由sun.misc.Launcher$ExtClassLoader实现,他负责加载<\JAVA_HOME>\lib\ext目录中的,或者被java.ext.dirs紫铜变量所指定的路径中所有类库,开发者可以直接使用扩展类加载器。
- 应用程序类加载器(Application ClassLoader):这个类加载器是由sun.misc.Launcher$AppClassLoader)来实现。由于这个类加载器是ClassLoader中getSystemClassLoader()方法的返回值,所以一般也称它为系统类加载器。他负责加载用户路径(ClassPath)上所指定的类库,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
类加载器之间的关系:
java中采用双亲委派模型(Parents Delegation Model)来实现类的加载模式。双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器,此处的父子关系不以继承来实现,而是采用组合来利用父加载器。
双亲委派模型的工作过程:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会自己去加载。
双亲委派模型的实现:
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 {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 如果父类加载器抛出ClassNotFoundException
// 则说明父类加载器无法完成加载请求
}
if (c == null) {
// 父类加载器无法加载对象的时候
// 在调用本身的findClass方法来进行类的加载
long t1 = System.nanoTime();
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}