通过 HelloWorld 深入剖析 JVM 启动过程
在 Java 开发中,理解 JVM 的启动过程对于调试和性能优化至关重要。本文将通过一个简单的 HelloWorld 示例,深入探讨从运行 java 命令到应用程序启动的全过程,帮助开发人员更好地理解 JVM 的内部机制。
文章目录
- [通过 HelloWorld 深入剖析 JVM 启动过程](#通过 HelloWorld 深入剖析 JVM 启动过程)
-
- [1. 概述](#1. 概述)
- [2. 从 Java 命令到 JVM 启动](#2. 从 Java 命令到 JVM 启动)
-
- [2.1 Java 命令与初始调用](#2.1 Java 命令与初始调用)
- [2.2 参数验证](#2.2 参数验证)
- [2.3 系统资源检测](#2.3 系统资源检测)
- [2.4 环境准备](#2.4 环境准备)
- [3. 加载、链接和初始化](#3. 加载、链接和初始化)
-
- [3.1 垃圾回收器选择](#3.1 垃圾回收器选择)
- [3.2 缓存数据存储加载](#3.2 缓存数据存储加载)
- [3.3 创建方法区](#3.3 创建方法区)
- [3.4 类加载](#3.4 类加载)
- [3.5 类链接](#3.5 类链接)
- [3.6 类初始化](#3.6 类初始化)
- [4. 优化 JVM 启动性能](#4. 优化 JVM 启动性能)
-
- [4.1 类加载的影响](#4.1 类加载的影响)
- [4.2 莱顿计划(Project Leyden)](#4.2 莱顿计划(Project Leyden))
- [4.3 JVM 标志和调优](#4.3 JVM 标志和调优)
- [5. 结论](#5. 结论)
1. 概述
运行 Java 应用程序时,JVM 会执行一系列复杂的初始化步骤,这些步骤为 Java 应用程序的运行奠定了基础。通过了解这些内部机制,我们可以显著提升调试和性能调优的效率。
2. 从 Java 命令到 JVM 启动
在 JVM 能够执行任何代码之前,它必须先启动、验证输入并配置其运行环境。本节将介绍从运行 java 命令到初始化 JVM 运行时的早期启动序列。
2.1 Java 命令与初始调用
当我们运行 java 命令时,JVM 启动序列首先调用 JNI 方法 JNI_CreateJavaVM()。该方法负责执行必要的初始化任务,为 Java 应用程序的运行做好准备。Java 原生接口(JNI)作为 JVM 与本地系统库之间的桥梁,实现了 Java 代码与平台特定功能之间的无缝双向通信。
bash
java -Xlog:all=trace HelloWorld
2.2 参数验证
JVM 首先验证我们传递的参数,确保它们有效后才会继续执行。此验证步骤有助于在启动过程早期发现许多常见的配置错误,防止它们在后续阶段引发问题。
plaintext
[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
2.3 系统资源检测
接下来,JVM 会识别可用的系统资源,例如处理器数量、内存大小和关键系统服务。这些信息指导 JVM 做出一些内部决策,例如默认选择哪个垃圾回收器。
plaintext
[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.
2.4 环境准备
然后,JVM 通过生成 HotSpot 性能数据来准备运行时环境。这些数据会被 JConsole 和 VisualVM 等性能分析工具使用。
plaintext
[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
3. 加载、链接和初始化
一旦 JVM 环境准备就绪,它将开始为执行我们的程序做准备。
3.1 垃圾回收器选择
JVM 内部的一个关键步骤是选择垃圾回收器。从 JDK 23 开始,默认情况下,JVM 选择 G1 GC,除非系统内存小于 1792MB 和/或系统是单处理器系统。
plaintext
[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
3.2 缓存数据存储加载
JVM 开始寻求优化。CDS(Class Data Sharing)是一个已经过预处理的类文件归档,它可以显著提高 JVM 的启动性能。
plaintext
[cds] trying to map [Java home]/lib/server/classes.jsa
[cds] Opened archive [Java home]/lib/server/classes.jsa
3.3 创建方法区
JVM 随后会创建方法区,这是一个特殊的堆外内存区域,用于存储类数据。HotSpot JVM 实现将此区域称为元空间。
plaintext
[metaspace,map ] Trying anywhere...
[metaspace,map ] Mapped at 0x000001f32b000000
3.4 类加载
类加载是一个三步过程:找到类的二进制表示、从中派生出类,并将其加载到方法区。动态加载类的能力使得像 Spring 和 Mockito 这样的框架能够在 JVM 运行时按需加载生成的类。
我们可以通过两种方式加载类:使用引导类加载器(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 运行的基础。引导类加载器在 JNI_CreateJavaVM() 期间被实例化,它负责处理简单 HelloWorld 程序的所有类加载。
3.5 类链接
类链接包含三个子过程------验证、准备和解析。这些步骤并非按顺序进行,解析可以在验证之前、类初始化之后的任何时间发生。验证确保类结构正确:
plaintext
[class,init] Start class verification for: HelloWorld
[verification] Verifying class HelloWorld with new format
[verification] Verifying method HelloWorld.<init>()V
CDS 中的类已经经过验证,因此可以跳过此步骤,从而提高启动性能。这是 CDS 提供者的一个关键优势。在准备阶段,JVM 会将静态字段初始化为其默认值。任何没有显式初始化器的静态变量将自动获得其默认值。
在解析阶段,JVM 会解析常量池中的符号引用。常量池存储了类的所有符号引用,JVM 必须在执行相应指令之前解析它们。
我们可以通过 javap 查看这些内容:
bash
javap -verbose HelloWorld
这将显示常量池:
plaintext
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),这些条目描述了方法和字段。在解析阶段,JVM 将这些符号条目转换为可执行的真实内存引用:
plaintext
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 类初始化
类初始化会为静态字段赋值并执行静态初始化器。这与实例初始化(发生在调用构造函数时)不同。clinit 方法由 javac 自动生成,用于处理类初始化。
4. 优化 JVM 启动性能
尽管 JVM 的启动已经很高效,但仍有改进空间。以下是一些优化建议。
4.1 类加载的影响
为了测量 JVM 启动、加载类、链接类并执行我们简单程序所需的总时间,我们可以使用系统的时间实用程序:
bash
time java HelloWorld
这将测量从 JVM 进程启动到退出的墙钟时间,包括类加载、链接、JIT 预热和程序执行------而不仅仅是用户代码。对于 HelloWorld,JVM 在启动时大约会加载 400-450 个类。在现代硬件上,即使启用了详细日志记录,整个过程也仅需大约 60 毫秒。
4.2 莱顿计划(Project Leyden)
莱顿计划旨在缩短启动时间、缩短达到峰值性能所需的时间并减少内存占用。JDK 24 引入了 JEP 483:提前类加载和链接,它会在启动前而不是启动时执行这些操作,从而显著提升启动性能。
此功能会在训练运行期间记录 JVM 的行为,将其存储在缓存中,并在后续启动时从缓存中加载。这将取代 CDS 的过渡,并最终引入 AOT(提前编译)以更好地涵盖新功能。
4.3 JVM 标志和调优
虽然可以通过使用静态字段和初始化器来优化启动性能,但我们应该谨慎对待。大部分执行的代码来自依赖项,而不是我们自己的应用程序代码。因此,合理使用 JVM 标志(如 -XX:+UseG1GC 和 -XX:MaxMetaspaceSize)可以进一步优化性能。
5. 结论
本文深入剖析了 JVM 在启动过程中经历的复杂流程,从验证用户输入、检测系统资源到加载、链接和初始化类。即使是简单的 HelloWorld 应用程序,JVM 也会在执行代码之前准备整个运行时环境,加载数百个类。随着 Project Leyden 的 AOT 功能等改进措施的推出,启动性能将持续提升。对于开发人员而言,理解这些机制不仅有助于调试和性能优化,还能帮助我们更好地设计和优化 Java 应用程序。