JVM系列 之 深入理解类加载机制

🌟 JVM系列 之 深入理解类加载机制

👥 背景故事

在一次友人间的聚餐讨论中,我们提及了不同编程语言的优势与学习深度。对于一位以PHP为背景的朋友,有人建议其转向Java生态,因为Java具有成熟的生态系统和广泛的应用场景。朋友表示自己也会Java, 掌握Java的语法 …..

💡 学习层次剖析

学习编程语言可分为三个递进层次:

  1. 📚 基础语法核心类库的熟悉使用
  2. 🌐 优势劣势分析与生态系统了解
  3. 🔧 底层运行机制探究与应用优化

💻 Java核心 -JVM

Java的核心魅力在于Java虚拟机(JVM),它是跨平台执行Java字节码的关键。诸如Kotlin, GroovyScala等其他基于JVM的语言,通过深入理解JVM可以更高效地掌握。

📖 类加载机制详解

  • 编译与执行:Java源代码经过javap编译转换成.class字节码文件,由JVM加载并解析执行,同时即时编译器(JIT)会将热点代码转化为高效的二进制代码, 通过解释器和即时编译器想结合预热后的代码可以达到很快的执行效率

image-20240106170521969

  • 类加载流程

    • 加载阶段:加载阶段的任务是读取字节码资源以静态结构的形式存储在方法区,同时在堆中生成java.lang.Class对象。

    • 验证阶段:验证阶段用于验证字节码的合法性, 确保程序安全性和完整性(分布在多个步骤中)

    • 准备阶段:准备阶段为类的静态变量分配内存并设置默认初始值。这为静态变量的初始化提供了基础。

    • 解析阶段:解析阶段将符号引用解析为直接引用(可以发生在初始化阶段的前后)

    • 初始化阶段:在初始化阶段,会执行类中定义的静态代码块和静态变量的赋值操作。这是程序准备运行的重要阶段。

📥 加载

image-20240104233940085

加载阶段是类加载的第一个阶段,这个阶段的任务是

读取Class资源, 以静态数据结构的形式存储(instanceKlass)在方法区,同时在中生成**java.lang.Class对象**供程序调用, 通过引用彼此关联.

静态字段数据

1.7 之前静态字段信息存在方法区中

1.8 之后静态字段信息存在堆区java.lang.Class`对象 中

此处**Class文件**不只是本地文件, 泛指各种二进制流, 不限于 网络, 数据库,以及即时生成的Class文件(例如动态代理)

❓问题: 为什么要分别在堆和方法去中存两份Class信息呢?

  • instanceKlass 由C++实现, 无法直接给使用ava程序调用获取信息
  • instanceKlass存储的信息比java.lang.Class对象更全面(如虚方法表), java.lang.Class

只提供Java程序可能需要的类信息, 这样的设计符合开闭原则

🔍 验证

验证阶段是连接阶段的一部分。

验证阶段其实分布在各个阶段中

image-20240104231254562

其中, 在加载期间就会进行 **Class文件格式验证 **(例如 cafebabe, 主次版本号等)

此时 虽然方法区已经存在类的静态结构, 堆中存在类的Class对象但是并不代表JVM完全认可这个类

程序如果想要使用这个类, 就必须进行连接, 而连接的第一步就是对类进行验证, 包括

  • 元数据验证(例如 类必须有父类(super不能为空))
  • 字节码验证 (例如 语义校验 (程序跳转位置是否正确))

简单概括来说就是对class静态结构进行语法和语义上的分析保证其不会产生危害虚拟机的

如果两个验证结束, 那么虚拟机会姑且认为该类是安全的, 但是这并不意味着验证的结束, 在解析阶段, 还会进行符号引用的验证

所以说验证其实可以分很多个步骤, 分别在不同的阶段执行, 验证阶段是不断迭代的, 随着JDK的迭代, 以后虚拟机开发人员还会再虚拟机中引入更多更完善的验证策略

⚙️ 准备

准备阶段是连接阶段的另一部分。

在准备阶段,JVM为类中的静态变量分配内存并设置默认初始值

数据类型 初始值
int 0
long 0L
short 0
char ‘/u0000’
byte 0
boolean false
float 0.0
double 0.0
引用数据类型 null

如果静态变量被final修饰, 会赋值代码中的初值(别忘了, final修饰的静态变量如果不初始化赋值会直接报错的)

❓问题 : 为什么需要赋初始值呢?

假如这片内存区域之前使用过, 那么如果后续没有堆该静态变量进行主动的赋值, 这个静态变量的值就会是随机的

这里只包括静态变量,不包括成员变量。

👉 解析

image-20240104235310370

解析阶段是连接阶段的最后也是最重要的一步。

首先要明确在Java中 符号引用直接引用这两个相关的概念。

符号引用是在编译期间使用的一种符号表示方式,它用来表示需要引用的类、方法、字段等实体。符号引用并不包含具体的内存地址,而是使用一个字符串或其他标识来表示。

直接引用是在运行期间使用的一种引用方式,它包含实际的内存地址或偏移量,可以直接访问对应的类、方法、字段等实体。

编译阶段,如果一个类A引用了另一个类B,由于B可能还未被编译或加载,所以A无法知道B的实际地址。因此,在A的class文件中会使用一个字符串S来代表B的地址,S就被称为符号引用。

在运行时,如果A被加载并进行解析阶段,发现B还未被加载,则会触发B的加载过程,将B加载到虚拟机中。此时,A中B的符号引用将被替换成B的实际地址,即直接引用。这样,A就能够真正地调用B了。

值得注意的是,对于多态的情况,Java通过后期绑定来实现。如果A调用的B是一个具体的实现类,这被称为静态解析,因为解析的目标类很明确。

但如果B是一个抽象类或接口,它可能有多个具体的实现类。此时,B的具体实现并不明确,无法确定使用哪个具体类的直接引用进行替换。在运行过程中,当发生了调用时,虚拟机调用栈中将会得到具体的类型信息,此时可以进行解析,使用明确的直接引用来替换符号引用, 这就是动态解析

因此,解析阶段有时会发生在初始化阶段之后,这是为了支持动态解析的需要

🚀 初始化

初始化阶段是类加载的最后一个阶段。在这个阶段中,会执行类中定义的静态代码块和成员变量的赋值操作(执行clinit字节码指令, 执行顺序和Java中代码顺序是一致的),即执行主动资源初始化动作

需要注意的是,调用构造方法不在这个阶段, 这是类阶段的初始化, 只有显式调用new指令才会调用构造函数进行对象实例化,这是对象层面的初始化。

🌐 扩展

以下几种方式会导致类的初始化 (-XX: +TraceClassLoading 参数可以打印出加载并初始化的类):

  • 访问一个类的静态变量 或者静态方法(注意如果变量是final修饰的并且等号右边是常量不会触发初始化)
  • 调用Class.forName(String className)
  • new一个该类的对象时
  • 执行Main方法的当前类

image-20240106165153965

继承关系下类的初始化

image-20240106165851734

image-20240106165929709

image-20240106170209007

image-20240106170254698

✨ 结论

深入理解Java类加载机制是提升Java技术栈的重要一环。从编写代码到在不同操作系统上运行起来,JVM在其中扮演了至关重要的角色。

每个阶段都有其独特目的和功能,而掌握这一过程不仅能应对面试考点,更能帮助我们在实际开发中解决复杂问题,充分利用Java的动态扩展性性能优化特性

参考文献: