深入JVM:从类加载机制解读类的生命周期
对于Java工程师而言,深入理解JVM(Java虚拟机)不仅是掌握Java程序运行机制的基础,也是提升系统性能、优化应用和解决复杂问题能力的重要一步,更是Java进阶之路的重中之重。本文小豪将带大家学习类的生命周期,包括类的验证、准备、解析和初始化这几个关键步骤,同时从类加载的角度剖析static静态代码、构造方法、初始化方法的执行顺序,进一步理解类加载机制。
文章目录
深入JVM:从类加载机制解读类的生命周期
一、序言
对于Java工程师而言,深入理解JVM(Java虚拟机)不仅是掌握Java程序运行机制的基础,也是提升系统性能、优化应用和解决复杂问题能力的重要一步,更是Java进阶之路的重中之重。
本文小豪将带大家学习类的生命周期,包括类的验证、准备、解析和初始化这几个关键步骤,同时从类加载的角度剖析static
静态代码、构造方法、初始化方法的执行顺序,进一步理解类加载机制。
二、前置知识
在学习类的生命周期之前,大家有必要先行了解一些基础概述:
(1)类的字节码文件
类的字节码文件即.class文件,后缀为.class的文件是Java编译器编译Java源文件(.java)后产生的目标文件。
.class文件包含了字节码指令,使其能够在JVM上解释执行。
(2)JVM内存模型
即JVM管理的内存,也称运行时数据区,负责管理JVM使用到的内存,主要包括虚拟机栈、本地方法栈、程序计数器、方法区和堆。
想必大家稍微接触过JVM都应该有所了解,如果还有不知道在讲什么的小伙伴,可以先行恶补一下JVM基础
三、生命周期
类的生命周期描述了一个类加载、使用、卸载的整个过程。
大致分为五个阶段:加载(Loading)、连接(Linking)、初始化(Initialization)、使用(Using)和卸载(Unloading)。其中连接阶段又分为验证、准备和解析三个步骤。
1、加载Loading
加载阶段是类生命周期的起点,负责将Java类的字节码文件加载到JVM内存中,其主要通过类加载器完成。
主要业务流程如下:
-
类加载器根据类的全限定类名从多个渠道获取类的字节码信息(二进制数据流)
-
加载完成之后会为其分配两块内存空间,分别在方法区和堆上。
- 方法区(JDK1.8之后称元空间):存储类的字节码信息,生成
InstanceKlass
对象,保存类的所有信息,包括类基本信息、静态常量池、字段、方法、虚方法表(多态) - 堆:创建并存储类的Class对象,用来封装在方法区中类的数据结构,用于对外暴露提供
- 方法区(JDK1.8之后称元空间):存储类的字节码信息,生成
基于安全性考虑,JVM只提供给我们堆中类对象的访问权限,方法区中的类信息是无法访问的,同时堆中的类对象也是实现反射的入口
流程图如下:
2、连接Linking
(1)验证Verification
连接阶段的第一个步骤是验证,主要是确保加载阶段加载的Java字节码信息是符合规范要求的,对字节码文件进行严格的检查,检测Java字节码文件是否符合《Java虚拟机规范》中的约束。
其具体过程不用深究,小豪在这里大致总结一下:
- 文件格式检查:基本文件信息正确性检查
- 检查字节码文件的基本格式,是否以魔数
0xCAFEBABE
开头(.class文件标识) - 检查字节码文件的主次版本号,是否与当前Java环境匹配
- 检查字节码文件的基本格式,是否以魔数
- 元数据检查:语义规范正确性检查
- 检查类的继承关系、接口实现,是否存在父类(类默认继承
Object
) - 检查访问权限的合法性,是否存在被
final
修饰的类或方法被重写
- 检查类的继承关系、接口实现,是否存在父类(类默认继承
- 字节码检查:字节码规范正确性检查
- 检查数据类型安全,方法的入参数据类型是否正确,变量赋值的数据类型是否正确,确保操作数栈和局部变量表存在正确的数据类型
- 符号引用检查:符号引用有效性检查
- 检查类文件中的符号引用是否有效,是否存在当前类引用的其它类或者方法,当前类是否有权限访问引用的类或方法(例如检查是否访问其它类中
private
修饰的方法)
- 检查类文件中的符号引用是否有效,是否存在当前类引用的其它类或者方法,当前类是否有权限访问引用的类或方法(例如检查是否访问其它类中
(2)准备Preparation
准备主要是为类的静态变量分配内存,并为其设置默认值。
其中数值类型的默认为0
,boolean
类型默认为fasle
,引用类型默认为null
。
(重要)如果静态变量被final修饰,其会被认为是常量,对应的值在编译时就已经确定,JVM会直接为其赋定义的值,而不会赋默认值。
结合案例我们来看一下:
这里查看类编译后的字节码文件,小豪使用的是Jclasslib工具,通过IDEA集成Jclasslib插件。
Jclasslib字节码编辑器是一个可视化已编译Java类文件和包含的字节码的工具,更多用法大家可自行查阅,这里不过多赘述
情况一:静态变量未被final
修饰
public static String name = "xiaohao";
public static int age = 24;
Jclasslib字节码工具呈现结果:
如图,静态变量name
和age
分别被赋予默认值null
和0
情况二:静态变量被final
修饰
public static final String name = "xiaohao";
public static final int age = 24;
Jclasslib字节码工具呈现结果:
此时由于静态变量name
和age
被final
修饰,在准备环节直接完成赋值,分别被赋予定义的xiaohao
和24
。
(3)解析Resolution
连接阶段的最后一步就是解析了,主要是将类中的符号引用转换为直接引用。
说白了,符号引用类似于数据库中的非聚簇索引,想要获取一条数据,需要查询两次,先获取主键,再通过主键获取完整数据。直接引用类似于聚簇索引,直接通过主键获取了完整数据。
直接引用直接指向了内存中的某一个具体地址,可以直接定位到目标,效率更高,进一步降低了内存开销。
3、初始化Initialization
上述加载、连接步骤都正常之后,进入初始化阶段。执行类的初始化方法<clinit>
。
主要是完成静态代码块的执行并且为静态变量赋值。
类执行初始化通常包含以下几种方式:
-
使用
new
关键字创建对象实例 -
访问类的静态变量或静态方法
-
对类进行反射操作
-
执行
Main
方法所在的类
这里需要注意一下,子类调用初始化方法之前,会优先调用父类的初始化方法,如果父类存在静态代码块,也会优先执行
同时初始化方法<clinit>
的执行顺序与我们编写的代码顺序是一致的。
举个例子:
public class UserInfo {
// 定义静态变量放在静态代码块之前
public static int age = 24;
static {
age = 25;
}
}
打印UserInfo.age
,控制台输出:
25
调换静态变量定义与静态代码块顺序:
public class UserInfo {
// 静态代码块放在定义静态变量之前
static {
age = 25;
}
public static int age = 24;
}
控制台输出:
24
查看字节码指令,也验证了初始化方法<clinit>
的执行顺序与代码流程保持一致:
4、使用Using
经历上述阶段,代表类已经正式成型了,可以进行使用了,执行具体的业务逻辑。
5、卸载Unloading
类生命周期最后一个阶段即为类的卸载,卸载条件相对苛刻,其需要同时满足三个条件:
- 类的所有实例对象都已经被垃圾回收,堆内存中不存在类对象及其子类对象。
- 加载此类的类加载器也被回收(运行期间基本不太可能)。
- 类对应的
Class
对象没有在任何地方被引用,即无法使用反制机制获取到该类信息。
五、经典案例
接下来小豪结合几个大厂经典笔试题,带大家深入理解一下类的生命周期:
(1)No.1
public class JvmStudy {
// 构造方法
public JvmStudy(){
System.out.println("B");
}
// 初始化块
{
System.out.println("C");
}
// 静态代码块
static {
System.out.println("D");
}
public static void main(String[] args) {
System.out.println("A");
new JvmStudy();
new JvmStudy();
}
}
控制台输出:
DACBCBD
解析:
- 执行
main
方法,自动触发当前类的初始化,JVM首先会先加载JvmStudy
类并初始化,触发静态代码块的执行,输出单词D
- 其次执行
main
方法首行代码,打印单词A
- 最后创建了两个
JvmStudy
对象,由于该类已经被加载,不会再打印单词D
,依次执行类的初始化块和构造方法,输出单词CBCB
(2)No.2
public class JvmStudy {
public static void main(String[] args) {
// 调用Cat类的静态变量catSay
System.out.print(Cat.catSay);
}
}
class Animal{
public static String animalSay = "I'm Animal";
static {
System.out.println("Animal静态代码块");
}
}
// Cat类继承Animal父类
class Cat extends Animal{
public static String catSay = "I'm Cat";
static {
System.out.println("Cat静态代码块");
}
}
控制台输出:
Animal静态代码块
Cat静态代码块
I'm Cat
解析:
- 在上文介绍的初始化阶段我们了解到,JVM会优先加载类的父类,由于
Cat
类继承自Animal
父类,则会优先调用父类的初始化方法,执行Animal
类的静态代码块。 - 其次执行自身
Cat
类的静态代码块
(3)No.3
public class JvmStudy {
public static void main(String[] args) {
// 修改点一:Cat.catSay -> Cat.animalSay
System.out.print(Cat.animalSay);
}
}
class Animal{
// 修改点二:修饰符final
public static final String animalSay = "I'm Animal";
static {
System.out.println("Animal静态代码块");
}
}
class Cat extends Animal{
public static String catSay = "I'm Cat";
static {
System.out.println("Cat静态代码块");
}
}
控制台输出:
I'm Animal
解析:
此时animalSay
静态变量已经被final
修饰,其会被认为是常量,对应的值在编译时就已经确定,JVM会直接为其赋值,直接被打印输出,不会进行初始化Animal
和Cat
类。
(4)No.4
public class JvmStudy {
public static void main(String[] args) {
// 创建B对象
new B();
}
}
class A {
public A() {
System.out.println("class A");
}
{
System.out.println("I'm A class");
}
static {
System.out.println("class A static");
}
}
// B类继承自A类
class B extends A {
public B() {
System.out.println("class B");
}
{
System.out.println("I'm B class");
}
static {
System.out.println("class B static");
}
}
控制台输出:
class A static
class B static
I'm A class
class A
I'm B class
class B
解析:
没错,还是同样的思路
- 实例化
B
对象时,先加载B
的父类A
,执行A
的初始化,输出class A static
- 然后执行
B
的初始化,输出class B static
- 其次执行
A
类的实例化,依次执行A
的初始化块和构造方法,最后执行B
的初始化块和构造方法
六、后记
本文从类的生命周期开始介绍,过程中详解了连接阶段的三个步骤,同时结合经典案例来分析类加载的执行顺序,相信大家已经掌握了类的加载机制。
本文相对来说较易理解,更多的是偏理论层面的知识,大家看到这里的只是JVM体系中的九牛一毛,但是理解类的加载机制,对于我们后续进行Java程序的性能调优还是具有实际意义的。
未来一段时间,小豪将会持续更新JVM相关知识体系,如果大家觉得内容还不错,可以先点点关注,共同进步~
更多推荐
所有评论(0)