Java 代码执行流程
从一行代码到屏幕输出
你有没有想过,当你在 IDE 里点击「运行」,到屏幕上出现 Hello World,JVM 到底经历了什么?
我们从一个最简单的例子开始:
public class HelloJVM {
public static void main(String[] args) {
System.out.println("Hello, JVM");
}
}这一行代码的背后,是一次完整的 JVM 旅程。
全流程概览
源代码
(.java)
│
│ javac 编译
▼
字节码文件
(.class)
│
│ 类加载器
▼
运行时数据区
(加载到内存)
│
│ 执行引擎
▼
本地机器码
(CPU 执行)
│
▼
程序输出看起来只有 5 步,但每一步背后都藏着大量细节。
第一步:编写源代码
public class HelloJVM {
public static void main(String[] args) {
System.out.println("Hello, JVM");
}
}.java 文件是人类用高级语言写的文本。机器不认识它,JVM 也不认识它。第一步要把人类语言翻译成机器能理解的中间语言——字节码。
第二步:编译成字节码
javac HelloJVM.javajavac 是 Java 编译器。它的任务是把 Java 源代码翻译成 JVM 字节码(.class 文件)。
字节码不是机器码,而是 JVM 指令的二进制表示。看看编译后生成了什么:
xxd HelloJVM.class | head -20输出大致如下(十六进制):
00000000: cafe babe 0000 0037 001a 0a00 0500 0f09 .......7........
00000010: 0003 000f 0800 100a 0011 0012 0700 1307 ................
00000020: 0014 0c00 0800 0907 0015 0c00 1600 1701 ................
00000030: 001c 6865 6c6c 6f20 776f 726c 6401 0006 ...hello world..开头的 cafe babe 是 JVM 规定的魔数(magic number),所有 Class 文件都以这四个字节开头。JVM 通过这个标记判断文件是否合法。
小知识:据说 James Gosling 在选择这个魔数时说,「Java」这个名字和爪哇咖啡有关,所以选了一个咖啡馆会有的数字:CAFE BABE(咖啡馆里的靓仔)。这当然是个玩笑,但这个约定一直延续到今天。
字节码能用文本方式查看吗?
可以,用 javap -c 反汇编:
Compiled from "HelloJVM.java"
public class HelloJVM {
public HelloJVM();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]);
Code:
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #3 // String Hello, JVM
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
}这就是 System.out.println("Hello, JVM") 的字节码表示。每条指令都对应一个 JVM 指令。
第三步:类加载
运行 java HelloJVM 时,JVM 启动,类加载器开始工作。
类加载的过程
- Loading(加载):找到
.class文件,读取字节流,创建一个Class对象 - Linking(链接):
- 验证:字节码格式是否合法
- 准备:为类的静态变量分配内存并设置默认值
- 解析:把符号引用替换为直接引用(如
#2→ 实际的System.out对象)
- Initialization(初始化):执行
<clinit>方法,给静态变量赋予真正的初始值
类加载不是一次性完成的。JVM 遵循懒加载原则:只有当代码真正用到某个类时,这个类才会被加载。比如 System 类在一开始就被引用了,所以会先加载进来。
类加载器层级
引导类加载器(Bootstrap ClassLoader)
└── 加载 JAVA_HOME/lib 下的核心类库
│
▼
扩展类加载器(Extension ClassLoader)
└── 加载 JAVA_HOME/lib/ext 下的扩展类
│
▼
应用类加载器(Application ClassLoader)
└── 加载 classpath 下的用户类
│
▼
自定义类加载器(可选)一个类加载请求,会从下往上先问问「父母」是否加载过,再从上往下逐层尝试加载。这就是双亲委派模型。
第四步:执行引擎
字节码被加载到内存后,执行引擎开始工作。
解释执行 vs JIT 编译
解释执行:JVM 是一条一条解释字节码并执行的。就像同声传译,说一句翻译一句。
// JVM 内部大致是这样的循环
while (hasMoreInstructions()) {
Instruction ins = fetchNextInstruction();
execute(ins); // 把字节码指令「翻译」成机器码执行
}JIT 编译:HotSpot 会监控哪些代码被反复执行(热点代码),把热点代码编译成本地机器码缓存起来,以后直接执行编译后的代码。
// 热点探测:被调用 1000 次以上,或循环回边 10000 次以上
if (isHotSpot(method)) {
compiledCode = JITCompiler.compile(method);
cache(compiledCode);
execute(compiledCode);
}JIT 编译器又有两个:
- C1 编译器(Client Compiler):编译速度快,优化较少,适合桌面应用
- C2 编译器(Server Compiler):编译速度慢,优化激进,适合服务器长时间运行
HotSpot 默认使用分层编译(Tiered Compilation):先用 C1 快速编译让程序跑起来,再慢慢用 C2 深度优化。
第五步:本地机器码执行
JIT 编译后的本地机器码被交给 CPU 执行。CPU 执行机器指令,访问内存,最终输出结果。
这是一个完整的闭环:
源代码 (.java)
↓ javac
字节码 (.class)
↓ 类加载器
内存中的 Class 对象
↓ 执行引擎
本地机器码
↓ CPU
程序输出运行时数据区的状态
在程序执行过程中,JVM 运行时数据区发生了什么?以 System.out.println("Hello, JVM") 为例:
PC 寄存器:指向当前字节码指令位置(第 3 条:ldc #3)
↓
虚拟机栈:main 方法的栈帧
├── 局部变量表:[args = 引用类型数组]
├── 操作数栈:[栈顶: "Hello, JVM" 字符串引用]
└── 动态链接:指向常量池中符号引用的解析结果
↓
堆:字符串常量 "Hello, JVM"(字符串池中的对象)
↓
方法区/元空间:System、PrintStream 等类的元信息每个线程都有自己的 PC 寄存器和虚拟机栈,但堆和方法区是所有线程共享的。
方法调用与栈帧
当 main 方法调用 println 时,JVM 在 main 的栈帧之上,创建一个新的 println 栈帧:
┌─────────────────┐
│ println 栈帧 │ ← 当前执行位置
│ ├─ 操作数栈 │
│ ├─ 局部变量表 │
│ └─ 返回地址 │
├─────────────────┤
│ main 栈帧 │
│ ├─ 操作数栈 │
│ ├─ 局部变量表 │
│ └─ 返回地址 │
└─────────────────┘println 执行完后,它的栈帧弹出,main 栈帧恢复执行。这个过程叫方法调用栈。
本节小结
Java 代码的执行流程:
- 编写 →
.java源代码 - 编译 →
javac把源码编译成.class字节码文件 - 加载 → 类加载器把字节码加载进 JVM,创建
Class对象 - 执行 → 执行引擎解释或 JIT 编译字节码
- 输出 → 本地机器码交给 CPU 执行,结果输出到屏幕
理解这个流程,有助于理解 JVM 各个模块是怎么协同工作的。后续章节会逐一深入每个环节。
下一节,我们来聊聊 指令集架构,看看为什么 JVM 选择基于栈的设计,以及栈式架构和寄存器架构各有何优劣。
