以前在介绍JVM内存模型的时候(参看:JVM内存模型),提到了在运行时数据区以前,有个Class Loader,这个就是类加载器。用以把Class文件中的描述信息加载到内存中运行和使用。如下是《深刻理解Java虚拟机第二版》对类加载器机制的定义原文:java
虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终造成能够被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。node
通常咱们把类从加载到内存到卸载出内存的整个过程分为七个阶段:加载,验证,准备,解析,初始化,使用和卸载。其中,验证、准备和解析统称为链接。数据库
在这几个阶段中,加载、验证、准备、初始化和卸载这五个阶段的顺序是固定的,而解析阶段则不必定,它有时候可能会在初始化以后开始,这是为了支持Java的运行时绑定。须要特别注意的是,这里边的顺序指的是按顺序开始,而不是按顺序进行或完成,由于这些阶段一般会互相交叉的混合进行。数组
了解类的加载机制很是有必要,下面将逐个解释说明类加载的全过程(即加载,验证,准备,解析,初始化五个阶段)。相信看完以后,你会对Java类某些问题有更深入的理解(例如,为何子类能够覆盖父类的字段和方法?饿汉式单例为何天生是线程安全的?)安全
加载
加载过程分为三步:网络
1)经过一个类的全限定名来获取定义此类的二进制字节流。数据结构
2)将这个字节流所表明的的静态存储结构转化为方法区的运行时数据结构。多线程
3)在内存中生成一个表明这个类的Class对象,做为方法区这个类的各类数据的访问入口。jsp
上面的第一步获取二进制字节流,并无限定只能从编译好的.class文件中获取,也能够是zip包,jar,war,网络流(Applet),运行时计算生成(如动态代理,经过反射在运行时动态生成代理类),其余文件(如jsp,因jsp最终会编译成class),数据库(用的场景较少)。工具
对于数组类的加载,和普通类的加载有所不一样。数组类自己不经过类加载器加载,而是由虚拟机直接完成。可是数组类的元素类型(指数组类去除维度以后的类型,如String[] 数组的元素类型就是 String)是靠类加载器加载的。
加载阶段完成以后,虚拟机就会把外部的二进制字节流(不论从何处获取的)按照必定的数据格式存储在运行时数据区中的方法区。而后在内存中实例化一个java.lang.Class对象(Class这个对象比较特殊,它存放在方法区中而不是堆中),这个对象将做为程序访问方法区中的这些数据的外部接口。
验证
验证是链接阶段的第一步,这一阶段的主要目的就是确保Class文件流中的信息符合虚拟机的规范,而且不会危害虚拟机的安全。验证阶段通常分为四个阶段:文件格式验证,元数据验证,字节码验证和符号引用验证。
1)文件格式验证
第一阶段要验证二进制字节流是否符合Class文件格式的规范,确保能被虚拟机处理。主要包括如下验证点:
- 是否以魔数 0xCAFEBABE 开头。(每一个Class文件的头4个字节称为魔数,是一个16进制的固定值,它的做用就是确保这个Class文件能被虚拟机接受)
- 主、次版本号是否在当前虚拟机的处理范围中(紧接着魔数后面的第5,6字节表明次版本号,第7,8字节表明主版本号)。
- 常量池中的常量是否有不被支持的常量类型(依据常量的tag值)。
等等,还有其余不少验证,再也不一一说明。这一阶段的验证主要是针对二进制字节流进行的,验证完成以后,字节流会进入内存中的方法区进行存储。因此后面的三个验证阶段再也不直接操做二进制字节流。
2)元数据验证
第二阶段是对字节码描述的信息进行语义分析,保证其描述的信息符合Java语言规范。主要包括如下验证点:
- 这个类是否有父类(除了Object类,全部类都应该有父类)。
- 这个类是否继承了不容许被继承的类(被final修饰的类不可被继承)。
- 是否实现了其父类或接口要求实现的全部方法。
- 类中的字段、方法是否与父类产生矛盾(如覆盖了父类的final字段,或者重写、重载不符合规范)。
3)字节码验证
第三阶段主要是对类的方法体进行验证,确保程序语义是合法的、符合逻辑的。
- 保证数据的定义和使用相匹配,如定义int类型数据,使用时不能以long型操做。
- 保证跳转指令不会跳转到方法体之外的字节码指令上。
- 保证方法体中的类型转换是有效的。如能够把子类对象赋值给父类引用,可是父类不能够直接赋值给子类(必须强转)或其余不相干的类型。
4)符号引用验证
最后一个阶段的验证发生在符号引用转换为直接引用的时候。实际的转换动做,发生在后面的解析阶段。主要对类自身之外的信息(常量池中的各类符号引用)进行匹配性的校验。
验证阶段是很是重要可是非必要的一个阶段。若是确保代码对程序运行期没有影响,则能够经过 -Xverify:node 参数关闭大部分的验证,以缩短类加载的总时间。
准备
准备阶段是类变量分配内存并设置初始值的阶段。这里的类变量指的是被static修饰的变量,而不包括实例变量。类变量被分配到方法区中,而实例变量存放在堆中。
这里的初始值指的是数据类型的默认值,而不是代码中所赋的值。例如
public static int value = 1 ;
在准备阶段以后,value值为0,而不是1。赋值为1的动做发生在初始化阶段。
可是,也要特殊状况,若是变量被static 和 final同时修饰,则准备阶段直接赋值为指定值。如
public static int value = 1 ;
在准备阶段以后,value的值即为1.
各数据类型的初始默认值以下:
数据类型 | 默认值 |
---|---|
short | (short)0 |
int | 0 |
long | 0L |
float | 0.0f |
double | 0.0d |
char | '\u0000' |
byte | byte(0) |
boolean | false |
reference | null |
解析
解析阶段是将常量池中的符号引用转换为直接引用的过程。那什么是符号引用和直接引用呢?
符号引用是用一组符号来描述所引用的目标,符号能够是任何形式的字面量,只要使用时能无歧义的定位到目标便可(前面JVM的模型中,也提到了符号引用,它存在于常量池中,包括类和接口的全限定名、字段的名称和描述符、方法的名称和描述符)。看概念可能比较抽象,能够理解为它就是一个代号,就像你有一个大名,同时也有一个小名,可是无论怎么叫指代的都是你本人。
直接引用能够是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。
解析动做主要针对类或接口、字段、类方法、接口方法、方法属性、方法句柄、调用点限定符7类符号引用。此处分别介绍一下前四种的解析过程。
1)类或接口的解析
若是类C不是数组类型,那么虚拟机会把类C直接传给类加载器。若是类C是数组类型而且元素类型是对象(如String[]),那么先用类加载器加载元素类型(String类型),再由虚拟机建立表明此数组维度和元素的数组对象。判断调用类是否有权限访问被加载类,若是不容许的话,就抛出IllegalAccessError异常。
2)字段的解析
首先解析字段所属的类或接口的符号引用。若是类中有字段的符号引用(字段的名称和描述符)和目标字段相匹配,则返回这个字段的直接引用。若是没有,则自下而上查找其实现的接口和父接口,若匹配到,则返回这个字段的直接引用。若是尚未,就自下而上查找其继承的父类,若匹配到,则返回这个字段的直接引用。不然,查找失败,抛出NoSuchFieldError异常。最后若是查找成功的话,会判断字段访问权限,若是该字段不容许访问,则抛出 IllegalAccessError异常。
这么一大段,若是乍看没明白,下面用代码解释一下就懂了。
public class ResolveTest { public static void main(String[] args) { System.out.println(Child.a); } interface Interface0 { int a = 0; } static class Parent { static int a = 1; } //① static class Child { static int a = 2; } //① }
好比,我去查找类Child中的a字段,目前来看能够直接查到,就是a=2。若是我把①所包围的代码修改成
static class Child implements Interface0 { }
则表示在本类中找不到a字段,所以去Child类实现的接口Interface0中查找,因而,成功找到 a=0。
再次把①代码修改成
static class Child extends Parent { }
本类找不到a,则去它的父类查找,因而查找成功,a=1。
那么聪明的同窗可能想到了,若是我修改代码为既继承父类又实现接口会怎么样呢?
static class Child extends Parent implements Interface0 { }
这样是不行的,编译器会拒绝编译。其实,想一下,就能明白,这个时候Child应该取父类中字段的值仍是接口中字段的值呢,编译器是不知道的,因此不能编译。其实,若是是在编译期,代码开发工具会给一条这样的报错信息:Reference to 'a' is ambiguous, both 'Parent.a' and 'Interface0.a' match.
若是强制执行这段代码,控制台则会报错以下信息:
思考一下,若是,我非要既继承父类又实现接口,应该怎样修改代码才能编译经过呢?
3)类方法解析
类方法解析第一步同字段解析同样,也须要先解析方法所属的类或接口的符号引用。类方法和接口方法符号引用的常量类型是分开的。若是,在类方法中解析出来的是一个接口,则会抛出 IncompatibleClassChangeError 异常。若是在类中有方法的符号引用(方法的名称和描述符)和目标方法相匹配,则返回这个方法的直接引用,查找结束。不然,在类的父类中递归查找,若找到则返回,查找结束。不然,查找它实现的接口和父接口,若是找到,说明此类是一个抽象类,抛出 AbstractMethodError异常。若都找不到,就抛出NoSuchMethodError 异常。最后,若是查找成功,会判断此方法是否有访问权限,若没有,则抛出 IllegalAccessError异常。
下面经过代码解释:
public class ResolveTest2 { public static void main(String[] args) { Child child = new Child(); child.method0(); } interface Interface0 { void method0(); } static class Parent { void method0(){ System.out.println("parent method0"); } } //② static class Child extends Parent { void method0(){ System.out.println("child method0"); } } //② }
②中,若是当前类Child中有method0方法,则直接返回此方法,打印结果child method0。若把Child中的method0方法注释掉,则会去找父类Parent的method0,打印结果 parent method0 。最后一点,若是类是实现了接口Interface0,并在接口中找到了method0方法,则说明Child类必定是抽象类。由于,只有抽象类才能够选择不重写接口的抽象方法。若是不是抽象类,则须要实现接口的所有方法,此时就能够直接在当前Child类中找到method0方法,而没必要去接口中查找方法了。
//必须是抽象类,不然,须要实现接口的所有方法 static abstract class Child implements Interface0 { }
4)接口方法的解析
首先解析方法所属的类或接口的符号引用,和类方法解析同理,若是发现解析出来是一个类方法,则会抛出 IncompatibleClassChangeError 异常。若是所属接口中匹配到目标方法,则返回此方法的直接引用。不然,在父接口中查找,若找到,则返回。不然,查找失败,抛出 NoSuchMethodError 异常。因为接口的方法都是public的,因此不存在访问权限的问题。
初始化
这是类加载的最后一步,到这才真正开始执行Java代码。在准备阶段,已经为类变量分配内存,并赋值了默认值。在初始阶段,则能够根据须要来赋值了。能够说,初始化阶段是执行类构造器 < clinit > 方法的过程。
首先说下类构造器 < clinit > 方法和实例构造器 < init > 方法有什么区别。< clinit > 方法是在类加载的初始化阶段执行,是对静态变量、静态代码块进行的初始化。而< init > 方法是new一个对象,即调用类的 constructor方法时才会执行,是对非静态变量进行的初始化。
类构造器方法有以下特色:
- 保证父类的 < clinit > 方法执行完毕,再执行子类的 < clinit > 方法。
- 因为父类的 < clinit > 方法先执行,因此父类的静态代码块也优于子类执行。
- 若是类中没有静态代码块,也没有为变量赋值,则能够不生成 < clinit > 方法。
- 执行接口的 < clinit > 方法时,不须要先执行父接口的 < clinit > 方法。只有父接口中定义的变量使用时,父接口才会初始化。另外,接口的实现类在初始化时也不执行接口的 < clinit > 方法。
- 虚拟机会保证在多线程环境下 < clinit > 方法能被正确的加锁、同步。若是有多个线程同时请求加载一个类,那么只会有一个线程去执行这个类的 < clinit > 方法,其余线程都会阻塞,直到方法执行完毕。同时,其余线程也不会再去执行 < clinit > 方法了。这就保证了同一个类加载器下,一个类只会初始化一次。(这也是为何说饿汉式单例模式是线程安全的,由于类只会加载一次。)
类的初始化时机:只有对类主动使用的时候才会触发初始化,主动使用的场景以下:
- 使用new关键词建立对象时,访问某个类的静态变量或给静态变量赋值时,调用类的静态方法时。
- 反射调用时,会触发类的初始化(如Class.forName())
- 初始化一个类的时候,如其父类未初始化,则会先触发父类的初始化。
- 虚拟机启动时,会先初始化主类(即包含main方法的类)。
另外,也有些场景并不会触发类的初始化:
- 经过子类调用父类的静态变量,只会触发父类的初始化,而不会触发子类的初始化(由于,对于静态变量,只有直接定义这个变量的类才会初始化)。
- 经过数组来建立对象不会触发此类的初始化。(如定义一个自定义的Person[] 数组,不会触发Person类的初始化)
- 经过调用静态常量(即static final修饰的变量),并不会触发此类的初始化。由于,在编译阶段,就已经把final修饰的变量放到常量池中了,本质上并无直接引用到定义常量的类,所以不会触发类的初始化。
原文首发地址: 类加载机制你真的了解吗? 文末可获取《深刻理解Java虚拟机第二版》pdf电子书,及JVM视频