或者说……当你运行一个 Java 程序时,究竟发生了什么?
César Soto Valero 发布于 2022 年 10 月 20 日
如果你正在阅读本文,很可能你已经知道如何编写 Java 代码。这非常好!我认为如今每个人都应该掌握编程(就像每个人都应该了解基本的数学运算,如 +、-、*、/,即使我们都有计算器一样)。
在之前的一篇文章中,我介绍了 Java 代码首先被“编译”为字节码,然后由 JVM 解释并执行。然而,我当时并未解释 JVM 实际上是如何执行字节码的。
本文的目的正是填补这一空白。我将回答这个问题:
当你在最喜欢的 IDE 中点击“执行”按钮时,到底发生了什么?
阅读完本文后,你将理解 Java 应用程序的执行生命周期,以及 JVM 在执行阶段所进行的各项活动。
1. 执行生命周期
Java 应用程序的执行生命周期大致可分为三个阶段:
- 编译(Compilation):应用程序的源代码通过
javac编译器转换为字节码。 - 类加载(Class Loading):字节码被加载到内存中,并准备好必要的类文件以供执行。
- 字节码执行(Bytecode Execution):JVM 执行字节码,程序开始运行。
JVM 负责管理最后这个阶段,包括:
- 加载字节码
- 分配内存
- 将字节码转换为本地机器码
换句话说,JVM 负责将字节码翻译成特定目标平台的机器码并执行它。这是一个复杂的过程,因为每种微处理器架构(如 x86、ARM、MIPS、PowerPC 等)都“理解”不同的指令集。
此外,JVM 还提供运行时服务,例如内存管理、线程同步和异常处理。
本文重点聚焦于字节码执行阶段。下图的活动图展示了该阶段发生的主要流程:
以下各节将详细说明字节码执行阶段中每个活动的具体内容。
1.1 加载(Loading)
加载是指根据特定名称查找类或接口的二进制形式(即 class 文件格式),并从中构造出一个 Class 对象的过程。
JVM 使用 ClassLoader 来查找 Main 类的二进制表示。ClassLoader 类及其子类实现了加载过程,其中 defineClass 方法用于从 class 文件的二进制数据构造 Class 对象。
JVM 提供两类内置类加载器:
- 引导类加载器(Bootstrap Class Loader):加载来自
rt.jar的核心 Java 类。 - 扩展类加载器(Extension Class Loader):从
ext目录加载类。
此外,还可以使用应用程序类加载器从其他位置(如 classpath 或远程服务器)加载类。这些通常是 ClassLoader 的自定义子类,可通过 java.lang.Class 实例动态加载类。
下面是一个自定义类加载器的示例:
public class CustomClassLoader extends ClassLoader {
public CustomClassLoader(ClassLoader parent) {
super(parent);
}
// 根据类名加载类的方法
public Class<?> loadClass(String name) throws ClassNotFoundException {
if (!name.startsWith("com.example")) {
// 若不是 com.example 开头,则委托给父加载器
return super.loadClass(name);
}
// 构造文件名
String fileName = name.substring(name.lastIndexOf('.') + 1) + ".class";
// 尝试打开输入流
InputStream inputStream = getClass().getResourceAsStream(fileName);
if (inputStream == null) {
throw new ClassNotFoundException();
}
try {
byte[] bytes = new byte[inputStream.available()];
inputStream.read(bytes);
// 使用 defineClass 定义类
return defineClass(name, bytes, 0, bytes.length);
} catch (IOException e) {
throw new ClassNotFoundException();
}
}
}
该类继承 ClassLoader 并重写了 loadClass 方法:
- 如果类名不以
com.example开头,就委托给父加载器; - 否则,尝试从资源路径加载
.class文件,读取字节并调用defineClass。
总结:类加载过程执行以下三项功能:
- 从 class 文件创建二进制数据流
- 按照内部数据结构解析该二进制数据
- 创建
java.lang.Class的实例
完成后,该 Class 实例即可进入链接阶段。
1.2 链接(Linking)
链接是指将类或接口的二进制形式整合到 JVM 的运行时状态中,使其可以被执行。链接包含三个步骤:
1. 验证(Verification)
检查加载的类表示是否结构良好,符号表是否正确。同时验证类的字节码是否符合 Java 语言和 JVM 的语义要求,例如:
- 每条指令的操作码是否有效
- 所有跳转指令是否指向另一条指令的起始位置(而非中间)
- 每个方法是否有正确的签名
2. 准备(Preparation)
为类或接口创建静态字段(类变量和常量),并将其初始化为默认值(如 int 默认为 0)。此过程会分配静态存储空间,并构建 JVM 内部所需的数据结构(如方法表[^1])。
3. 解析(Resolution,可选)
解析类中对其他类或接口的符号引用。通过加载被引用的类并验证引用是否合法来完成。
对比 C 语言:在简单的 C 语言静态链接中,编译后的程序(如
a.out)包含所有已完全链接的库函数副本。
而在 Java 中,符号引用采用惰性解析(lazy resolution)——仅在实际使用时才解析。例如,如果一个类包含多个对另一个类的引用,这些引用可能逐个解析,甚至可能完全不解析(如果程序从未使用它们)。
总结:链接包含三个阶段
- 验证
- 准备
- 解析(可选)
完成后,类即可进入初始化阶段。
1.3 初始化(Initializing)
类的初始化是指执行其静态初始化器和静态字段的初始化表达式。这些代码按其在源文件中出现的顺序依次执行。
考虑以下示例:
class Main extends Object {
static int x = 1; // 静态字段初始化器(先执行)
static int y; // 无初始化器
static { // 静态初始化块(其次执行)
y = x + 1;
}
static int z = x + y; // 静态字段初始化器(最后执行)
public static void main(String[] args) {
// main 方法在类初始化完成后才执行
}
}
当 JVM 初始化 Main 类时:
- 首先递归初始化其所有超类(从
Object开始) - 然后按源码顺序执行
Main中的静态字段初始化器和静态块
重要规则:在 Java 中,超类总是在子类之前初始化。
触发类 T 初始化的常见情形包括:
- 创建 T 的实例
- 调用 T 的静态方法
- 给 T 的静态字段赋值
- 使用 T 的非常量静态字段
- 通过反射调用 T 的方法
一旦所有类完成初始化,JVM 就会开始实例化对象。
1.4 实例化(Instantiating)
显式创建新实例通常通过 new 表达式完成,例如:
Point magicPoint = new Point(42, 42);
但实例也可能隐式创建,例如:
- 加载包含字符串字面量或文本块的类时,可能创建新的
String对象 - 执行装箱转换(如
int→Integer)时,可能创建包装类对象 - 字符串拼接操作可能创建新的
String对象 - 方法引用或 Lambda 表达式求值可能创建函数式接口的实例
实例化过程中执行以下步骤:
- 在堆上分配内存以容纳新对象
- 调用类的构造函数初始化对象
- 返回新对象的引用
1.5 终结(Finalizing)
终结是指在对象被垃圾回收前清理其所持有的资源。Object 类定义了一个 finalize() 方法,垃圾回收器在回收对象前会调用它。
子类可重写 finalize() 以执行必要的清理操作。例如:
public class TempFile {
private File file;
public TempFile(String filename) {
file = new File(filename);
}
@Override
protected void finalize() throws Throwable {
file.delete(); // 删除临时文件
super.finalize();
}
}
⚠️ 注意:
finalize()不保证会被调用,不应依赖它执行关键任务。它仅作为对象回收前的辅助清理机制。
1.6 卸载(Unloading)
卸载是指从 JVM 运行时状态中移除类或接口(例如,当定义该类的类加载器被垃圾回收时)。类卸载可减少内存占用,因此主要对动态加载大量类并在之后不再使用的应用有意义。
以下示例演示了用户定义的垃圾回收场景:
import java.lang.ref.WeakReference;
public class LargeClass {
private int[] data = new int[Integer.MAX_VALUE]; // 占用大量内存
public static void main(String[] args) {
LargeClass largeObject = new LargeClass();
WeakReference<LargeClass> weakRef = new WeakReference<>(largeObject);
largeObject = null; // 不再强引用
System.gc(); // 建议执行 GC
if (weakRef.get() == null) {
System.out.println("LargeClass 对象已被回收");
} else {
System.out.println("LargeClass 对象未被回收");
}
}
}
注意:由引导类加载器加载的类永远不会被卸载。因此,在典型的独立应用中,由于系统类加载器在整个程序生命周期内都处于活跃状态,类卸载并不常见。
与对象垃圾回收不同,类卸载指的是移除类的定义(即 Class 对象)及其元数据。这通常发生在加载该类的类加载器本身被垃圾回收时。
以下示例更可能触发类卸载:
import java.lang.ref.WeakReference;
import java.net.URL;
import java.net.URLClassLoader;
public class ClassUnloadingExample {
public static void main(String[] args) throws Exception {
URL classUrl = new URL("file:///path/to/LargeClass.class");
URLClassLoader customClassLoader = new URLClassLoader(new URL[]{classUrl});
Class<?> largeClass = Class.forName("LargeClass", true, customClassLoader);
WeakReference<ClassLoader> weakClassLoaderRef = new WeakReference<>(customClassLoader);
customClassLoader = null;
largeClass = null;
System.gc();
Thread.sleep(1000); // 等待 GC
if (weakClassLoaderRef.get() == null) {
System.out.println("自定义类加载器已被回收,表明 LargeClass 可能已被卸载");
} else {
System.out.println("自定义类加载器仍在内存中");
}
}
}
在此例中,通过自定义 URLClassLoader 加载类,随后清除所有强引用。若该加载器被回收,则其加载的类很可能也被卸载[^3]。
1.7 程序退出(Program Exit)
程序退出指终止程序的执行。这发生在:
- 所有非守护线程终止,或
- 某线程调用了
Runtime.exit()方法
exit() 会停止 JVM 并返回指定的退出码。但若存在安全管理器且不允许退出,则会抛出 SecurityException。
2. 结论
本文深入探讨了 Java 应用程序的执行生命周期。正如所见,在 main 方法真正执行之前,JVM 已经完成了大量复杂的工作——从类加载、链接、初始化,到实例化、终结,甚至可能的卸载。
理解这些机制对开发者至关重要,它有助于我们:
- 更深入地掌握 JVM 工作原理
- 编写出更高效、更可靠的 Java 应用
希望你喜欢这篇文章,并有所收获 😎。