「JVM 类加载子系统」系列文章
当写好一个 Java 程序后,会经过编译阶段,生成.class
文件
当后续需要运行和使用类型时,会将程序对应的.class
文件加载到虚拟机内存中 (方法区)
同时也会对数据进行校验、转换解析和初始化,最终形成可以被 JVM 使用的 Java 类型
该 Java 类型其实就是 Class 类型,Class 类型和其他类型一样,如:Integer 等。每个.class
文件对应一个 Class 类型的对象,有且仅有一个
上述的过程就被称作位 JVM 的类加载机制!!
一个类型从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经过加载 (Loading)、验证 (Verification)、准备 (Preparation)、解析 (Resolution)、初始化 (Initialization)、使用 (Using) 和卸载 (Unloading) 七个阶段,其中验证、准备、解析三个阶段部分统称为连接 (Linking)。这七个阶段的发生顺序如下图所示:
下面开始介绍类加载的全过程,即:加载、验证、准备、解析、初始化
「加载」阶段只是「类加载」过程中的一个阶段!!
在加载阶段中,JVM 需要完成三件事情:
通过一个类的全限定名来获取定义此类的二进制字节流
将该字节流代表的静态存储结构转化为方法区的运行时数据结构
在内存中生成一个代表该类的java.lang.Class
对象,作为方法区该类的各种数据的访问入口
加载阶段结束后,Java 虚拟机外部的二进制字节流就按照虚拟机所设定的格式存储在方法区中了,方法区中的数据存储格式完全由虚拟机自行定义
类型数据妥善安置在方法区后,会在 Java 堆内存中实例化一个java.lang.Class
类的对象,这个对象将作为程序访问方法区中的类型数据的外部接口
PS:第二、三点可认为是,Xxx.class 文件 -> Xxx Class 对象
// myObjClass 对象是各种数据的访问入口
Class<MyObj> myObjClass = MyObj.class;
更多关于 Class 对象的操作可见 反射机制
疑问:既然加载阶段就已经在堆中生成了对应的 Class 对象,那么类的验证、准备肯定已经完成了,不然怎么可能就直接生成了对象呢?
如下所示,可以通过Class
对象直接获取类变量的值
xxxxxxxxxx
public class MyObj {
public static final int a = 11;
}
public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException {
// 获取 Class 对象
Class<MyObj> myObjClass = MyObj.class;
// 获取指定字段对应的 Field 对象
Field a = myObjClass.getDeclaredField("a");
// 获取指定字段的值
int aa = (int) a.get(null);
System.out.println(aa);
}
回答:其实「加载阶段」与「连接阶段」的部分动作并不是完全的前后顺序,即先完成「加载阶段」,再完成「连接阶段」;它们的关系是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,但这些夹在加载阶段之中进行的动作,仍然属于连接阶段的一部分,这两个阶段的开始时间仍然保持着固定的先后顺序
确保 Class 文件的字节流中包含的信息符合《Java 虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全
从整体上看,验证阶段大致上会完成四个方面的检验动作:
文件格式验证:确保符合 Class 文件格式的规范
元数据验证:进行语义分析,确保描述信息符合《Java 语言规范》的要求
字节码验证:通过数据流分析和控制流分析,确保程序语义是合法的,符合逻辑的
符号引用验证:确保解析行为能正常执行
准备阶段是正式为类中定义的变量 (即:静态变量 = 类变量 = 被 static 修饰的变量) 分配内存并设置类变量初始值的阶段
混淆点一:
从概念上讲,「类变量」所使用的内存都应当在「方法区」中进行分配,但方法区本身是一个逻辑上的区域
在 JDK7 之前,HotSpot 使用「永久代」来实现方法区时,实现是完全符合这种逻辑概念的;而在 JDK7 及之后,「类变量」则会随着 Class 对象一起存放在 Java 堆中,这个时候「类变量在方法区」就完全是一种对逻辑概念的表述了!
混淆点二:
在准备阶段,进行内存分配仅仅包括「类变量」,而不包括「实例变量」,「实例变量」将会在对象实例化时随对象一起分配在 Java 堆中
混淆点三:
在准备阶段,「初始值」通常情况下是数据类型的「零值」
x// 准备阶段过后,value 的值为 0,而非 123;为 value 赋值为 123 是在初始化阶段
public static int value = 123;
// 下面列出基本数据类型的零值
int 0; long 0L; short (short) 0; char '\u0000'; byte (byte) 0; boolean false; float 0.0f; double 0.0d; reference null
混淆点四:
若为「静态常量」,在准备阶段就会为其赋值为实际值
xxxxxxxxxx
// 准备阶段过后,value 的值为 123
public static final int value = 123;
首先介绍三个概念:「符号引用」「字面量」「直接引用」
符号引用:以一组符号来描述所引用的目标,符号引用是以任何形式的字面量,只要使用时能无歧义地定位到目标即可
符号引用与 JVM 实现的内存布局无关,引用的目标并不一定是已经加载到 JVM 内存当中的内容
直接引用:可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄
先摘出部分javap
反编译后常量池的内容:(此时该类还未被加载进内存中)
xxxxxxxxxx
#2 = Class #22 // com/lfool/myself/Test
#22 = Utf8 com/lfool/myself/Test
上面的#2
和#22
就是「符号引用」,可以根据这两个符号引用无歧义的定位到目标!其实这两个「符号引用」也可以看作两个「字面量」
而com/lfool/myself/Test
就是一种「字面量」,可以通过符号引用#22
定位到字面量com/lfool/myself/Test
我们可以发现「符号引用」和「字面量」是属于关系,即:「符号引用」属于「字面量」。但「符号引用」多了一个属性,即:可以无歧义的定位到目标,关系如下图所示:
「直接引用」也是可以无歧义的定位目标,但和「符号引用」不同点在于「直接引用」通过内存地址定位,这也意味着「直接引用」必须加载到内存中才有意义
当上面常量池的内容被加载到内存中时,对应的「直接引用」如下图所示:
解析阶段是 Java 虚拟机将常量池内的「符号引用」替换成「直接引用」的过程
举个很简单的小例子!现在交给张三一个任务:去找李四。此时常量池可能就是下面这个样子:
xxxxxxxxxx
Constant pool:
#1 = Utf8 李四
而编译后这个任务对应的执行指令为去找 #1
。此处,#1
就是一个符号引用,也可看作是一种字面量,通过这个符号引用,可以无歧义的定位到目标,即:李四
当张三真正去执行该任务的时候 (即:加载到内存后),会将#1
这个符号引用转化成直接引用,也就是「李四」在内存中的地址,假设「李四」在内存中的地址为0x1234
当类被加载到内存后,常量池变为运行时常量池,上面例子对应如下:
xxxxxxxxxx
0x1234 = Utf8 李四
若此时「符号引用」替换成「直接引用」,那么指令就变为了去找 0x1234
当.class
文件还没有加载到内存中的时候,类中的信息均以符号引用和字面量存在,可以通过javap -v Xxx.class
命令查看
当.class
文件加载到内存中的时候,有些符号引用被转化为直接引用 (如:静态常量,静态方法等);而有些符号引用只有在每一次运行期间才会转化成直接引用,这也为了支持方法调用过程中的动态连接
进行准备阶段时,类变量已经赋值过一次系统要求的初始零值;而在初始化阶段,会根据程序员主观计划去初始化类变量和其他资源
更直接的表达:初始化阶段就是执行类构造器<clinit>()
方法的过程
关键点一:
类构造器<clinit>()
方法由编译器自动生成,它会自动收集类中所有「类变量」的赋值动作和「静态语句块 (static {})」中的语句合并产生
编译器收集的顺序是由语句在源程序中出现的顺序决定的,静态语句块只能访问到定义在静态语句块之前的类变量;对于定义在其之后的类变量,可以赋值,但不能访问
为什么可以赋值,却不能访问???
对于一个类变量,在准备阶段就已经赋值了零值,所以初始化阶段可以在该类变量定义前赋值,后面定义处会再次赋值,将前面的赋值覆盖
但如果提前访问,该类变量的值可能是准备阶段赋的零值,也可能是定义前赋的无效值,所以会访问到无效值
xxxxxxxxxx
public class Test {
static {
i = 0; // 给变量赋值可以正常编译通过
System.out.println(i); // 错误:非法向前引用
}
static int i = 1;
}
关键点二:
<clinit>()
方法与类的构造函数 (<init>()
方法)不同,它不需要显示地调用父类构造器,Java 虚拟机会保证在子类的<clinit>()
方法执行前,父类的<clinit>()
方法已经执行完毕
这也是为什么父类的静态变量、静态语句块会在子类之前初始化完成
关键点三:
<clinit>()
方法对类或接口来说并非必需的,如果一个类中没有静态语句块,也没有对类变量的赋值操作,那么编译器可以不为这个类生成<clinit>()
方法
注意:如果仅仅定义了一个类变量,但没有赋值操作,也不会生成<clinit>()
方法,如下代码所示
xxxxxxxxxx
public class Test01 {
public static int a; // 无赋值操作,仅仅定义了而已
}
关键点四:
接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成<clinit>()
方法
但接口与类不同的是,执行接口的<clinit>()
方法不需要先执行父接口的<clinit>()
方法,因为只有当父接口中定义的变量被使用时,父接口才会被初始化
此外,接口的实现类在初始化时,也一样不会执行接口的<clinit>()
方法
疑惑:接口中的字段全都是public static final
类型,既然是final
,那就肯定在准备阶段赋实际值,为什么还会生成<clinit>()
方法呢?
xxxxxxxxxx
public interface SuperInterface {
// 在编译时就将 11 存储在了常量池中
// 在准备阶段为 a 赋值为 11
// 所以就不会生成 clinit 方法
int a = 11;
}
但如果是下面这种情况呢?
xxxxxxxxxx
public interface SuperInterface {
// a 此时是一个引用类型
Integer a = 11;
}
通过javap -v SuperInterface.class
反编译,可以看到 11 并不在常量池中,而是通过Integer.valueOf(11)
创建
x
Classfile /Users/lfool/myself/IdeaProjects/concurrency/target/classes/com/lfool/myself/SuperInterface.class
Last modified 2023年3月3日; size 327 bytes
SHA-256 checksum 27780ea92fdb03a727e8aa498034989c0ea3c1e8949a31012604b8b214fdba56
Compiled from "SuperInterface.java"
public interface com.lfool.myself.SuperInterface
minor version: 0
major version: 52
flags: (0x0601) ACC_PUBLIC, ACC_INTERFACE, ACC_ABSTRACT
this_class: #3 // com/lfool/myself/SuperInterface
super_class: #4 // java/lang/Object
interfaces: 0, fields: 1, methods: 1, attributes: 1
Constant pool:
#1 = Methodref #13.#14 // java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
#2 = Fieldref #3.#15 // com/lfool/myself/SuperInterface.a:Ljava/lang/Integer;
#3 = Class #16 // com/lfool/myself/SuperInterface
#4 = Class #17 // java/lang/Object
#5 = Utf8 a
#6 = Utf8 Ljava/lang/Integer;
#7 = Utf8 <clinit>
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 SourceFile
#12 = Utf8 SuperInterface.java
#13 = Class #18 // java/lang/Integer
#14 = NameAndType #19:#20 // valueOf:(I)Ljava/lang/Integer;
#15 = NameAndType #5:#6 // a:Ljava/lang/Integer;
#16 = Utf8 com/lfool/myself/SuperInterface
#17 = Utf8 java/lang/Object
#18 = Utf8 java/lang/Integer
#19 = Utf8 valueOf
#20 = Utf8 (I)Ljava/lang/Integer;
{
public static final java.lang.Integer a;
descriptor: Ljava/lang/Integer;
flags: (0x0019) ACC_PUBLIC, ACC_STATIC, ACC_FINAL
static {};
descriptor: ()V
flags: (0x0008) ACC_STATIC
Code:
stack=1, locals=0, args_size=0
0: bipush 11
2: invokestatic #1 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
5: putstatic #2 // Field a:Ljava/lang/Integer;
8: return
LineNumberTable:
line 9: 0
}
总结:
对于基本数据类型和字符串来说,在接口中声明的静态常量,在类加载的「准备」阶段,就完成了初始化,其值存储在常量池中
对于其他引用数据类型,如java.util.Date
,在接口中虽然是常量,却是在初始化阶段赋值的,因为在准备阶段其值并不在常量池中
关键点五:
Java 虚拟机保证一个类的<clinit>()
方法在多线程中被正确地加锁同步,如果多个线程同时去初始化一个类,那么只会有其中一个线程去执行这个类的成<clinit>()
方法,其他线程都需要阻塞等待