概述
Java 虚拟机把描述类的二进制流从 .Class 文件读到内存中,并在这个过程对加载的数据进行校验、转换解析和初始化,最终形成可以被 Java 虚拟机直接使用的 Java 类型,这个过程叫做虚拟机的类加载机制。在 Java 中,类型的加载、连接和初始化过程都是在程序运行期间完成的,这让类加载时稍微增加了性能开销,却也提供了极高的扩展性和灵活性。
类加载时机
一个类从被加载到内存到被卸载一共包括7个阶段:加载、验证、准备、解析、初始化、使用、卸载,其中验证、准备、解析被统称为连接。
![image.png]从p6-juejin.byteimg.com/tos-cn-i-k3…)
加载、验证、准备、初始化等阶段时按序开始的,而为了支持Java语言的动态绑定特性,解析可以在初始化前开始也可以在初始化之后开始;需要注意的是,这里提到的概念是按序开始,而非按序进行,这些环节的执行通常都是并发的关系,而非串行,也就是说只知道他们什么时候开始,而不能确定谁先完成,这些阶段会交叉进行,经常会在一个阶段执行的过程中去调用或激活另一个阶段。
JVM 规范规定了有且只有六种情况必须立即对类进行初始化:
- 遇到某些指令时,如 new 关键字实例化对象、读取或设置一个静态字段、调用静态方法;
- 对类型进行反射调用时;
- 初始化类时,如果父类没有被初始化,则先触发父类的初始化;
- 虚拟机启动时,指定的主类(包含 main 函数的类)需要立即初始化;
- 使用动态语言支持时,解析出的某些方法句柄对应的类;
- 接口中存在 default 方法, 且实现类被初始化了,则该接口要在其之前被初始化。
类加载过程
加载
加载阶段需要做如下三件事:
- 通过一个类的全限定名来获取其定义的二进制字节流。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在 Java 堆中生成一个代表这个类的 java.lang.Class 对象,作为对方法区中这些数据的访问入口。
由于 JVM 规范没有规定二进制字节流必须来自于某个 Class 文件,因此提供给开发者很高的灵活度,也产生了很多技术,例如从 ZIP 压缩包中读取发展出 JAR、WAR 等格式,运行时计算生成发展出动态代理技术等等。
当加载完成后,方法区会保存该类的数据结构,而堆区也会保存一个封装了方法区数据结构的 Class 对象。
验证
这一阶段的目的是为了确保 Class 文件的字节流中包含的信息符合 JVM 规范,并且不会危害虚拟机自身的安全。从总体上看,验证阶段大致会完成 4 个阶段的检验动作:
-
文件格式验证
验证字节流是否符合 Class 文件格式的规范,例如:
- 是否以 0xCAFEBABE 开头;
- 主次版本号是否在当前虚拟机的处理范围之内;
- 常量池中的常量是否有不被支持的类型;
- CONSTANT_Utf8_info型的常量冲是否有不符合UTF-8编码的数据;
- ......
文件格式验证是基于二进制流的,文件格式验证之后的验证都是基于方法区存储的数据结构的,通过验证后的二进制流才被允许进入JVM方法区。
-
元数据验证
对字节码描述的信息(类层次) 进行语义分析,要求其必须满足 JAVA 语法规范,例如:
- 是否有父类(除 Object 类之外,所有类都应该有父类);
- 该类的父类是否继承了不允许被继承的类;
- 如果该类不是抽象类,那么它是否实现了其父类或接口中要求实现的方法;
- ......
-
字节码验证
元数据验证是针对类层次的验证,而字节码验证则是基于类的方法体(Code 属性)进行验证的。
字节码验证的工作是通过数据流分析和控制流分析,从而确定程序语义是否合法、是否符合逻辑,例如:
- 保证任意是个操作数栈的数据类型和指令代码序列都能配合工作,不会出现操作数栈中压入了一个 int,使用时却按 long 类型来加载入本地变量表中(非主观问题导致、被篡改);
- 保证任何跳转指令都不会跳转到方法体以外的字节码指令上;
- 保证方法体中类型转换总是有效的;
- ......
-
符号引用验证
最后一个校验行为发生在虚拟机将符号引用转化为直接引用的时候,这个转化将在解析阶段中发生。符号引用验证可以看作是对类自身以外的各类信息进行匹配性校验,即检验该类是否缺少或者被禁止访问他所依赖的某些外部类、方法、字段等等资源。其中校验内容有:
- 符号引用中通过字符串描述的全限定名,是否能找到实际的与之对应的类;
- 在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段;
- 符号引用中的类、字段、方法的可访问性,是否可以被当前类访问;
- ......
验证阶段是非常重要的,但不是必须的,它对程序运行期没有影响,如果所引用的类经过反复验证,那么可以考虑采用 -Xverifynone 参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。
准备
准备阶段是正式为类变量(即 static 变量)分配内存并设置类变量初始值的阶段,从概念上讲,这些变量所使用的内存都应该在方法区上进行分配。
这里所设置的初始值通常情况下是数据类型默认的零值(如0、0L、null、false等),final 修饰的静态变量除外。
解析
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。
- 符号引用就是一组符号来描述目标,可以是任何字面量。符号引用与虚拟机实现的内存布局无关,引用的目标不一定要是已经加载到虚拟机内存中的内容。
- 直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。直接原因和虚拟机实现的内存布局直接相关。
举个例子:在程序执行方法时,系统需要明确知道这个方法所在的位置。Java 虚拟机为每个类都准备了一张方法表来存放类中所有的方法。当需要调用一个类的方法的时候,只要知道这个方法在方法表中的偏移量就可以直接调用该方法了。通过解析操作符号引用就可以直接转变为目标方法在类中方法表的位置,从而使得方法可以被调用。
综上,解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,也就是得到类或者字段、方法在内存中的指针或者偏移量。
初始化
初始化阶段就是执行类构造器 <clinit>() 方法的过程。<clinit>() 方法有以下几个特点:
-
<clinit>() 方法是由编译器自动收集类中所有类变量的赋值动作和静态语句块中的语句合并产生的。
-
JVM 保证在子类的 <clinit>() 方法执行前,父类的 <clinit>() 方法已经执行完毕,因此在 JVM 中第一个被执行 <clinit>() 方法的类型肯定是 Object 类。在下面的代码中,父类的静态语句块优于子类的赋值操作,因此会输出 2。
public class Test { 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); } }
-
JVM 保证一个类的<clinit>() 方法在多线程环境下被正确加锁,即如果多个线程同时去初始化一个类,那么只有其中一个线程会执行 <clinit>() 方法,其他线程都需要阻塞等待,直到活动线程执行完毕。如下代码所示:
public class DeadLoopClass { static class DeadLock { static { if(true) { System.out.println(Thread.currentThread() + "init class"); while(true) {} } } } public static void main(String[] args) { Runnable script = new Runnable() { @Override public void run() { System.out.println(Thread.currentThread() + "start"); DeadLock dlc = new DeadLock(); System.out.println(Thread.currentThread() + "run over"); } }; Thread t1 = new Thread(script); // t1 将执行 init Thread t2 = new Thread(script); // t2 阻塞等待 t1.start(); t2.start(); } }
执行结果: