一、概述
JAVA虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的JAVA类型,这个过程被称作虚拟机的类加载机制。每个Class文件都代表着一个类或者接口的可能,而Class文件只是用来承载最终转化成机器能够理解的二进制流的载体而已,“Class文件”可以是以,磁盘文件、网络、数据库、内存或者动态产生都可以。
二、类加载的时机
一个类从被加载到虚拟机内存到 卸载出内存,它的整个生命周期如一下:加载、验证、准备、解析、初始化、使用、卸载。其中验证、准备、解析统称为连接。《JAVA虚拟机规范》中严格强调有且只有以下6种情况必须立即对类进行“初始化”:
- 遇到new、getstatic、putstatic、invokestatic这四条指令时,如果类型没有进行过初始化,则需要先触发其初始化阶段,能够生成这四条指令的典型代码场景有:
a. 使用new关键字创建对象 b. 读取或设置一个类型的静态字段 c. 调用一个类型的静态方法 - 使用java.lang.reflect包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则要先触发初始化
- 当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先初始化其父类
- 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类
- 当使用JDK7新加入的动态语言支持时(这个我也没有弄懂 - - !!!)
- 当一个接口中定义了JDK8新加入的默认方法,如果这个接口的实现类发生了初始化,那么该接口要在其之前被初始化
以上的这6种行为称之为对类型的主动引用,除此之外,所有的引用类型的方式都不会触发初始化,称之为被动引用。举三个例子:
例子 一:
/**
* 父类
* 通过子类引用父类的静态字段,不会导致子类初始化
*/
public class SuperClass {
public static int a = 10;
static {
System.out.println("Super class printed");
}
}
/**
* 子类
*/
public class SubClass extends SuperClass{
static {
System.out.println("Sub class printed");
}
}
/**
* 测试类
*/
public class Test {
public static void main(String[] args) {
System.out.println(SubClass.a);
}
}
//最终打印出来的结果为:
Super class printed
10
上述代码运行后,只会打印出来“Super class printed”,及其对应的变量数值,是因为,对于静态字段,只有直接定义这个字段的类才会被初始化,因此通过其子类来引用父类的静态字段,只会触发父类的初始化,而不会触发子类的初始化。
例子二:
/**
* 通过数组定义来引用类,不会触发此类的初始化
*/
public class ArrClass {
public static void main(String[] args) {
SuperClass[] arr = new SuperClass[10];
}
}
运行后并没有打印出数据,说明没有触发SuperClass的初始化,但并不能说明没有类被初始化,虚拟机会自动生成一个直接继承Object的子类(Lorg.xxx.xxx.xxx.SuperClass),创建动作由newarray字节码指令触发,这个子类代表了一个类型元素为org.xxx.xxx.xxx.SuperClass的一堆数组,数组中应有的属性和方法的实现都在这个类里面。
例子三:
/**
* 常量在编译阶段会存入类的常量池中,
* 本质上没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化
*/
public class ConstClass {
static {
System.out.println("ConstClass printed");
}
public static final String str ="Hello、World";
}
/**
* 测试类
*/
public class Test {
public static void main(String[] args) {
System.out.println(ConstClass.str);
}
}
上述代码运行后,并没有输出“Hello、World”,这是因为放在编译阶段通过常量传入的“Hello World”,已经直接被存储在Test这个类的常量池里面了,此后Test对常量ConstClass.str的引用,实际都被转化为Test类对自身常量池的引用了,也就是说,实际上Test的Class文件中并没有ConstClass类的符号引用入口,在两个类编译完成Class文件之后就不存在任何联系了。
接口的加载过程和类的加载过程稍有不同,真正的区别在于:类的初始化时,要求其父类全部都已经被初始化过了,但是一个接口在初始化的过程当中,并不要求其父类接口全部都要完成初始化。
三、类加载的过程
3.1 加载
加载阶段,虚拟机会完成以下三件事:
- 通过一个类的全限定名来获取定义此类的二进制字节流
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
- 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口
针对第一点《JAVA虚拟机规范》并没指明二进制流必须从某个Class文件里面获取,因此仅仅通过这一个空隙,后续边诞生了多种不同读取字节流的方式:
a.从zip压缩包中读取,最终成为日后的jar包、war包等格式
b.运行时计算生成,这种场景使用的最多的就是动态代理技术,在java.lang.reflect.Proxy中,就是用了ProxyGenerator.generateProxyClass()来为特定的接口生成形式为“*$Proxy”的代理类的二进制字节流。
c.从加密的文件中获取,这是典型防Class文件被反编译的保护措施,通过加载时解密Class文件来保证程序运行逻辑不被偷窥。
3.2 验证
3.2.1 概念:
验证阶段的目的是确保Class文件的字节流中包含的信息符合《JAVA虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。
这边的校验并非指编译器对java文件编译成class文件的校验(虽然能一定程度阻止有问题的代码,比如说引用不存在的类等等)。但是虚拟机实际读取的是字节码文件,现在有很多手段绕过编译器生成字节码,因此JAVA虚拟机需要检查输入的字节流,否则如果载入了有错误或者恶意的字节码流后果将不堪设想。
验证阶段是一个非常重要的,但并不是一个必须执行的阶段,因为它只有通过或者不通过的差别。如果程序运行的全部代码已经被反复使用和验证过了,其实就可以考虑关闭大部分类的类验证措施,以缩短虚拟机类加载的时间,可以通过 -Xverify:none 参数设置。
目前虚拟机字节码验证分为以下四个阶段:
- 文件格式验证:
校验字节流是否符合Class文件格式的规范,并且能否被当前虚拟机理解,只有通过这个阶段,字节流才允许进入JAVA虚拟机内存的方法区中存储。 - 元数据验证:
对字节流描述的信息进行语义分析,确保符合《JAVA语言规范》的要求,比如:1.这个类是否有父类(至少有个Obejct父类);2.如果这个类implement了某个接口,那么该类是否有实现其接口方法。 - 字节码验证
这个阶段是整个过程最复杂的,确定程序语义上合法的、符合逻辑的。这个阶段主要是对方法体进行校验分析,保证校验类的方法在运行中不会出现危害虚拟机安全的行为,只有符合 JVM 字节码规范的文件才能被 JVM 正确执行。例如:JVM 会对代码组成的数据流和控制流进行校验,确保 JVM 运行该字节码文件后不会出现致命错误。例如一个方法要求传入 int 类型的参数,但是使用它的时候却传入了一个 String 类型的参数。一个方法要求返回 String 类型的结果,但是最后却没有返回结果。代码中引用了一个名为 Apple 的类,但是你实际上却没有定义 Apple 类(当然这些如果是通过IDEA编辑的话,在编译器肯定是会报错的,这边讨论的是生成后的字节码阶段,已经绕过了编译期)。 - 符号引用验证:
可以看作是对类自身以外的各类信息进行匹配校验,通俗来说就是,该类是否缺少或者被禁止访问它依赖的某些外部类、方法、字段等资源,通常需要校验的有以下:1.符号引用中通过字符串描述的全限定类名是否能够找到对应的类;2.在指定的类中是否存在对应的方法或者字段;3.符号引用中的类、字段、方法是否能够被访问到(private、protected、ppublic),如果无法通过符号引用一般会抛出诸如:IllegalAccessError、NoSuchFieldError、NoSuchMethodError异常。
符号引用校验其实是为后续的解析做准备,真正将符号引用转化为直接引用的这个阶段发生在“解析阶段”。
3.3 准备
为类中的类变量(被static修饰过的为类变量,否则为实例变量),分配内存并设置类变量的初始值,JDK7及以前是分配在方法区里,JDK8及之后则分配在堆里面。准备阶段内存分配不包括实例变量,实例变量会在对象实例化随着对象一起分配在堆内存中。
public class Test {
private static int a = 12; //类变量
private static final int b = 13; //类变量
private int c = 14; //实例变量
}
例如类变量a在准备阶段仅仅是被初始化为0值(如果是引用类型的话,则是null值),而把a复制为12的动作需要等到初始化阶段才会被执行,但是如果类变量被final修饰过,例如类变量b,那么准备阶段就会直接给赋值成13。
3.4 解析
该阶段是将符号引用替换为直接引用的过程,以下是对二者的定义:
- 符号引用:用一组符号来描述所引用的目标,作用是准确地定位到引用地目标。符号引用和虚拟机实现的内存布局无关,引用的目标不一定是已经加载到虚拟内存当中的内容。在Java中,一个java类将会编译成一个class文件。在编译时,java类并不知道所引用的类的实际地址,因此只能使用符号引用来代替。比如org.simple.People类引用了org.simple.Language类,在编译时People类并不知道Language类的实际内存地址,因此只能使用符号org.simple.Language**(假设是这个,当然实际中是由常量池中的表CONSTANT_Class_info来表示的,CONSTANT_Class_info存放的是该类下需要引用到其他类的全限定名常量)**来表示Language类的地址。
- 直接引用:直接引用可以是一个直接指向目标的指针,它和虚拟机实现的内存布局直接相关。如果有了直接引用,那引用的目标必须已经在虚拟机内存中存在的。解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄、调用点限定符这7类符号引用进行。
针对前四种进行说明下:
1.类或接口解析
设当前类型为D,如果要把一个从未解析过的符号引用N解析成一个类或接口C的的直接引用,虚拟机完成整个解析过程需要三个步骤:
a.如果C不是一个数组类型,虚拟机会通过符号引用中的信息,将全限定类名传递给D的类加载器去加载类C,加载过程当中,又会触发其他相关类的加载动作,例如类C有又会去加载父类,或者实现的接口。一旦这个过程当中出现任何异常,解析过程就宣告失败。
b.如果C类是一个数组,并且元素为引用类型,那么会先按照第a点的规则加载数组元素类型,接着由虚拟机生成一个代表该数组的Class对象。(一般是在全限定类名前面加L,例如Integer[],由虚拟机生成的对象为Ljava/lang/Integer)。
c.如果上面两部已没有任何异常,继续对其访问权限的校验,比如对当前类D是否有对类C的访问权限(如果一个类不用public来修饰,那么这个类仅能被同包的其他类引用),如果发现不具备访问权限,就会抛出IllegalAccessError异常。
2.字段解析
如果该字段未被解析过,首先会在常量池中的表CONSTANT_FIELDREF_info里面找到对应的CONSTANT_Class_info符号引用进行解析,也就是该字段所示的类或接口的符号引用。如果在解析这个类或接口符号引用过程中出现问题,都会导致字段符号引用解析的失败。解析完成后,还需要按照以下步骤对C进行后续的字段搜索:
a.如果C本身包含的字段能与目标相匹配,则返回这个字段的直接引用。
b.否则,在C中实现的接口中,由下而上递归搜素各个接口,如果在接口中能找到与之相匹配的字段,则返回这个字段的直接引用。
c.否则 ,如果C不是Obejc的话,按照继承关系,由下而上递归搜索其父类,如果能找到与之匹配的字段,则返回这个字段的直接引用。
d.否则,抛出NoSuchFieldError异常
3.类方法解析
方法解析第一个步骤和字段解析一样,先解析常量池里面的方法表CONSTANT_Methodref_info所属的类或接口的符号引用,如果解析成功,那么会按照以下步骤对方法进行搜索,我们依旧用C表示所属方法的类:
a.Class文件中类的方法和接口的方法符号引用的常量类型定义是分开的,如果在类的方法表中发现对应C类方法是个接口的话,那就抛出IncompatibleClassChangeError异常。
b.如果通过第一步,则在C类中查找是否有方法名与之相匹配的方法,如果有则直接返回这个方法的直接引用。
c.否则,在C类的父类中递归查找所有能够匹配的方法,如果有则直接返回这个方法的直接引用。
d.否则,在C类实现的接口及其继承的接口中递归寻找与之匹配的方法,如果匹配说明C类是一个抽象类,并抛出AbstractMethodError。
e.否则,宣告查找方法失败,抛出NoSuchMethodError。
4.接口方法解析
接口方法解析和类方法解析类似,不再赘述。
3.5 初始化
3.5.1 概念
类的初始化阶段是类加载过程的最后一个阶段,直到这个阶段,JAVA虚拟机才真正开始执行类中编写的JAVA代码程序,将主导权移交给应用程序。
在准备阶段,变量已经进行过一次初始零值的赋值操作,在初始化阶段,会根据程序员的主观计划去初始化类变量和其他资源。而初始化阶段是执行类构造器
3.5.2 clinit()方法定义:
- clinit方法是由编译器自动收集类中所有的类变量的赋值动作和静态代码块中的语句合并产生的。也就是说如果一个类中,没有对类变量进行赋值操作或者没有静态代码块,那么编译器可以不为这个类生成
()方法。 - 编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态代码块只能访问到定义在它之前的,而定义在之后的变量不能访问,但可以赋值。
public class Parent {
static {
a =2; //可以赋值
System.out.println(a); //不能访问
}
public static int a =1;
}
- 类构造器
()和实例构造器 ()之间的区别
a.出现的时间顺序不用,clinit方法一定会比init方法先执行,因为类的加载一定优先于类的实例化,可以这么认为类的加载完后会生成一个对应的class对象放入到共享的方法区,后续的new出来的实例对象,其实都是拿着这个class对象模板来创建的。
b.因为构造器加载的顺序不同,导致文件里的资源访问的顺序不同,即:实例变量或者是实例方法可以访问类变量或类方法,反之则编译报错 ,其根本原因是两者的初始化顺序不同,类变量和类方法在类加载的时候就已经初始化好了,而实例变量和实例方法得等到创建实例的时候才初始化好。一个已初始化好的类变量怎么能够去访问还未初始化的实例变量呢?
public class StaticClass {
private static int a = 100;
private int b = a; //实例变量可以访问类变量
private static int x = b; //类变量不能访问实例变量,编译报错
private static void staticMethod(){
instanceMethod(); //类方法不能访问实例方法,编译报错
}
private void instanceMethod(){
staticMethod(); //实例方法可以访问类方法
}
}
c.init方法会显示的调用父类构造器,而clinit不用,这是因为在初始化之前,在解析阶段就会将继承的父类加载好,而init方法执行的时候,假如有继承的父类,那么会先去执行父类的init方法。具体如下图:
//父类
public class Parent {
static {
System.out.println("父类类加载完成");
}
public Parent() {
System.out.println("父类构造器执行");
}
public Parent(int a) {
System.out.println("父类构造器执行");
}
}
//子类
public class Son extends Parent{
static {
System.out.println("子类类加载完成");
}
public Son() {
// super(); //如果父类有默认的无参构造函数,则这边可不写
super(1);//如果父类无默认的构造函数,
// 那么必须声明调用父类构造函数,并且是放在方法函数的第一行
System.out.println("子类构造器执行");
}
}
以上的打印顺序为:
父类类加载完成
子类类加载完成
父类构造器执行
子类构造器执行
- 接口中不能使用静态代码块,但仍然有变量初始化的赋值操作,因此接口也可以生成clinit方法,但是接口与类不同的是,执行clinit方法的时候不需要去先执行父接口的clinit方法,除非是需要加载父接口,因为只有当父接口中定义的变量被使用时,父类接口才会被初始化。此外接口的实现类在初始化的时候,也不会去执行接口的clinit方法,即不会去加载接口,除非是接口的变量在子类中有被使用到。
- JAVA虚拟机在加载类的时候,必须保证,只有一个线程执行clinit方法,即,在多线程环境中,clinit方法会被加锁同步,如果有多个线程同时去初始化这个类,那么只会有一个线程去执行clinit方法,其他线程都需要阻塞等待,知道活动线程执行完毕
方法。如果有一个类的clinit方法耗时很长的操作,那就可能造成多个线程阻塞,这也就是为什么很多大型项目在上线前,为什么都需要预先做一下热启动的方案,这是因为在热启动阶段通过较少的线程去将整个运行程序所需要的类都加载完毕,避免在生产环境并发量很高的时候,导致线程阻塞,将服务压垮。
PS:需要注意,其他线程虽然会阻塞,但如果执行clinit方法的那条线程退出clinit方法后,其他线程唤醒后,不会再次进入clinit方法。同个类加载器下,一个类型只会被初始化一次。