深入探索剖析 JVM 的启动过程

你可曾想过:当你在终端里敲下 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.Classjava.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专题

相关推荐
Arva .2 小时前
ConcurrentHashMap 的线程安全实现
java·开发语言
听风吟丶2 小时前
Java 9+ 模块化系统(Jigsaw)实战:从 Jar 地狱到模块解耦的架构升级
java·架构·jar
昂子的博客2 小时前
Redis缓存 更新策略 双写一致 缓存穿透 击穿 雪崩 解决方案... 一篇文章带你学透
java·数据库·redis·后端·spring·缓存
百***68822 小时前
SpringBoot中Get请求和POST请求接收参数详解
java·spring boot·spring
百***41662 小时前
Java MySQL 连接
java·mysql·adb
Jayden3 小时前
synchronized全解析:从锁升级到性能优化,彻底掌握Java内置锁
java·synchronized·synchronized面试·synchronized扫盲
任子菲阳3 小时前
学Java第四十五天——斗地主小游戏创作
java·开发语言·windows
czhc11400756633 小时前
Java1112 基类 c#vscode使用 程序结构
android·java·数据库
嫂子的姐夫3 小时前
23-MD5+DES+Webpack:考试宝
java·爬虫·python·webpack·node.js·逆向