「JVM 类加载子系统」系列文章

类加载的过程

概述

当写好一个 Java 程序后,会经过编译阶段,生成.class文件

当后续需要运行和使用类型时,会将程序对应的.class文件加载到虚拟机内存中 (方法区)

同时也会对数据进行校验、转换解析和初始化,最终形成可以被 JVM 使用的 Java 类型

该 Java 类型其实就是 Class 类型,Class 类型和其他类型一样,如:Integer 等。每个.class文件对应一个 Class 类型的对象,有且仅有一个

上述的过程就被称作位 JVM 的类加载机制!!

类加载的过程

一个类型从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经过加载 (Loading)、验证 (Verification)、准备 (Preparation)、解析 (Resolution)、初始化 (Initialization)、使用 (Using) 和卸载 (Unloading) 七个阶段,其中验证、准备、解析三个阶段部分统称为连接 (Linking)。这七个阶段的发生顺序如下图所示:

1

下面开始介绍类加载的全过程,即:加载、验证、准备、解析、初始化

加载

「加载」阶段只是「类加载」过程中的一个阶段!!

在加载阶段中,JVM 需要完成三件事情:

加载阶段结束后,Java 虚拟机外部的二进制字节流就按照虚拟机所设定的格式存储在方法区中了,方法区中的数据存储格式完全由虚拟机自行定义

类型数据妥善安置在方法区后,会在 Java 堆内存中实例化一个java.lang.Class类的对象,这个对象将作为程序访问方法区中的类型数据的外部接口

PS:第二、三点可认为是,Xxx.class 文件 -> Xxx Class 对象

更多关于 Class 对象的操作可见 反射机制

疑问:既然加载阶段就已经在堆中生成了对应的 Class 对象,那么类的验证、准备肯定已经完成了,不然怎么可能就直接生成了对象呢?

如下所示,可以通过Class对象直接获取类变量的值

回答:其实「加载阶段」与「连接阶段」的部分动作并不是完全的前后顺序,即先完成「加载阶段」,再完成「连接阶段」;它们的关系是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,但这些夹在加载阶段之中进行的动作,仍然属于连接阶段的一部分,这两个阶段的开始时间仍然保持着固定的先后顺序

验证

确保 Class 文件的字节流中包含的信息符合《Java 虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全

从整体上看,验证阶段大致上会完成四个方面的检验动作:

准备

准备阶段是正式为类中定义的变量 (即:静态变量 = 类变量 = 被 static 修饰的变量) 分配内存并设置类变量初始值的阶段

混淆点一:

从概念上讲,「类变量」所使用的内存都应当在「方法区」中进行分配,但方法区本身是一个逻辑上的区域

在 JDK7 之前,HotSpot 使用「永久代」来实现方法区时,实现是完全符合这种逻辑概念的;而在 JDK7 及之后,「类变量」则会随着 Class 对象一起存放在 Java 堆中,这个时候「类变量在方法区」就完全是一种对逻辑概念的表述了!

混淆点二:

在准备阶段,进行内存分配仅仅包括「类变量」,而不包括「实例变量」,「实例变量」将会在对象实例化时随对象一起分配在 Java 堆中

混淆点三:

在准备阶段,「初始值」通常情况下是数据类型的「零值」

混淆点四:

若为「静态常量」,在准备阶段就会为其赋值为实际值

解析

首先介绍三个概念:「符号引用」「字面量」「直接引用」

符号引用:以一组符号来描述所引用的目标,符号引用是以任何形式的字面量,只要使用时能无歧义地定位到目标即可

符号引用与 JVM 实现的内存布局无关,引用的目标并不一定是已经加载到 JVM 内存当中的内容

直接引用:可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄

先摘出部分javap反编译后常量池的内容:(此时该类还未被加载进内存中)

上面的#2#22就是「符号引用」,可以根据这两个符号引用无歧义的定位到目标!其实这两个「符号引用」也可以看作两个「字面量」

com/lfool/myself/Test就是一种「字面量」,可以通过符号引用#22定位到字面量com/lfool/myself/Test

