探索Java类加载的奥秘:从加载到初始化的完整指南
前言
Java 一直以来备受青睐,是“互联网大厂”企业级后端服务偏爱的语言。我们通过 TIOBE 编程排行榜也可一览其平均江湖地位和受欢迎程度。
近两年随着人工智能的兴起(风头正盛),Python赶超跃居榜首,Java看似不再佔據前两位。查看2023年8月份的榜单时,甚至已被挤出前三!难道Java已经凉凉?已跌落神坛?实际上,Java的需求依然是很大,Java在后端语言的领导地位仍无法撼动「手动狗头」(但不可否认的,很卷、市场饱和、门槛已经大幅度提升...)。
这主要还得归因于它很多优化点和特性。如跨平台、面向对象编程、强大的开源生态和社区、稳定性和可靠性、安全性等。
跨平台 这个特性,绝对是Java的巨大优势。是实现号称“一次编译到处运行”的关键。而实现这一目标的重要环节就是类加载。
一、程序的执行过程
要理解类的加载,那先了解在java中一个类的执行过程。
废话不多说了,上代码:
package com.jvm.test;
public class Book {
public static void main(String[] args) {
String name = "《三体》";
System.out.printf("一本你不看,都不知道何为“惊艳”二字的书:" + name);
}
}
简单的代码,那具体的执行过程是怎样的呢?
如上图,java源文件通过编译器编译为.class字节码文件,字节码文件通过JVM虚拟机,先进行字节码校验,然后再通过解释器 或 JIT编译器 翻译为特定平台的本地机器码,最终实现在不同的操作系统上运行。
其中,关于字节码翻译为机器码,JVM并不一定总是先将字节码翻译成本地机器码。JVM有两种执行策略:
- 解释执行:JVM会逐条解释执行字节码指令,将其翻译成本地机器码后直接执行。一句话,就是边翻译边执行。
- JIT编译:JIT编译器会将经常执行的热点代码翻译成机器码,缓存起来以供后续使用,以提高性能。并且通常只对热点代码进行一次编译。然后在后续执行中直接使用已翻译好的本地机器码。
而如何判断是否为热点代码,会使用到一种叫“热点探测”的技术。这个不是本章的重点,了解下即可。
Java编译器将源代码编译为字节码,这是一种与具体平台无关的中间表示。然后,JVM负责将字节码翻译成适用于特定操作系统和硬件平台的机器码。从软件层面屏蔽了不同操作系统在底层硬件与指令的区别。使得Java应用程序可以在不同的操作系统上运行,从而实现“一次编译到处运行“的跨平台目标。
二、类的加载过程
当我们通过开发工具(IDEA、Eclipse等)运行这个类的main
方法来启动程序时,实际上也是在后台调用java
命令来执行该类的.class字节码文件。‘
通过Java命令执行代码后,其底层大体流程如下:
再看一个类在JVM中的生命周期:
而类的加载过程需要经过前五个步骤,即:加载 >> 验证 >> 准备 >> 解析 >> 初始化。
-
加载(Loading):类加载过程的第一步,类加载器会根据类的全限定名查找并加载类的字节码。将字节码从磁盘上查找并加载到内存,并创建一个
java.lang.Class
对象表示这个类,作为方法区这个类的各种数据的访问入口。
需要注意的是,是使用到这个类才会加载,例如调用类的main()方法、new对象等。 -
链接(Linking): 链接阶段分为三个子阶段:
-
验证(Verification): 验证被加载类的字节码,确保它符合Java虚拟机规范,不会引发安全问题。
-
准备(Preparation): 为类的静态变量分配内存,并赋默认的初始值。
例如下面的代码,在准备阶段,只会为price属性分配内存和赋初始值0,而不会为name属性分配内存。
这里的初始值,一般是根据数据类型来决定的,比如数值类型会设置为0, 引用类型会设置为null
。至于用户希望的最终值的赋予,将在初始化阶段进行。
例如下面代码,在这个阶段赋予price属性的初始值是0,而不是45。public static int price = 45; public String name = "《三体》";
但如果一个变量是常量(被static final修饰)的话,那么在这个阶段,属性将会被赋予用户希望的值。例如下面的代码,在这个阶段,num的值将直接是99,而不是0。
public static final int num = 99;
为何static final变量会直接赋予用户希望的值,而static变量会被赋予零值?稍微想一下也能明白了。
final关键字,在Java中代表不可变的意思。既然一旦赋值就不会再变,那就在准备阶段直接赋予用户希望的值。而没有被final修饰的静态变量,而没有被filal修饰的静态变量,可能在初始化阶段或运行阶段还会发生变化,所以没必要在准备阶段赋予最终值。
-
-
解析(Resolution): 将符号引用(例如类、方法、字段的引用)解析为直接引用(内存地址)。
-
初始化(Initialization): 在这一阶段,类的静态初始化代码块(
static
块)会被执行,静态变量会被赋予初始值。
例如下面的代码,在初始化阶段,将会触发静态变量book对象的实例化、静态代码块将会被执行、静态变量price将会被赋予用户希望的值45。static TestDynamicLoadBook book = new TestDynamicLoadBook(); static { System.out.println("书的静态代码块"); } public static int price = 45;
-
使用(Using): 当JVM完成初始化后,JVM便开始从入口方法执行程序代码。
-
卸载(Uninstall): 当程序执行完毕,JVM便开始销毁创建的Class对象,最后负责运行的JVM也退出内存。
类被加载到方法区后,主要包含 运行时常量池、类型信息、字段信息、方法信息、类加载器的引用、对应class实例的引用等信息。
类加载器的引用:这个类到类加载器实例的引用。
对应class实例的引用:类加载器把类信息加载放到方法区后,会创建一个对应的java.lang.Class
对象实例放到堆(Heap)中,作为开发人员访问方法区中类定义的入口。
另外,主类在运行过程中如果使用到其它类,会逐步加载这些类。jar包 或 war包的类也不是一次性加载,是使用到才加载。
三、牛刀小试
理解了类的加载过程,那我们来看一个栗子。
看看输出结果是依次是什么。
package com.jvm;
public class TestDynamicLoadBook {
public static void main(String[] args)
{
staticFunc();
System.out.printf("price="+price);
}
static TestDynamicLoadBook book = new TestDynamicLoadBook();
static
{
System.out.println("书的静态代码块");
}
{
System.out.println("书的普通代码块");
}
TestDynamicLoadBook()
{
System.out.println("书的构造方法");
System.out.println("name=" + name +",author=" + author +",price=" + price);
}
public static void staticFunc(){
System.out.println("书的静态方法");
}
public static int price = 45;
public static final String author = "刘慈欣";
public String name = "《三体》";
}
输出结果是:
书的普通代码块
书的构造方法
name=《三体》,author=刘慈欣,price=0
书的静态代码块
书的静态方法
price=45
下面来分析一下代码的整个执行过程。
首先,再回顾类加载过程的几个步骤:加载、链接(验证、准备、解析)、初始化。因为准备 和 初始化 是影响我们这个示例输出结果的两个重要阶段。
- 准备阶段:为静态变量分配内存并设置初始值。
- 初始化阶段:静态变量被赋予用户期望的值,静态代码块会被执行。
代码示例分析:
-
首先,在准备阶段,静态变量
book
被分配内存,初始值为null
; 静态变量price
也被分配内存,初始值为0
; 常量author
被分配内存并赋值为“刘慈欣”;static TestDynamicLoadBook book = new TestDynamicLoadBook(); public static int price = 45; public static final String author = "刘慈欣";
-
然后,进入初始化阶段:
a.Book
类被触发实例化,也就是创建book
对象。先执行对象的实例初始化块,也就是普通代码块,因此输出:「书的普通代码块」;在Java中,无论使用哪种构造器构造对象,首先都会运行初始化块(实例初始化块),然后才会运行构造器的主体部分。
然后执行构造方法,输出:「书的构造方法」、「name=《三体》,author=刘慈欣,price=0」。
这个结果,关于 name=《三体》和 price=0 可能有点小疑问。
先解释price: 这里的
price
为0,是因为在Java中,代码的执行是按代码在类文件中的顺序依次执行的。在我们的示例中,public static int price = 45;
语句在static TestDynamicLoadBook book = new TestDynamicLoadBook();
语句之后,初始化阶段,此时还才执行到book
对象的实例化的构造方法的调用,静态变量price
的初始化还未被执行到,因此尚未被赋值。解释name=《三体》:name是Book类的成员变量,在执行 static TestDynamicLoadBook book = new TestDynamicLoadBook(); 语句后进行对象的实例化,构造器会被调用,成员变量就会被赋予预期的值。
b. 执行静态代码块,输出:「书的静态代码块」
-
最后,在
main
方法中:
a. 静态方法staticFunc
被调用,输出:「书的静态方法」。
b. 打印静态变量price
的值,此时price
的值已经在初始化阶段赋值为45
,所以输出: 「price=45」。
写到最后
今天,我们介绍Java类加载的全过程,还深入研究了类加载的几个很重要阶段。希望能够为你对类加载的理解带来些许启发和帮助。
作为一个第一次正式尝试写技术文章的小白,心里有些小激动和小紧张。文中可能有些地方理解得不准确,不对的地方,希望大佬们不吝赐教。
写文章的的初衷,主要是为了加深对知识的理解、强化记忆、构建更完整的知识体系、同时锻炼自己的表达能力。
如果你觉得文章还行,不妨点个赞、留个言,让我知道你来过,对我将是一种极大的正反馈。
参考资料: