类加载过程

java 文件经过编译后生成了 class 文件,类加载器的工作对象就是 class 文件,将 class 文件从其他位置加载到 JVM 内存中。类从加载到虚拟机到卸载出内存整个生命周期包括:加载、验证、准备、解析、初始化、使用、卸载七个过程。其中验证、准备、解析三个步骤统称为链接。

类加载过程
类加载过程

加载、验证、准备、初始化、卸载这 5 个过程是按顺序开始即可(不一定等上一步骤结束)。解析阶段有可能在初始化之后,这是为了支持 Java 语言的运行时绑定特性。

加载

加载时虚拟机的工作:

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

虚拟机获取字节流的方式:

  • 本地磁盘中的 class 文件中获取
  • 从 ZIP 包中获取,例如JAR、WAR、EAR等
  • 从网络中读取
  • 运行时计算生成,例如动态代理技术,利用 sun.misc.ProxyGenerator#generateProxyClass 为特定接口生成代理类的二进制字节流
  • 由其他文件生成,例如 JSP 应用
  • 从数据库中读取

对于数组类的加载,由虚拟机直接创建,但是数组的元素类型最终还是靠类加载器来加载。数组创建遵循的原则:

  • 如果数组的组件类型(去掉一个维度的类型)是引用类型,则递归加载过程来创建,数组也在加载该组件类型的类加载器的类名称空间上被标识。
  • 数组的组件类型不是引用类型(基本数据类型,例如 int[]),虚拟机会将数组标记为与引导类加载关联。
  • 数组类的可见性与引用类型的可见性一致,如果组件类型不是引用类型,那数组类的可见性将默认为 public。

验证

class 文件可以有多种生成方式,生成的文件是否完全符合 class 文件规范。验证是为了尽量保证虚拟机的安全,避免错误的字节流导致虚拟机奔溃。如果验证到字节流不符合规范虚拟机会抛出 java.lang.VerifyError 异常。验证大致会完成四个阶段的校验:

文件格式校验

验证字节流是否符合 class 文件规范,保证输入的字节流能正确解析并且存储在方法区。

元数据验证

对字节码描述的信息进行语义分析,保证描述信息符合 Java 语言规范。包括:该类是否有父类、父类是否继承了被 final 修饰的类、该类不是抽象类是否实现了其父类或者接口中的所有方法、类中的方法和字段是否与父类冲突等。

字节码验证

通过数据流和控制流分析确保程序语义合法、符合逻辑。保证被校验类的方法在运行时不会做出危害虚拟机安全的事件,例如:

  • 保证任意时刻操作数栈数据类型与指令序列能够匹配工作,避免出现操作数栈的 int 数据类型被按照 long 类型指令加载入本地变量表
  • 保证跳转指令不会跳转到方法体以外的字节码指令上
  • 保证方法体中的类型转换是有效的

符号引用验证

这个验证是在加载过程的解析阶段将符号引用转化为直接引用时发生。符号引用校验是对类自身外(常量池的符号引用)的信息进行匹配性校验:

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

符号引用验证确保解析动作能正常执行,如果符号引用验证失败将会抛出 java.lang.IncompatibleClassChangeError 异常的子类,如:java.lang.IllegalAccessErrorjava.lang.NoSuchFieldErrorjava.langNoSuchMethodError等。

验证对于虚拟机是重要但是不必要的阶段,如果对于运行的 class 文件都反复使用和验证过,可以使用 -Xverify:none 来关闭验证。

准备

在方法区中为类变量分配内存并且设置初始值,仅类变量不包括实例变量,实例变量随着对象实例化时一起在堆中分配内存。这里的初始值是数据类型的零值。例如:

1
public static int value = 2;

在准备阶段只会把 value 置为 0,赋值是在编译阶段会把 putstatic 指令放到类构造器的 clinit() 方法中,在初始化阶段在执行赋值。数据零值:

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

但是如果字段属性存在 ConstantValue 属性,则编译器会给 value 生成 ConstantValue 属性,然后在准备阶段就会置为指定值,例如:

1
public static final int value = 2;

解析

解析是虚拟机将常量池内的符号引用转化为直接引用的过程。解析动作主要针对 7 类符号引用:

类型 名称
CONSTANT_Class_info 类或接口
CONSTANT_Fieldref_info 字段
CONSTANT_Methodref_info 类方法
CONSTANT_InterfaceMethodref_info 接口方法
CONSTANT_MethodType_info 方法类型
CONSTANT_MethodHandle_info 方法句柄
CONSTANT_InvokeDynamic_info 调用点限定符

符号引用

以一组符号来描述引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义的定位到目标即可。符号引用的字面量形式定义在 Java 虚拟机规范 Class 文件中,与虚拟机的内存布局无关。

直接引用

指向目标的指针、相对偏移量或者一个能间接定位到目标的句柄。与虚拟机实现的内存布局相关,同一个符号引用在不同的虚拟机上翻译出来的直接引用一般不相同,如果有了直接引用那目标一定被加载到了内存中。

初始化

初始化阶段虚拟机开始执行前期编译好的字节码,初始化类变量和其他资源,就是执行类构造器 <clinit>() 方法。

  • <clinit>() 方法是由编译器自动收集所有类变量赋值语句和静态语句块组合,顺序是由源文件中出现的顺序决定。静态语句块只能访问到定义在静态语句块之前的变量,之后的变量在前面的静态语句块可以赋值,但是不能访问。
1
2
3
4
5
6
7
8
9
10
public class StaticTest {
static {
i = 0;
System.out.println(i); // 编译器会提示:Illegal forward reference;无效的向前引用
}
public static int i = 1;
public static void main(String[] args) {
System.out.println(i);
}
}
  • <clinit>() 方法与类的构造函数(实例构造器()方法)不同,不需要显示调用父类的<clinit>() 方法,虚拟机会保证在执行子类的<clinit>() 之前执行完父类的<clinit>() 方法。即 java.lang.Object<clinit>() 方法是第一个被执行的。

  • 由于父类的 <clinit>() 先执行,则父类的静态语句块先于子类的变量赋值操作。

  • <clinit>() 不是必须的,如果类或者接口没有静态语句块或者变量赋值操作,编译器也可以不生成该方法。

  • 接口中不能使用静态语句块,但可以有变量赋值操作,都会生成 <clinit>() 方法。不同点是,执行接口的 <clinit>() 方法不需要先执行父类的,只有使用到父类接口中定义的变量时在初始化。同样接口的实现类在初始化时也不会执行接口的 <clinit>() 方法。

  • 虚拟机保证一个类 <clinit>() 方法的执行是线程安全的。其他线程会被阻塞,等当前初始化线程执行完后其他线程也不会再进入 <clinit>() 方法,同一个类加载器一个类只会被初始化一次。