1 概述
Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程称为虚拟机的类加载机制。
Java的类加载、连接和初始化过程都是在程序运行期间完成的,这种策略让Java不利于进行提前编译,增加了性能消耗,但是却带来巨大的动态扩展性。例如:在运行期间通过网络或者其他地方加载一个二进制流作为程序代码的一部分;面向接口(抽象)的编程;
2 类加载时机
一个类型从被加载到虚拟机内存开始,到被卸载出内存为止,整个生命周期会经历加载、验证、准备、解析、初始化、使用、卸载的过程。
其中 验证、准备、解析三个部分统称为连接。
加载、验证、准备、初始化和卸载的顺序是确定的,类加载的过程必须按照这种顺序开始。
注意是按照顺序开始开始,而不是依次进行。存在并行交叉进行的情况,在某个阶段激活另外一个阶段,并不能保证谁先完成。
解析不能保证按顺序开始,是因为支持Java语言的动态绑定特性,可以在初始化之后再进行。
《Java虚拟机规范》对何时进行类加载并没有强制约束,这部分交给不同的虚拟机进行自由把握。但是却对合适进行初始化进行了严格规定,有且仅有以下六种情况:
(1)遇到
new
、getstatic
、putstatic
、invokestatic
这四条字节码指令的时候,如果对应的类型还没有进行初始化,需要先触发初始化阶段。能够生成这四条指令的典型Java场景:
① new一个实例化对象
②读取或设置一个类型的静态字段(被final修饰,编译器把结果放入常量池的除外)
③ 调用一个类的静态方法
(2)用reflect
包的方法对类型进行反射调用的时候,如果类型没有初始化,需要先进行初始化。
(3)初始化一个类的时候,发现其父类还没有被初始化,需要先触发父类的初始化。
(4)虚拟机启动时,先初始化要执行的主类(包含要执行的main方法的类)。
(5)一个接口中定义了default
方法,接口的实现类如果发生了初始化,需要先初始化该接口。
(6)JDK7中心的动态语言支持,如果一个java.lang.MethodHandle
实例最后的解析结果为REF_getStatic
、REF_putStatic
、REF_invokeStatic
和REF_newInvokeSpecial
四种类型的方法句柄,并且这个句柄对应的类没有进行过初始化。
这六种行为称为对一个类型的主动引用。
3 类加载的过程
分别讲述:加载、验证、准备、解析、初始化。
注意区别 类加载 和 加载 的概念
3.1 加载
(1)通过类的全限定名获取此类的二进制字节流。
(2)将这个字节流所代表的** 静态存储结构** 转换成为** 方法区**的 运行时数据结构。
(3)在内存中生成一个代表此类的java.lang.Class
对象。作为入口访问方法区这个类的各种数据。
其中第一步有非常大的操作空间,全限定名 不一定指文件路径。第一步的目标是把一个 Class文件格式的二进制字节流。这个二进制流可以从网络中获取、可以从数据库读取、从硬盘上读取、还可以从运行时的计算生成、压缩包读取等等方式。
动态代理技术就是使用了运行时生成Class二进制流的方式,为某个类生成代理类。
相较于类加载过程的其他阶段,加载阶段获取二进制流的动作是开发人员可控性最强的阶段。(数组类除外)。
加载阶段既可以使用虚拟机内置的 **引导类加载器 ****BootStrap Class Loader**
完成,也可以由开发人员自定义的类加载器完成,重写 findClass()
和 loadClass()
方法控制字节流的获取方式。
数组类不是由类加载器加载,是有Java虚拟机在内存中构造出来的。但是数组的元素类型仍由类加载器完成(其内的元素类型仍然按照前述类加载过程去加载)。
3.2 验证
如上文,因为二进制流的获取比较自由,虚拟机需要通过验证机制保证二进制流的正确性,防止恶意的程序破坏自身。
验证阶段的工作量占了整个类加载过程相当大的比重。
如果中间有发现字节流不符合Class文件格式约束,就抛出 java.lang.VerifyError
异常或者子类异常。
(1)文件格式验证
验证字节流是否满足Class文件格式的规范,并且能够被当前版本的虚拟机处理。
其中包括:魔数 0xCAFEBABE,主次版本号是否被当前版本虚拟机接受等等。
(2)元数据验证
对字节码描述的信息进行语义分析,保证符合《Java语言规范》。主要是对类的元数据信息进行校验。
(3)字节码验证
通过对数据流分析和控制流分析,确保语义合法、符合逻辑。主要是对类的方法体进行校验。
(4)符号引用验证
确保解析行为能正常进行。对类自身以外的各类信息进行匹配性校验,验证是否能访问到它依赖的某些外部类、字段、方法等资源。
如果无法通过符号引用的验证,Java虚拟机将会抛出一个 java.lang.InCompatibleClassChangeError
的子类异常,典型的有: java.lang.NoSuchFieldError
、 java.lang.NoSuchMethodError
、 java.lang.IllegalAccessError
等。
3.3 准备
准备阶段是正式为类中的静态变量分配内存并设置类变量初始值的阶段。
注意:类变量初始值是指当前类型的初始零值。如 int:0
、 boolean : false
、 reference : null
等等。
但是,常量 ConstantValue,也就是被声明为 public static final
在准备阶段就是被设置成指定的初始值。
3.4 解析
解析阶段是把符号引用替换成为直接引用的过程。
笔者把它理解为 从符号标识 转换成为 目标内存地址或者句柄的过程,以便后续使用。
符号引用:以一组符号来描述所引用的目标,符号引用可以是任何形式的字面量,要求能准确无歧义地定位目标。
直接引用:直接指向目标的指针、偏移量或者简介定位句柄。
其中主要包括:
- 类或接口的解析;
- 字段解析;
- 类方法解析;
- 接口方法解析;
- 方法类型;
- 方法句柄;
- 调用点限定符;
3.5 初始化
类的初始化阶段是类加载的最后一个步骤。在类加载的众多过程之中,在初始化之前,一直是由虚拟机主导的控制。直到该阶段,虚拟机才把主导权移交给应用程序。
在准备阶段,已经为类变量赋予过一次初始零值。而在初始化阶段,将会根据程序员的定制编码去初始化类变量和其他资源。
从另一个方向理解,则是:**初始化阶段是执行类构造器 **<clinit>()**
方法的过程。
(1) <clinit>()
方法是 javac编译器自动生成的产物,它不是编码产生,和类的构造函数也没有太大关系。
(2) <clinit>()
方法是由编译器自动收集类中所有类变量的赋值动作和静态语句块 static {}
合并产生的。编译器收集的顺序是由源文件的出现顺序决定的。静态语句块中中能访问拿到定义在该语句块之前的类变量,定义在之后的变量,可以赋值但是不能访问。
public class Test {
static int num1 = 1;
static {
System.out.println(num1); //正常输出num1
numb2 = 22; //不会报错
System.out.println(num2); //编译器提示:"非法向前引用"
}
static int num2 = 2;
}
(3)Java虚拟机保证,在子类的 <clinit>()
方法执行之前,父类的 **<clinit>()**
方法已经执行完毕。因此意味着第一个 <clinit>()
方法的类一定是 java.lang.Object
。也意味着 父类的静态语句块 优先于 子类的变量赋值 操作。
public class Parent {
public static int A = 1;
static {
A = 2;
}
}
class Son {
public static int B = A;
public static void main(String[] args) {
System.out.println(B); // 输出2
}
}
(4) <clinit>()
方法并不是必须要有的,如果该类或者接口没有 static 代码块,也没有类变量的赋值操作,编译器可以不为其生成 <clinit>()
方法。
(5) <clinit>()
方法是被加锁同步的,线程安全的,如果有多个线程尝试初始化同一个类,只有一个线程会进行类的 <clinit>()
方法,其它的线程全部被阻塞等待。