07、JVM实战:类加载

07–类加载


1、类加载机制

将class文件字节码文件加载到内存中, 并将这些静态数据转换成方法区中的运行时数据结构,在堆(并不一定在堆中,HotSpot在方法区中)中生成一个代表这个类的java.lang.Class 对象,作为方法区类数据的访问入口。

2、过程

*

2.1、加载

加载过程主要完成三件事情:

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

2.2、校验

此阶段主要确保Class文件的字节流的信息符合当前虚拟机的要求,并且不会危害虚拟机的自身安全。

2.2.1、文件格式验证:基于字节流验证

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

2.2.2、元数据验证:基于方法区的存储结构验证

1. 这个类是否有父类:除了java.lang.Object之外,所有类都应当有父类
2. 这个类是否继承了不允许被继承的类(被final修饰的类)。
3. 如果这个类不是抽象类,是否实现了其父类或接口之中所要求实现的所有方法。
4. 类中的字段、方法是否与父类产生矛盾,举例如下
	1. 覆盖了父类的final字段
	2. 出现不符合规则的方法重载,例如方法参数都一致,但返回值类型却不同等等

2.2.3、字节码验证:基于方法区的存储结构验证

主要目的是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。

这个阶段将对类的方法体进行校验分析,保证被校验类的方法在运行时不会产生危害虚拟机安全的事件,例如:

1. 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现类似下面的情况
	1. 在操作数栈放置了一个int类型的数据,使用时却按long类型来加载入本地变量表中。
2. 保证跳转指令不会跳转到方法体以外的字节码指令上。
3. 保证方法体中的类型转换是有效的,举例如下
	1. 可以把一个子类对象赋值给父类数据类型,但是把父类对象赋值给子类数据类型,甚至把对象赋值给与它毫无继承关系、完全不相干的一个数据类型,则是危险不合法的。
4. Halting Problem:通过程序去校验程序逻辑是无法做到绝对准确的,不能通过程序准确的检查出程序是否能在有限时间之内结束运行。

2.2.4、符号引用验证:基于方法区的存储结构验证。

符号引用验证可以看作是类对自身以外(常量池中的各种符号引用)的信息进行匹配性校验,通常需要校验以下内容:

1. 符号引用中通过字符串描述的全限定名是否能够找到对应的类。
2. 在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段。
3. 符号引用中的类、字段、方法的访问性(private、protected、public、default)是否可被当前类访问。

2.3、准备

1、 在方法区中为类变量分配内存空间;
2、 为类变量初始化为默认值,此时为默认值,在初始化的时候才会给变量赋值;

2.3.1、案例

public class Hello{
** *
** *public static int age = 666;
** *
}

此时在准备阶段过后的初始值为0而不是666,将age赋值为666的putstatic指令是程序被编译后,存放于类构造器方法之中

2.3.2、特例

public class Hello{
** *public static final* int age = 666;
}

age值在准备阶段过后就是666。

2.4、解析

把类型中的符号引用转换为直接引用。

1. 针对类或接口,字段,类或接口的方法进行解析。
2. 首先是用类加载器加载这个类。在加载的过程中逐步解析类中的字段和方法。
3. 符号引用是以字面量的实形式明确定义在常量池中
4. 直接引用是指向目标的指针,或者相对偏移量

符号引用转换为直接引用主要有以下四种情况

2.4.1、类或接口的解析

1、 如果C不是一个数组类型,那虚拟机将会把代表N的全限定名传递给D的类加载器去加载这个类C在加载过程中,由于元数据验证、字节码验证的需要,又可能触发其他相关类的加载动作,例如加载这个类的父类或实现的接口一旦这个加载过程出现了任何异常,解析过程就宣告失败;
2、 如果C是一个数组类型,并且数组的元素类型为对象,也就是N的描述符会是类似"[Ljava/lang/Integer"的形式,那将会按照第1点的规则加载数组元素类型如果N的描述符如前面所假设的形式,需要加载的元素类型就是"java.lang.Integer",接着由虚拟机生成一个代表此数组维度和元素的数组对象;
3、 如果上面的步骤没有出现任何异常,那么C在虚拟机中实际确认D是否具备对C的访问权限上已经成为一个有效的类或接口了,但在解析完成之前还要进行符号引用验证如果发现不具备访问权限,将抛出java.lang.IllegalAccessError异常;

2.4.2、字段解析

要解析一个未被解析过的字段符号引用,首先将会对类字段表内class_index项中索引的CONSTANT_Class_info符号引用进行解析,也就是字段所属的类或接口的符号引用。

如果在解析这个类或接口符号引用的过程中出现了任何异常,都会导致字段符号引用解析的失败。如果解析成功完成,那将这个字段所属的类或接口用C表示,虚拟机规范要求按照如下步骤对C进行后续字段的搜索。

  1. 如果C本身就包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
  2. 否则,如果在C中实现了接口,将会按照继承关系从下往上递归搜索各个接口和他的父接口,如果接口中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
  3. 否则,如果C不是java.lang.Object的话,将会按照继承关系从下往上递归搜索其父类,如果在父类中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段直接引用,查找结束。
  4. 否则,查找失败,抛出java.lang.NoSuchFieldError异常。
  5. 如果查找过程成功返回了引用,将会对这个字段进行权限验证,如果发现不具备对字段的访问权限,将抛出java.lang.IllegalAccessError异常。

