【JVM 类加载机制】— 类加载的过程
注:本文是类加载器总结,内容基本来自《深入理解Java虚拟机(第2版)》
上篇说过,类加载的过程分为 5 个阶段:加载、验证、准备、解析和初始化。
加载
“加载”是“类加载”过程的第一个阶段,在加载阶段,虚拟机主要做 3 件事情:
- 通过一个类的全限定名来获取定义此类的二进制字节流。
- 将这个字节流所代表的的静态存储结构转化为方法区的运行时数据结构。
- 在内存中生成一个代表这个类的 java.lang.CLass 对象,作为方法区这个类的各种数据的访问入口
JVM 规范对上面 3 点要求不算具体,因此虚拟机实现与具体应用的灵活度相当大。比如第一步“通过一个类的全限定名来获取定义此类的二进制字节流”,JVM 并没有规定二进制字节流必须从 class 文件中获取,来源可以有很多种,例如:
- 从 zip 包中读取
- 运行时计算生成,这种场景使用的最多就是动态代理技术,在 java.lang.reflect.Proxy 中,就是用了 ProxyGenerator.generateProxyClass 来为特定接口生成形式为 “*$Proxy” 的代理类的二进制字节流。
- 有其他文件生成,典型场景是 jsp 应用,有 jsp 文件生成对应的 class 类。
- ......
对于非数组类的加载阶段,准确的说是在加载阶段获取二进制字节流的阶段,是开发人员可控性最强的,因为加载阶段既可以用系统提供的引导类加载器去加载,也可以由用户自定义加载器加载。
对于数组类而言,情况就有所不同,数组类本身不通过类加载器创建,而是由 JVM 直接创建的。但是数组的元素类型是需要类加载器去加载的,一个数组类创建过程遵循以下规则:
- 如果数组的组件类型是引用类型,那就递归地加载组件,该数组将在加载组件类型的类加载器的类名称空间上被表示。
- 如果数组的组件类型不是引用类型,JVM 将会把该数组标记为与引导类加载器关联。
- 数组类的可见性与它的组件类型可以行一致,如果组件类型不是引用类型,那么数组类型的可见性将默认为 public
加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之中,然后在内存中实例化一个 java.lang.Class 对象(Class 对象并不一定在 Java 堆中,对 HotSpot 虚拟机而言,Class 对象存放在方法区),这个对象将作为程序访问方法区中的这些类型的外部接口。
验证
验证是连接阶段的第一步,目的是为了确保 Class 文件字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。验证阶段大致上会完成 4 个阶段的检验动作:文件格式验证、元数据验证、字节码验证、符号引用验证。
文件格式验证
这个阶段是验证字节流是否符合 Class 文件格式的规范,包括下面这些验证点:
- 是否以魔数 0xCAFEBABE 开头
- 主、次版本号是否在当前虚拟机处理范围之内
- 常量池的常量是否有不被支持的常量类型(检查常量 tag 标志)
- 指向常量的各种索引值是否有指向不存在的常量或不符合类型的常量
- CONSTANT_Utf8_info 类型的常量是否有不符合 UTF8 编码的数据。
- ......
当然,实际上虚拟机所做的验证点远不止这些,该验证阶段主要目的是确保字节流能正确的被解析并存储于方法区之内。这阶段的验证是基于二进制字节流进行的,只有通过这个阶段的验证,字节流才会进入内存的方法区中进行存储,所以后面的 3 个阶段的验证是基于方法区的存储结构进行的,不会再直接操作字节流。
元数据验证
第二阶段的验证是对字节码描述信息进行语义分析,以确保其描述的信息符合 Java 语言规范,这个阶段的验证如下:
- 这个类是否有父类(除了 java.lang.Object 之外,所有类应该都有父类)
- 这个类是否继承了不允许被继承的类(被 final 修饰的类)
- 如果这个类不是抽象类,是否实现了其父类或接口中要求实现的所有方法
- 类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的 final 字段,或者出现了不符合规则的方法重载等)
- ......
字节码验证
第三阶段是整个验证过程最复杂的阶段,主要的目的是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。
- 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作
- 保证跳转指令不会跳转到方法体以外的字节码指令上
- 保证方法体中的类型转换是有效的
- ......
符号引用验证
最后一个阶段的校验发生在虚拟机将符号引用转化直接引用的过程,这个转化动作将在连接的第三个阶段(解析阶段)发生。符号引用验证可以看做是对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验。教研内容如下:
- 符号引用中通过字符串描述的全限定名是否能找到对应的类
- 在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段
- 符号引用中的类、字段、方法的访问性是否可被当前类访问。
- ......
符号引用验证的目的是确保解析动作能够正常执行,如果无法通过符号引用验证,那么将会抛出一个 java.lang.IncompatibleClassChangeError 异常的子类,如 java.lang.IllegalAccessError、java.lang.NoSuchMFieldError、java.lang.NoSuchMethod等。
准备
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的的内存将在方法区中进行分配。这里有三点需要注意
类变量
这里进行内存分配的仅仅是类变量(被 static 修饰的变量),不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在 Java 堆中。
初始值
给类变量设置初始值通常情况是指数据类型的零值,下面是所有基本数据类型的零值。
数据类型 | 零值 | 数据类型 | 零值 |
---|---|---|---|
int | 0 | boolean | false |
long | 0L | float | 0.0f |
short | (short)0 | double | 0.0d |
char | '\u0000' | reference | null |
byte | (byte)0 |
常量
上面说的“通常情况”下初始值是零值,但也有一些特殊情况:如果类字段在字段属性表总存在 ConstantValue 属性,那么准备阶段该变量会被初始化为 ConstantValue 属性所指定的值。
比如
public static final int VALUE = 123;
编译时 Javac 将会为 VALUE 生成 ConstantValue 属性,在准备阶段虚拟机就会根据 ConstantValue 的设置将 value 赋值为 123。
解析
解析阶段是虚拟机将常量池内的符号引用转化为直接引用的过程。在 Class 文件中,符号引用以 CONSTANT_Class_info、CONSTANT_Class_info 等类型的常量出现。
符号引用
符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义的定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。符号引用的字面量形式明确定义在 Java 虚拟机规范的 Class 文件格式中。
直接引用
直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局相关的。如果有了直接引用,那么引用的目标必定在内存中存在。
类或接口的解析
假设当前代码所处的类为 D,如果把一个从未解析过的符号引用 N 解析为一个类或接口 C 的直接引用,那虚拟机完成整个解析过程需要以下 3 个步骤:
- 如果 C 不是一个数组类型,那虚拟机将会把代表 N 的全限定名传递给 D 的类加载器去加载这个类 C。在加载过程中,由于元数据验证、字节码验证的需要,有可能触发其他相关类的加载动作,比如加载这个类的父类或实现接口。
- 如果 C 是一个数组类型,并且数组的元素类型为对象,也就是 N 的描述符是类似“[Ljava/lang/Integer”的形式,那么会按照第 1 步的规则加载数组的元素类型。如果 N 的描述符如前面所假设的形式,需要加载的元素类型就是“java.lang.Integer”,接着由虚拟机生成一个代表此数组维度和元素的数组对象
- 如果上面的步骤没有出现任何异常,那么 C 在虚拟机中实际上已经成为一个有效的类或接口了,但在解析完成之前还要进行符号引用验证,确认 D 是佛具备对 C 的访问权限。如果发现不具备访问权限,将抛出 java.lang.IllegalAccessError 异常。
字段解析
要解析一个未被解析过的字段符号引用,首先将会对字段表内 class_index 项中索引的 CONSTANT_Class_info 符号引用进行解析,也就是字段所属的类或接口的符号引用。如果在解析这个类或接口符号引用的过程中出现了任何异常,都会导致字段符号引用解析失败。如果解析成功,那将这个字段所属的类或接口用 C 表示,虚拟机则按一下步骤对 C 进行后续字段的搜索。
- 如果 C 本身就包含了简单名称和字段描述符都与目标匹配的字段,则返回这个字段你得直接引用,查找结束。
- 否则,如果在 C 中实现了接口,将会按照继承关系从下往上递归搜索各个接口和它的父接口,如果接口中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
- 否则,如果 C 不是 java.lang.Object 的话,将会按照继承关系从下往上递归搜索其父类,如果在父类中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
- 否则,查找失败,抛出 java.lang.NoSuchFieldError 异常。
如果查找过程中成功返回了引用,将会对这个字段进行权限验证,如果发现不具备对这个字段的访问权限,将抛出 java.lang.IllegalAccessError 异常。
类方法解析
类方法解析的第一步骤与字段解析一样,也需要先解析出类方法表的 class_index 项中索引的方法所属的类或接口的符号引用,如果解析成功,用 C 表示这个类,接下来虚拟机将会按照以下步骤进行后续的搜索。
- 类方法和接口方法的符号引用的常量类型第一是分开的(分别为 CONSTANT_Methodref_info 和 CONSTANT_InterfaceMethodref_info),如果在类方法表中发现 class_index 中索引的 C 是个接口,那就直接抛出 java.lang.IncompatibleClassChangeError 异常。
- 如果通过了第一步,在类 C 中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
- 否则,在类 C 的父类中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
- 否则,在类 C 实现的接口列表及它们的父接口之中递归查找是否简单名称和描述符都与目标匹配的方法,如果存在匹配的话,说明类 C 是一个抽象类,这时查找结束,抛出 java.lang.AbstractMethodError 异常。
- 否则,宣告方法查找失败,抛出 java.lang.NoSuchMethodError。
接口方法解析
接口方法也需要先解析出接口方法表的 class_index 项中索引的方法所述的类或接口的符号引用,如果解析成功,用 C 表示这个接口,虚拟机将会按照如下步骤进行后续的接口方法搜索。
- 与类方法解析不同,如果在接口方法表中发现 class_index 中的索引 C 是个类而不是接口,那就直接抛出 java.lang.IncompatibleClassChangeError 异常。
- 否则,在接口 C 中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则直接返回这个方法的直接引用,查找结束。
- 否则,在接口 C 的父接口中递归查找,知道 java.lang.Object 类为止,看是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
- 否则,宣告方法查找结束,抛出 java.lang.NoSuchMethodError 异常。
由于接口中的所有方法默认都是 public 的,所以不存在访问权限的问题,因此接口方法的符号引用解析应当不会抛出 java.lang.IllegalAccessError 异常。
初始化
类初始化阶段是类加载过程的最后一个阶段。在类加载过程中,除了加载阶段应用程序可以使用用户自定义的类加载器去加载类,其他阶段皆由虚拟机主导和控制。到了初始化阶段,才真正执行类中定义的 Java 代码(或者说是字节码)。
在准备阶段,类变量已经赋过一次系统要求的初始值。而在初始化阶段,则根据程序员通过程序制定的值去初始化类变量和其他资源。从一个角度来表达:初始化阶段是执行类构造器<clinit>()
方法的过程。<clinit>()
方法执行的过程中有一些影响和特点比较贴近普通的开发人员。
<clinit>()
方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{} 块)中的语句合并产生的。<clinit>()
方法与类的构造器(或者说是实例构造器<clinit>()
方法)不同,它不需要显式的调用父类构造器,虚拟机会保证在子类的<clinit>()
执行之前,父类的<clinit>()
方法已经执行完毕。因此在虚拟机中第一个被执行的<clinit>()
方法的类肯定是 java.lang.Object。- 由于父类的
<clinit>()
先执行,也就意味着父类中定义的静态语句块要优先于子类的类变量赋值操作。 <clinit>()
方法对于类或接口来说并不是必须的,如果一个类中没有静态语句块,也没有对类变量的赋值操作,那么编译器可以不为这个类生成<clinit>()
方法。- 接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成
<clinit>()
方法。但接口与类不同的是,执行接口的<clinit>()
方法不需要先执行父接口的<clinit>()
方法。只有当父接口中定义的变量使用时,父接口才会初始化。另外,接口的实现类在初始化时也一样不会执行接口的<clinit>()
方法。 - 虚拟机会保证一个类的
<clinit>()
方法在多线程环境中被正确地加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()
方法,其他线程都需要阻塞等待,知道活动线程执行<clinit>()
方法结束。