你可曾想过:当你在终端里敲下 java,在 main 方法真正运行之前,JVM 为了"创造一个可运行你的程序的宇宙",到底经历了哪些步骤?从参数校验、系统资源探测,到选择垃圾回收器,再到类的加载、链接与初始化,这些看不见的过程决定了应用的启动体验与后续性能。本文用一个极简的 HelloWorld 贯穿全程,结合详细日志,一步步洞察 JVM 的启动机制,帮你在调试和性能优化时更有抓手。
1. 概览
当我们运行一个 Java 应用时,JVM 在我们的代码真正开始执行之前,会先完成一系列复杂步骤。本文将从执行 java 命令的那一刻开始,一直走到应用就绪。
我们以一个简单的 HelloWorld 程序为例,拆解每一个阶段。理解这些内部机制能显著提升调试与性能调优的效果。
2. 从 java 命令到 JVM 启动
在 JVM 执行任何代码之前,它需要先启动、校验输入并配置运行环境。下面按启动顺序走一遍早期流程:从调用 java 命令到初始化 JVM 运行时。
2.1. java 命令与初始调用
当我们运行 java 命令时,JVM 启动序列会通过 JNI 方法 JNI_CreateJavaVM() 开始执行。该方法完成若干关键初始化任务,为执行 Java 应用准备环境。Java Native Interface(JNI)是 JVM 与原生系统库之间的桥梁,使 Java 与平台特性可以双向通信。
本文将使用详细日志观察 JVM 的内部运作,例如:
bash
java -Xlog:all=trace HelloWorldCopy
2.2. 校验用户输入
首先,JVM 会校验我们传入的参数:
text
[0.006s][info][arguments] VM Arguments:
[arguments] jvm_args: -Xlog:all=trace:file=helloworld.log
[arguments] java_command: HelloWorld
[arguments] java_class_path (initial): .
[arguments] Launcher Type: SUN_STANDARD
JVM 会验证目标可执行、类路径以及任何 JVM 参数,确保它们在继续执行前都是有效的。这个步骤能尽早捕获很多常见配置错误,避免后续阶段出现更难定位的问题。
2.3. 检测系统资源
接着,JVM 会识别可用的系统资源,例如处理器数量、内存大小以及关键系统服务:
text
[0.007s][debug][os ] Process is running in a job with 20 active processors.
[os ] Initial active processor count set to 20
[os ] Process is running in a job with 20 active processors.
[gc,heap ] Maximum heap size 4197875712
[gc,heap ] Initial heap size 262367232
[gc,heap ] Minimum heap size 6815736
[os ] Host Windows OS automatically schedules threads across all processor groups.
[os ] 20 logical processors found.
这些信息会影响 JVM 的一些内部决策,比如默认选择哪个垃圾回收器。可用 CPU 数和总内存会直接影响 JVM 的启发式选择。不过,大多数设置都可以通过显式的 JVM 参数进行覆盖。在这个阶段,JVM 还会检查是否支持 Native Memory Tracking,并验证它可能依赖的各类操作系统工具的可用性。
2.4. 环境准备
随后,JVM 会生成 HotSpot 性能数据。这些数据会被 JConsole、VisualVM 等工具用于检查和分析 JVM:
text
[perf,datacreation] name = sun.rt._sync_Inflations, dtype = 11, variability = 2, units = 4, dsize = 8, vlen = 0, pad_length = 4, size = 56, on_c_heap = FALSE, address = 0x000001f3085f0020, data address = 0x000001f3085f0050
这类性能数据通常存储在系统的 /tmp 目录下,并会在启动阶段的一段时间里持续生成,与其他初始化任务并行进行。
3. 加载、链接与初始化
当 JVM 环境就绪后,它会开始为我们的程序执行做准备。
3.1. 选择垃圾回收器
在 JVM 内部,一个关键步骤是选择垃圾回收器(GC)。截至 JDK 23,默认情况下 JVM 会选择 G1 GC,除非系统可用内存少于 1792MB 和/或仅有单处理器:
text
[gc ] Using G1
[gc,heap,coops ] Trying to allocate at address 0x0000000705c00000 heap of size 0xfa400000
[os ] VirtualAlloc(0x0000000705c00000, 4198498304, 2000, 4) returned 0x0000000705c00000.
[os,map ] Reserved [0x0000000705c00000 - 0x0000000800000000), (4198498304 bytes)
[gc,heap,coops ] Heap address: 0x0000000705c00000, size: 4004 MB, Compressed Oops mode: Zero based, Oop shift amount: 3
[pagesize ] Heap: min=8M max=4004M base=0x0000000705c00000 size=4004M page_size=4K
当然,我们也可以选择其它 GC:如 Parallel GC、ZGC 等,具体可用与默认策略依不同 JDK 版本与发行版而异。
3.2. 加载 CDS(类数据共享)
此时,JVM 会开始寻找进一步的优化机会。CDS 是一组已经经过预处理的类文件归档,可以改善 JVM 的启动性能:
text
[cds] trying to map [Java home]/lib/server/classes.jsa
[cds] Opened archive [Java home]/lib/server/classes.jsa
不过,CDS 正在被 Project Leyden 中的 AOT(提前)机制逐步替代,后文会继续讨论。
3.3. 创建方法区
JVM 随后会创建"方法区",这是一个用于存储类数据的特殊离堆内存区域。在 HotSpot 中,这一区域被称为 metaspace。当关联的类加载器不再可达时,存储于此的类数据也会被移除:
text
[metaspace,map ] Trying anywhere...
[metaspace,map ] Mapped at 0x000001f32b000000
虽然方法区不在堆中,但它仍由 GC 管理。
3.4. 类加载
类加载包含三个步骤:定位二进制表示、根据其派生出类、并将其加载到方法区。正是这种动态加载能力,让 Spring、Mockito 等框架可以在运行期按需生成并加载类。
类加载有两种方式:引导类加载器(bootstrap class loader)或自定义类加载器。下面借助一个简单的 HelloWorld 类,看看 JVM 首先会做什么:
java
public class HelloWorld extends Object {
public static void main(String[] args) {
System.out.println("Hello World!");
}
}
JVM 会优先加载 java.lang.Object 及其依赖。类在初次加载时大多处于"半隐藏"的状态,以便进行必要的验证与整理工作。
再看下 java.lang.Object 的方法:
java
public class Object {
public final native Class<?> getClass()
public String toString()
public boolean equals(Object obj)
}
这些方法分别引用了 java.lang.Class 与 java.lang.String,因此它们也需要先行加载。JVM 采用"按需加载"的策略,仅在类被实际引用时才加载。不过,上述这些对 JVM 至关重要的类会被"抢先加载"。在一个简单的 HelloWorld 程序里,由 JNI_CreateJavaVM() 初始化的引导类加载器负责所有的类加载工作。
3.5. 类链接
类链接可以拆分为验证(Verification)、准备(Preparation)与解析(Resolution),其发生顺序并不固定:解析可能发生在验证之前,也可能在类初始化之后。验证确保类结构正确:
text
[class,init] Start class verification for: HelloWorld
[verification] Verifying class HelloWorld with new format
[verification] Verifying method HelloWorld.<init>()V
位于 CDS 中的类已经过验证,因此会跳过该步骤,从而提升启动性能。这是 CDS 的重要收益之一。在"准备"阶段,JVM 会用默认值初始化静态字段,没有显式初始化器的静态变量会自动获得默认值。
在"解析"阶段,JVM 会解析常量池(Constant Pool)中的符号引用。常量池保存了类的所有符号引用,JVM 必须先将其解析为真实的内存引用,才能执行相关指令。
我们可以使用 javap 来观察:
bash
javap -verbose HelloWorldCopy
这将显示常量池的内容:
text
Constant pool:
#1 = Methodref #2.#3 // java/lang/Object."<init>":()V
#2 = Class #4 // java/lang/Object
#3 = NameAndType #5:#6 // "<init>":()V
#7 = Fieldref #8.#9 // java/lang/System.out:Ljava/io/PrintStream;
#13 = String #14 // Hello World
构造器的字节码并不直接包含地址。它引用常量池中的符号项(例如 #1),这些条目描述了方法或字段。解析阶段会将这些符号项转为可执行的真实内存引用:
text
public HelloWorld();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 2: 0
line 4: 4
第 1 行的 invokespecial 指令引用了常量池条目 #1,其中包含链接到 java.lang.Object 构造器所需的信息。<init> 表示这是由 javac 为每个构造器自动生成的特殊方法。JVM 采用"延迟解析",只有在尝试执行类中的某条指令时才触发解析;并非所有已加载的类都会实际执行其指令。
3.6. 类初始化
类初始化会为静态字段赋值并执行静态初始化器,这与我们调用构造器的实例初始化不同。该过程由 javac 自动生成的特殊方法 clinit 负责。
4. 优化 JVM 启动性能
尽管 JVM 的启动已经很高效,但仍有提升空间。以下是一些方向。
4.1. 类加载的影响
我们可以使用系统的 time 工具来度量 JVM 启动、加载类、链接并执行这个简单程序的总耗时:
bash
time java HelloWorldCopy
该工具会测量从 JVM 进程启动到退出的挂钟时间,包含类加载、链接、JIT 预热与程序执行------不仅仅是用户代码。对于 HelloWorld,JVM 在启动期间通常会加载约 400~450 个类。在现代硬件上,即便开启冗长日志,整个过程也大约在 60 毫秒左右完成。
4.2. Project Leyden
Project Leyden 的目标是减少启动时间、达到峰值性能的时间以及内存占用。JDK 24 引入了 JEP 483:Ahead-of-Time Class Loading and Linking(提前类加载与链接),将这些操作从启动时前移至 AOT 阶段。
该特性会在"训练运行"中记录 JVM 的行为,将其存入缓存,并在后续启动时从缓存加载。这将取代原先的 CDS 概念,并最终以 AOT 的更广泛能力来统一表达。
4.3. JVM 参数与调优
虽然我们可以通过静态字段与初始化器在某些场景中优化启动性能,但应谨慎对待。为了将行为挪到类加载阶段而进行重构,往往很难获得可测量的收益------特别是考虑到运行时的大部分代码来自依赖库而非我们自己的应用。
5. 结论
本文从校验用户输入、检测系统资源,到类的加载、链接与初始化,系统地梳理了 JVM 在启动阶段经历的复杂流程。即便是一个简单的 HelloWorld,JVM 也会在执行代码之前构建起完整的运行环境,加载数百个类。
随着 Project Leyden 等改进(例如 AOT)的到来,JVM 的启动性能还将进一步提升。
更多相关技术干货分享可以关注这个Java专题