虚拟机类加载机制

虚拟机类加载机制

虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验,转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制

与编译时需要进行连接工作的语言不同,Java语言里,类型的加载,连接和初始化过程都是在程序运行期间完成的

类加载的时机

类从被加载到虚拟机内存开始,到卸载出内存为止,整个生命周期包括:加载,验证,准备,解析,初始化,使用和卸载 7个阶段,其中验证,准备,解析 3个部分统称为连接,发生顺序如下图所示

加载,验证,准备,初始化和卸载这5个阶段的顺序是确定的,类加载过程必须按照这种顺序按部就班的开始(这些阶段都是互相交叉混合进行的,通常会在一个阶段执行的过程中调用,激活另一阶段),而解析阶段则不一定,它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定

对于什么时候开始加载阶段,Java虚拟机规范中并没有强制约束,可以交给虚拟机具体实现来自由把握,而对于初始化阶段,虚拟机规范严格规定有且只有5种情况必须立即对类进行初始化(加载,验证,准备要在初始化之前开始):

  • 遇到new,getstatic,putstatic或invokestatic这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这4条指令最常见的Java代码场景是:使用new关键字实例化对象的时候,读取或设置一个类的静态字段(被final修饰,已在编译期把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候
  • 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化
  • 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化
  • 虚拟机启动时,用户需要指定一个要执行的主类(main()方法那个类),虚拟机会先初始化这个主类
  • 动态语言支持时,如果java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic,REF_putStatic,REF_invokeStatic的方法句柄并且这个句柄所对应的类没有初始化,则需要先触发其初始化

这5个场景会触发类进行初始化的场景,它们的行为称为对一个类进行主动引用,所有其他场景都不会触发初始化,称为被动引用,如下面例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 通过子类引用父类的静态字段,不会导致子类的初始化
public class SuperClass {

static {
System.out.println("SuperClass init!");
}
public static int superValue = 123;
}

public class SubClass extends SuperClass {

static {
System.out.println("SubClass init!");
}
}

public class test {

public static void main(String[] args) {
System.out.println(SubClass.superValue);
}
}

// 输出结果:
/*
SuperClass init!
123
*/
1
2
3
4
// 通过数组定义来引用类,不会触发此类的初始化
public static void main(String[] args) {
SuperClass[] sca = new SuperClass[10];
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化
public class ConstClass {

/**
* @program: MyAlgothrims
*
* @description:
*
* @author: makaloo
*
* @create: 2019-07-19 12:00
**/
static {
System.out.println("ConstClass init!");
}

public static final String HELLOWORLD = "hello world!";
}

public class test {

public static void main(String[] args) {
System.out.println(ConstClass.HELLOWORLD);
}
}

// 输出结果
/*
hello world!
*/

接口和类的区别是:当类在初始化时,要求其父类全部都已经初始化,而接口并不要求父接口全部完成初始化,只有在真正使用到父接口的时候才会初始化(如引用接口中定义的常量)

类加载的过程

加载

加载是类加载过程的一个阶段,加载阶段,虚拟机需要完成以下3件事情

  • 通过一个类的全限定名来获取定义此类的二进制字节流
  • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
  • 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口

常见的获取二进制字节流的例子为:从ZIP包读取(以及之后的JAR,EAR,WAR包),其他文件生成(JSP)等

非数组类的加载阶段可控性最强,它既可以使用系统提供的引导类加载器来完成,也可以用用户自定义的类加载器去完成,开发人员可以通过定义自己的类加载器去控制字节流的获取方式(即重写一个loadClass()方法)
对于数组类,它本身不通过类加载器创建,而是由Java虚拟机直接创建,但数组类的元素类型最终还是要靠类加载器去完成,一个数组类(下面简称为C)创建过程遵循以下规则:

  • 如果数组的组件类型是引用类型,就递归采用本节中定义的加载过程去加载这个组件类型,数组C将在加载该组件类型的类加载器的类名称控件上被标识
  • 如果数组的组件类型不是引用类型,Java虚拟机将会把数组C标记为与引导类加载器关联
  • 数组类的可见性与它的组件类型的可见性一致,如果数组类型不是引用类型,那数组类的可见性将默认为public

加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区中,方法区中的数据存储格式由虚拟机自行定义(虚拟机规范未规定此区域的具体数据结构),然后在内存中实例化一个java.lang.Class类的对象(规范里没有说明在Java堆,但HotSpot虚拟机Class对象存放在方法区里),这个对象将作为程序访问方法区的这些类型数据的外部接口

加载阶段与连接阶段的部分内容(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,连接阶段可能就已经开始

验证

验证是连接阶段的第一步,这一阶段目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全

Java语言是相对安全的语言,使用纯粹Java代码无法做到访问数组边界以外的数据,对象转型为未实现的类型等,如果这样做,编译器拒绝编译。但Class文件不要求用Java源代码编译而来,可以使用任何途径包括十六进制编辑器等,从字节码语言层面上讲,这些途径是可以实现Java代码无法做到的事情(至少语义上可以表达出来),因此虚拟机需要检查输入的字节流。验证是虚拟机对自身保护的一项重要工作

整体上讲,验证分为4个阶段:文件格式验证,元数据验证,字节码验证,符号引用验证

文件格式验证

这一阶段可能包含下面验证点

  • 是否以魔数0xCAFEBABE开头
  • 主,次版本号是否在当前虚拟机处理范围内
  • 常量池的常量中是否有不被支持的常量类型(检查常量tag标志)
  • 指向常量的各种索引值是否有指向不存在的常量或不符合类型的常量
  • CONSTANT_Utf8_info型的常量中是否有不符合UTF8编码的数据
  • Class文件中各个部分及文件本身是否有被删除的或附加的其他信息
    ……

第一阶段还有许多验证点,该验证阶段确保输入的字节流能正确解析并存储于方法区内,格式上符合一个Java类型信息的要求。这阶段的验证是基于二进制字节流进行的,只有通过这个阶段的验证后,字节流才会进入内存的方法区中进行存储,后面3个阶段全部是基于方法区的存储结构进行的,不再直接操作字节流

元数据验证

对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求,可能包括的验证点如下

  • 这个类是否有父类(除了java.lang.Object,其他所有类都有父类)
  • 这个类的父类是否继承了不允许被继承的类(被final修饰的类)
  • 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法
  • 类中的字段,方法是否与父类产生矛盾(覆盖父类final字段,出现不符合规则的方法重载等)

第二阶段主要目的是对类的元数据信息进行语义校验,保证不存在不符合Java语言规范的元数据信息

字节码验证

第三阶段是验证过程最复杂的一个阶段,主要目的是通过数据流和控制流分析,确定程序语义是合法的,符合逻辑的。在第二阶段对元数据信息中的数据类型做完校验后,这个阶段将对类的方法体进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的事件

  • 保证任何时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现操作栈放置一个int类型的数据,使用时却按long类型来加载到本地变量表
  • 保证跳转指令不会跳转到方法体以外的字节码指令中
  • 保证方法体的类型转换是有效的,例如父类对象赋值给子类数据类型,对象赋值给与它无关的数据类型都是危险的,不合法的

没通过字节码验证的类方法体肯定有问题,通过字节码验证的方法体不一定没有问题:通过程序去校验程序逻辑是无法做到绝对准确的

为了避免过多时间消耗在字节码验证阶段,JDK 1.6之后Javac编译器和Java虚拟机进行一项优化是给方法体的Code属性的属性表增加一项名为”StackMapTable”的属性,这项属性描述方法体中所有基本块开始时本地变量表和操作栈应有的状态,字节码验证期间,不需要根据程序推导这些状态的合法性,只需要检查StackMapTable属性的记录是否合法即可,这样将字节码验证的类型推导转变为类型检查从而节省时间

符号引用验证

最后一个阶段的校验发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作在连接的第三阶段——解析阶段中发生。符号引用验证可以看做是对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验,通常需要校验下列内容:

  • 符号引用中通过字符串描述的全限定名是否能找到对应的类
  • 在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段
  • 符号引用中的类,字段和方法的访问性(private等)是否可以被当前类访问

符号引用验证的目的是确保解析动作能正常执行,如果无法通过符号引用验证,会抛出一个java.lang.IncompatibleClassChangeError异常的子类,如java.lang.IllegalAccessError,java.lang.NoSuchFieldError,java.lang.NoSuchMethodError等

验证阶段是非常重要但不是必要的阶段,如果所运行的全部代码都已经被反复使用和验证过,那么实施阶段可以考虑使用-Xverify:none参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间

准备

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。这个阶段进行内存分配的仅包括类变量(static),而不包括实例变量,实例变量将会在对象实例化的时候随着对象一起分配到Java堆中。初始值”通常情况下”是数据类型的零值,假设一个类变量定义为:public static int value = 123;,这时变量value在准备阶段后的初始值为0,而不是123,因为这时还没有执行任何Java方法,把value赋值为123的putstatic指令是程序被编译后,存放于类构造器<clinit>()方法之中,所以把value赋值为123的动作将在初始化阶段才会执行,零值表如下:

数据类型 int long short char byte boolean float double reference
零值 0 0L (short) 0 ‘\u0000’ (byte) 0 false 0.0f 0.0d null

特殊情况指类字段的字段属性表中存在ConstantValue属性,那么准备阶段变量value就会被初始化为ConstantValue属性所指定的值,如public static final int value = 123;,编译时javac将为value生成ConstantValue属性,在准备阶段,虚拟机根据ConstantValue的设置将value赋值为123

解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,在Class文件中符号引用以CONSTANT_Class_info,CONSTANT_Fieldref_info,CONSTANT_Methodref_info等类型的常量出现,符号引用和直接引用的关联如下:

  • 符号引用:以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时无歧义的定位到目标即可。符号引用和虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。各种虚拟机实现的内存布局可以各不相同,但能接受的符号引用必须一致,因为符号引用的字面量形式明确定义在Java虚拟机规范的Class文件格式中
  • 直接引用:可以是直接指向目标的指针,相对偏移量或是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内部布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在内存中存在

虚拟机规范没有规定解析阶段发生的具体时间,具体虚拟机实现可以根据需要来判断到底是在类被加载器加载时就对常量池的符号引用进行解析,还是等到一个符号引用将要被使用前才去解析它

解析动作主要针对类或接口,字段,类方法,接口方法,方法类型,方法句柄和调用点限定符7类符号引用进行,对应常量池的CONSTANT_Class_info,CONSTANT_Fieldref_info,CONSTANT_Methodref_info,CONSTANT_InterfaceMethodref_info,CONSTANT_MethodType_info,CONSTANT_MethodHandle_info和CONSTANT_InvokeDynamic_info 7种常量类型

主要分为类或接口的解析,字段解析,类方法解析和接口方法解析,过程略

初始化

类初始化阶段是类加载过程的最后一步。在初始化阶段才真正执行类中定义的Java程序代码

初始化阶段是执行类构造器<clinit>()方法的过程

  • <clinit>()方法是由编译器自动收集类中所有类变量的赋值动作和静态语句块中的语句合并产生的,顺序由语句在源文件中出现的顺序决定,静态语句块只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但不能访问,如下
1
2
3
4
5
6
7
public class Test {
static {
i = 0; // 正常编译通过
System.out.println(i); // 提示非法向前引用
}
static int i = 1;
}
  • <clinit>()方法与类的构造函数(或者说与实例构造器<init>()方法)不同,它不需要显式地调用父类构造器,虚拟机会保证子类的<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕,所以虚拟机第一个被执行的<clinit>()方法的类是java.lang.Object
  • <clinit>()方法不是必须的,如果一个类没有为类变量赋值和静态语句块,就不会有<clinit>()方法
  • <clinit>()方法会在多线程环境被正确加锁,同步

类加载器

类与类加载器,双亲委派模型,破坏双亲委派模型,略