title: 类加载的过程
tag: 笔记 JVM
概述
JVM中类加载的全过程为:
- 加载
- 验证
- 准备
- 解析
- 初始化
加载
在加载阶段,JVM需要完成以下三件事:
- 通过类的全限定名来获取定义此类的二进制字节流。(Class不一定由Java源码编译而来)
- 将该字节流所代表的静态存储结构转换为方法区的运行时数据结构
- 在内存中生成一个代表该类的java.lang.Class对象,作为方法区对这个类各种数据的访问入口。
注意:数组类本身不通过类加载器创建,它是由 Java 虚拟机直接创建的,再由类加载器创建数组中的元素类。
加载阶段结束后,Java虚拟机外部的二进制字节流就按照虚拟机所设定的格式存储在方法区之中 了。类型数据妥善安置在方法区之后,会在Java堆内存中实例化一个java.lang.Class
类的对象, 这个对象将作为程序访问方法区中的类型数据的外部接口。
验证
这一阶段的目的是确保Class
文件的字节流中包含的信息符合《Java虚 拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。
验证的必要
虽然不合规的Java源码编译器会拒绝编译,但由于Class不一定由Java源码编译而来,因此如果不检查输入的字节流,对其完全信任的话,很可能会因为 载入了有错误或有恶意企图的字节码流而导致整个系统受攻击甚至崩溃,所以验证字节码是Java虚拟 机保护自身的一项必要措施。
验证过程
验证过程大致分为以下四个阶段:
- 文件格式验证: 验证字节流是否符合 Class 文件格式的规范,并且能被当前版本的虚拟机处理,验证点如下:
- 是否以魔数 0XCAFEBABE 开头。
- 主次版本号是否在当前虚拟机处理范围内。
- 常量池是否有不被支持的常量类型。
- 指向常量的索引值是否指向了不存在的常量。
- CONSTANT_Utf8_info 型的常量是否有不符合 UTF8 编码的数据。
- …
- 元数据验证: 对字节码描述信息进行语义分析,确保其符合 Java 语法规范。
- 字节码验证: 本阶段是验证过程中最复杂的一个阶段,是对方法体(Code属性)进行语义分析,保证方法在运行时不会出现危害虚拟机的事件。
- 符号引用验证:检验该类是否缺少或者被禁止访问它依赖的某些外部类、方法、字段等资源。
准备
准备阶段是正式为类中定义的变量(属于类的变量,即static修饰的静态变量)分配内存并设置为变量初始值的阶段。简单来说也就是说该阶段会为类中的静态变量赋值。该阶段并不会为非静态字段赋值(实例变量)。
设置的初始值通常为**“零值”**:
假设定义下面这样一个类变量:‘
public static int value = 123;
那么在准备阶段后,value
的值会是0而不是123。因为此时并未执行任何Java方法,为该静态变量赋值的操作会在初始化阶段才会被执行。下面这种情况是例外:
public static final int value = 123;
static final
修饰的属性在类字段的属性表中设置为ConstantValue
,也就是常量。这种情况JVM
会在准备阶段就将value
的值设置为123.
解析
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。
- 符号引用:符号引用是一组用来描述所引用的目标的符号名称,符号引用并不直接指向内存中的数据,而是以符号的形式描述所引用的目标。在类加载过程中,类文件中的常量池会包含很多符号引用,例如类或接口的全限定名、字段的名称和描述符、方法的名称和描述符等。
- 直接引用:直接引用是可以直接指向内存中的数据的具体地址、偏移量或者一个能间接定位到目标的句柄。
JVM会将符号引用替换为直接引用,这样在程序运行时就可以直接定位并访问目标,避免了每次访问都需要重新解析符号引用的开销,提高了程序的执行效率。
具体来说,解析阶段包括了以下几个步骤:
- 符号引用转换:在加载阶段,类的常量池中会存储符号引用,如类和接口的全限定名、字段和方法的名称和描述符等。在解析阶段,JVM会将这些符号引用转换为直接引用,即真实的内存地址或偏移量。
- 解析动态绑定:对于使用
invokedynamic
指令的动态语言支持,解析阶段会涉及到对调用点限定符(MethodHandle)的解析。 - 接口方法解析:如果调用的是接口中的方法,需要在解析阶段确定最终调用的目标方法,这也涉及到接口方法表的解析。
总的来说,解析阶段的主要任务是将类加载过程中产生的符号引用转换为直接引用,以便在初始化阶段进行具体的内存分配和初始化操作。这样可以在程序运行时更高效地执行方法调用和字段访问。
初始化
类初始化阶段是类加载过程中的最后异步,在之前的过程中,除了加载阶段可以使用自定义类加载器干涉外,其余动作基本都由JVM主导。直到初始化阶段,JVM才开始执行类中的Java程序代码。将主导权交由应用程序。
在准备阶段中,静态变量已经赋值过程一次系统要求的“初始零值”,而在初始化阶段,会根据程序员编写的代码去初始化类变量和其它资源。也就是说,初始化阶段就是执行类构造器< clint >方法的过程。
<clinit方法>
<clinit方法>并不是在Java代码中的方法,而是javac编译器编译后的产物。它是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static {} 块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的。
静态语句块中只能访问定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块中可以赋值,但不能访问。如下方代码所示:
public class Test {
static {
i = 0; // 给变量赋值可以正常编译通过
System.out.println(i); // 这句编译器会提示“非法向前引用”
}
static int i = 1;
}
<clinit>()
方法不需要显式调用父类构造器,虚拟机会保证在子类的 <clinit>()
方法执行之前,父类的 <clinit>()
方法已经执行完毕。
由于父类的 <clinit>()
方法先执行,意味着父类中定义的静态语句块要优先于子类的变量赋值操作。如下方代码所示:
static class Parent {
public static int A = 1;
static {
A = 2;
}
}
static class Sub extends Parent {
public static int B = A;
}
public static void main(String[] args) {
System.out.println(Sub.B); // 输出 2
}
<clinit>()
方法不是必需的,如果一个类没有静态语句块,也没有对类变量的赋值操作,那么编译器可以不为这个类生成 <clinit>()
方法。
接口中不能使用静态代码块,但接口也需要通过 <clinit>()
方法为接口中定义的静态成员变量显式初始化。但接口与类不同,接口的 <clinit>()
方法不需要先执行父类的 <clinit>()
方法,只有当父接口中定义的变量使用时,父接口才会初始化。
虚拟机会保证一个类的 <clinit>()
方法在多线程环境中被正确加锁、同步。如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的 <clinit>()
方法。
)方法不需要先执行父类的
()` 方法,只有当父接口中定义的变量使用时,父接口才会初始化。
虚拟机会保证一个类的 <clinit>()
方法在多线程环境中被正确加锁、同步。如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的 <clinit>()
方法。