我们可以发现「符号引用」和「字面量」是属于关系,即:「符号引用」属于「字面量」。但「符号引用」多了一个属性,即:可以无歧义的定位到目标,关系如下图所示:

5

「直接引用」也是可以无歧义的定位目标,但和「符号引用」不同点在于「直接引用」通过内存地址定位,这也意味着「直接引用」必须加载到内存中才有意义

当上面常量池的内容被加载到内存中时,对应的「直接引用」如下图所示:

6

解析阶段是 Java 虚拟机将常量池内的「符号引用」替换成「直接引用」的过程

举个很简单的小例子!现在交给张三一个任务:去找李四。此时常量池可能就是下面这个样子:

而编译后这个任务对应的执行指令为去找 #1。此处,#1就是一个符号引用,也可看作是一种字面量,通过这个符号引用,可以无歧义的定位到目标,即:李四

当张三真正去执行该任务的时候 (即:加载到内存后),会将#1这个符号引用转化成直接引用,也就是「李四」在内存中的地址,假设「李四」在内存中的地址为0x1234

当类被加载到内存后,常量池变为运行时常量池,上面例子对应如下:

若此时「符号引用」替换成「直接引用」,那么指令就变为了去找 0x1234

.class文件还没有加载到内存中的时候,类中的信息均以符号引用和字面量存在,可以通过javap -v Xxx.class命令查看

.class文件加载到内存中的时候,有些符号引用被转化为直接引用 (如:静态常量,静态方法等);而有些符号引用只有在每一次运行期间才会转化成直接引用,这也为了支持方法调用过程中的动态连接

5

初始化

进行准备阶段时,类变量已经赋值过一次系统要求的初始零值;而在初始化阶段,会根据程序员主观计划去初始化类变量和其他资源

更直接的表达:初始化阶段就是执行类构造器<clinit>()方法的过程

关键点一:

类构造器<clinit>()方法由编译器自动生成,它会自动收集类中所有「类变量」赋值动作「静态语句块 (static {})」中的语句合并产生

编译器收集的顺序是由语句在源程序中出现的顺序决定的,静态语句块只能访问到定义在静态语句块之前的类变量;对于定义在其之后的类变量,可以赋值,但不能访问

为什么可以赋值,却不能访问???

对于一个类变量,在准备阶段就已经赋值了零值,所以初始化阶段可以在该类变量定义前赋值,后面定义处会再次赋值,将前面的赋值覆盖

但如果提前访问,该类变量的值可能是准备阶段赋的零值,也可能是定义前赋的无效值,所以会访问到无效值

关键点二:

<clinit>()方法与类的构造函数 (<init>()方法)不同,它不需要显示地调用父类构造器,Java 虚拟机会保证在子类的<clinit>()方法执行前,父类的<clinit>()方法已经执行完毕

这也是为什么父类的静态变量、静态语句块会在子类之前初始化完成

关键点三:

<clinit>()方法对类或接口来说并非必需的,如果一个类中没有静态语句块,也没有对类变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法

注意:如果仅仅定义了一个类变量,但没有赋值操作,也不会生成<clinit>()方法,如下代码所示

关键点四:

接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成<clinit>()方法

但接口与类不同的是,执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法,因为只有当父接口中定义的变量被使用时,父接口才会被初始化

此外,接口的实现类在初始化时,也一样不会执行接口的<clinit>()方法

疑惑:接口中的字段全都是public static final类型,既然是final,那就肯定在准备阶段赋实际值,为什么还会生成<clinit>()方法呢?

但如果是下面这种情况呢?

通过javap -v SuperInterface.class反编译,可以看到 11 并不在常量池中,而是通过Integer.valueOf(11)创建

总结

关键点五:

Java 虚拟机保证一个类的<clinit>()方法在多线程中被正确地加锁同步,如果多个线程同时去初始化一个类,那么只会有其中一个线程去执行这个类的成<clinit>()方法,其他线程都需要阻塞等待