在实际应用中,虚拟机的编译器实现可能会比上述规范要求的更加严格一些,如果有一个同名字段同时出现在C的接口和父类中,或者同时在自己或父类的多个接口中出现,那编译器将可能拒绝编译。如下面代码示例中

*

2.4.3、类方法解析

类方法解析的第一个步骤与字段解析一样,也需要先解析出类方法表的class_index项中索引的方法所属的类或接口的符号引用,如果解析成功,我们依然用C表示这个类,接下来虚拟机将会按照如下步骤进行后续的类方法搜索。

  1. 类方法和接口方法符号引用的常量类型定义是分开的,如果在类方法表中发现class_index中索引的C是个接口,那就直接抛出java.lang.IncompatibleClassChangeError异常。
  2. 如果通过了第1步,在类C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
  3. 否则,在类C的父类中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
  4. 否则,在类C实现的接口列表及他们的父接口之中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果存在匹配的方法,说明类C是一个抽象,这时查找结束,抛出java.lang.AbstractMethodError异常。
  5. 否则,宣告方法查找失败,抛出java.lang.NoSuchMethodError。
  6. 最后,如果查找过程成功返回了直接引用,将会对这个方法进行权限验证,如果发现不具备对此方法的访问权限,将抛出java.lang.IllegalAccessError异常。

2.4.4、接口方法解析

接口方法也需要先解析出接口方法表的class_index项中索引的方法所属的类或接口的符号引用,如果解析成功,依然用C表示这个接口,接下来虚拟机将会按照如下步骤进行后续的接口方法搜索。

  1. 与类方法解析不同,如果在接口方法表中发现class_index中的索引C是个类而不是接口,那就直接抛出java.lang.IncompatibleClassChangeError异常。
  2. 否则,在接口C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
  3. 否则,在接口C的父接口中递归查找,直到java.lang.Object(查找范围会包括Object类)为止,看是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
  4. 否则,宣告方法查找失败,抛出java.lang.NoSuchMethodError异常。
  5. 由于接口中的所有方法默认都是public,所以不存在访问权限的问题,因此接口方法的符号解析应当不会抛出java.lang.IllegalAccessError异常。

3、初始化

1、 初始化阶段是类加载过程的最后一步,到了这个阶段才真正开始执行类中定义的Java程序代码,或者说是字节码;
2、 在准备阶段,变量已经赋过一次系统要求的初始值,而在初始化阶段,则根据程序员通过程序制定的主观计划去初始化类变量和其他资源;

需要注意以下几点:

3.1、静态语句块中变量定义的顺序

编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但不能访问,代码解释如下:

*

3.2、初始化方法执行的顺序

初始化方法执行的顺序,虚拟机会保证在子类的初始化方法执行之前,父类的初始化方法已经执行完毕,因此在虚拟机中第一个被执行的类初始化方法一定是java.lang.Object。另外,也意味着父类中定义的静态语句块要优先于子类的变量赋值操作。

例如:

*

3.3、clinit 方法

clinit ()方法对于类或接口来说并不是必须的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成clinit()方法。

3.3.1、clinit 定义

方法 的执行时期: 类初始化阶段(该方法只能被jvm调用, 专门承担类变量的初始化工作)
方法 的内容: 所有的类变量初始化语句和类型的静态初始化器

3.3.2、接口的 clinit 方法。

接口中不能使用静态语句块,但仍然有变量初始化的操作,因此接口与类一样都会生成clinit()方法,但与类不同的是,执行接口的初始化方法之前,不需要先执行父接口的初始化方法。只有当父接口中定义的变量使用时,才会执行父接口的初始化方法。另外,接口的实现类在初始化时也一样不会执行接口的clinit()方法。

3.3.3、clinit 加锁

虚拟机会保证一个类的clinit()方法在多线程环境中被正确的加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的clinit()方法,其他线程都需要阻塞等待,直到活动线程执行类初始化方法完毕。

3.4、初始化触发的条件,主动引用有且只有四个

1、 new(实例化对象)、getstatic(获取类变量的值,被final修饰的除外,他的值在编译器时放到了常量池)、putstatic(给类变量赋值)、invokestatic(调用静态方法);
2、 使用java.lang.reflect包的方法对类进行反射调用方法;
3、 初始化子类的时候,如果他的父类还没有初始化,要先初始化父类;
4、 虚拟机启动时,含有main方法的类,会被先初始化main方法的类;

3.5、初始化触发的条件,被动引用(除了上面引用类的四个条件会触发类的初始化,其他对类的引用都不会触发类的初始化)

1、 在第三方类中,使用子类引用父类的类变量,不会初始化子类;
2、 在第三方类中,通过数组定义来应用类,不会初始化类;
3、 在第三方类中,引用类的常类变量(同时被final和static修饰的变量),不会触发类的初始化(因为在第三方类的编译之后,常量就被放在第三方类的常量池中了);