作者介绍 :本文作者 CodeStats,资深底层技术爱好者,专注计算机体系结构、操作系统内核与 Java 虚拟机实现原理。长期在 CSDN 分享硬核技术文章,致力于用通俗语言讲透 Java 程序从源码到 CPU 执行的完整运行逻辑。
参考文章:本文核心思想可与作者的以下前置文章配合阅读(点击跳转):
📖 目录
-
思考一个问题:你真的理解 java -jar 背后发生了什么吗?
-
程序执行的终极真相:CPU 只做一件"蠢事
-
操作系统与 CPU 权限:进程是"运行中的程序",线程是"执行单元
-
思想基础:操作系统如何管理进程与线程?(重点
-
Java 程序的真相:一个 JVM 进程 + 多个线
-
从 main 方法执行看完整内存与栈帧模型(重点
-
多线程执行:共享堆 + 私有栈的完美配合
-
总结:JVM 是对"计算机 + 操作系统"的完美模拟
一、思考一个问题:你真的理解 java -jar 背后发生了什么吗?
在日常开发中,我们无数次执行:
bash
java -jar MyApp.jar
然后 main 方法就跑起来了。但你是否想过:
-
到底是类加载 先发生,还是
main方法先入栈? -
JVM 是一个进程吗?Java 线程和操作系统线程是什么关系?
-
多个线程之间,堆和栈是如何共享与隔离的?
-
为什么说 JVM 完美模拟了操作系统?
如果你无法清晰回答这些问题,说明你对 Java 程序运行的完整自洽逻辑 还存在认知空白。
这篇文章将从 CPU 最底层的指令执行 开始,讲到 操作系统进程/线程模型 ,再到 JVM 如何模拟这一模型 ,最后彻底讲透 main 方法执行时的 类加载、主线程创建、栈帧入栈、方法调用链 等完整流程。
二、程序执行的终极真相:CPU 只做一件"蠢事"
2.1 剥离所有软件外衣
我们日常接触的 IDE、Maven、Spring、甚至 JVM 本身,全部是上层建筑。剥离所有软件层,直达硬件,你会发现 CPU 的工作逻辑简单到令人惊讶:
CPU 没有理解能力,不会"读懂"任何高级语言。它只会机械地重复一个无限循环:
-
查看 程序计数器(PC)------一个记录下一条指令内存地址的寄存器;
-
从内存中取出该地址的指令;
-
执行指令(算术运算、数据搬移、地址跳转等);
-
更新程序计数器,指向下一条指令;
-
回到第 1 步。
这就是 冯·诺依曼架构 的核心:
程序就是内存中连续排列的二进制指令;程序运行就是 CPU 顺着程序计数器这条"锁链",逐条取指执行的过程。
2.2 重要推论:CPU 根本听不懂你的 Java 代码
CPU 只能执行它所对应的机器码(x86、ARM、RISC-V 各不相同)。你写的任何高级语言------无论是 Java、Python 还是 JavaScript------最终都必须被翻译成 CPU 能识别的二进制机器语言。
这个"翻译"过程,正是 编译型语言 与 解释型语言 的本质分水岭。而 Java 走了一条 中间路线(字节码 + JIT 编译)。
三、操作系统与 CPU 权限:进程是"运行中的程序",线程是"执行单元"
3.1 为什么需要进程和线程?
如果所有程序都能随意访问所有内存和硬件,一个普通程序就能把你的硬盘格式化。为了防止这种灾难:
-
进程 :操作系统分配资源的基本单位。每个进程有独立的地址空间(你碰不到别人的内存)。
-
线程 :操作系统调度的基本单位。一个进程内可以有多个线程,共享进程的资源 (堆、文件句柄等),但每个线程有自己的栈 和程序计数器。
3.2 用户态与内核态:JVM 进程被关在"笼子"里
CPU 设计了特权级别(x86 的 Ring 0 ~ Ring 3):
| 模式 | 通俗称呼 | 能做什么 | 谁运行在这里 |
|---|---|---|---|
| Ring 0 | 内核态 | 执行任何 CPU 指令、访问所有内存、直接控制硬件 | 操作系统内核 |
| Ring 3 | 用户态 | 只能访问自己的内存空间,不能直接操作硬件 | 你的应用程序(包括 JVM 进程) |
核心规则 :用户态程序如果想做"特权操作"(读文件、发网络包、申请大块内存),必须通过 系统调用(System Call) 陷入内核,由内核代码代为完成。
这就是为什么你的 Java 程序崩溃不会导致整个电脑蓝屏------它被关在"用户态笼子"里。
四、【思想基础】操作系统如何管理进程与线程?(重点)
在讲 Java 之前,必须先理解操作系统本身的进程和线程设计思想。
4.1 进程的本质:虚拟内存 + 页表
操作系统给每个进程一个幻觉:它独占整个内存。
实现方式:
-
虚拟地址空间 :每个进程从
0到2^N-1独立编址 -
页表:记录虚拟地址 → 物理地址的映射
-
物理内存:被所有进程分时复用
进程 = 一套独立的页表 + 至少一个执行线程
4.2 线程的本质:共享页表 + 私有栈
同一进程内的多个线程:
-
✅ 共享:页表(地址映射)、堆、全局变量、文件句柄
-
❌ 独享:线程栈、PC 寄存器、一组通用寄存器
线程 = 在同一套地址空间内独立执行的"执行流"
4.3 切换代价的本质:换不换页?(最重要的思想)
这是理解线程"轻量"的关键:
| 切换类型 | 是否切换页表 | TLB 是否失效 | 内存页是否切换 |
|---|---|---|---|
| 进程切换 | ✅ 是 | ✅ 全部失效 | ✅ 需要页切换 |
| 线程切换 | ❌ 否 | ❌ 保持有效 | ❌ 不需要页切换 |
为什么进程切换必须"换页"?
-
不同进程的页表不同,同一虚拟地址映射到不同物理地址
-
切换进程 = 加载新页表 = 刷新 TLB(地址翻译缓存)
-
TLB 失效后,后续内存访问需要查页表 → 缺页开销
为什么线程切换不换页?
-
同一进程的线程共用同一个页表
-
虚拟地址 → 物理地址的映射完全一致
-
切换线程 = 只保存/恢复 PC、寄存器、栈指针 → 不碰 TLB
核心结论 :进程切换 = 重开销(换页表 + TLB 清空)
线程切换 = 轻量(不换页表,TLB 有效)
这就是为什么:
-
线程叫"轻量级进程"
-
多线程比多进程更适合高并发
操作系统思想的总结:
OS 用进程隔离资源(独立页表),用线程实现轻量并发(共享页表)。CPU 只负责执行指令,OS 负责决定哪个线程的指令被 CPU 执行。
五、Java 程序的真相:一个 JVM 进程 + 多个线程
5.1 java -jar 到底发生了什么?
执行 java -jar MyApp.jar 时,操作系统视角 看到的不是你的 main 方法,而是:
-
OS 创建一个新进程 (JVM 进程),加载
java命令对应的可执行文件(如/usr/bin/java或java.exe)。 -
这个进程的入口点不是 Java 的
main,而是 JVM 的 C++ 启动代码 (如java.c中的JLI_Launch函数)。 -
JVM 启动代码会:
-
初始化 JVM 内部结构(堆、方法区、线程管理等)
-
加载
MyApp.jar中指定的主类 -
创建主线程
-
由主线程调用 Java 层的
public static void main(String[])方法
-
关键结论:
Java 的
main方法不是 OS 的进程入口,它是 JVM 进程内主线程调用的一个 Java 方法。
5.2 JVM 进程 = 普通操作系统进程
| 组件 | 对应 OS 概念 |
|---|---|
| JVM 进程地址空间 | 进程虚拟内存(堆、方法区、线程栈都在里面) |
| Java 线程 | OS 线程(1:1 映射,由 OS 内核调度) |
| 堆、方法区 | 进程内共享内存区(所有线程可见) |
| 每个线程的栈 | 线程私有(存储栈帧) |
| 程序计数器 (PC) | 每个线程私有(记录当前执行到哪条字节码指令) |
结合第四章的 OS 思想:
-
JVM 进程 = 普通 OS 进程 → 拥有独立的页表
-
Java 线程 = OS 原生线程(1:1 映射)→ 共享 JVM 进程的页表
-
Java 线程切换 = OS 线程切换 = 不换页表 = 不需要内存页切换
JVM 并没有发明新的线程模型,它直接使用了操作系统提供的 原生线程库 (如 POSIX 的
pthread或 Windows 的CreateThread)。多线程的"轻量",底层依赖的是 OS 线程的页表共享特性。
六、从 main 方法执行看完整内存与栈帧模型(重点)
6.1 类加载:早于 main 的任何指令
顺序不是:先有 main 栈帧。正确的完整顺序是:
-
JVM 进程启动(OS 分配进程地址空间)
-
类加载 :JVM 的类加载器找到并加载
MainClass:-
加载(查找并读入
.class文件) -
链接(验证、准备、解析)
-
初始化:执行静态变量赋值、静态代码块
-
-
此时还没有任何栈帧,只有方法区中有了类的元数据。
-
JVM 创建主线程(主线程的栈初始为空)。
-
主线程调用
main→ JVM 在主线程栈中压入第一个栈帧 (main栈帧)。
一句话总结:
类加载发生在
main方法入栈之前;main方法是主线程的第一个栈帧。
6.2 主线程创建与 main 栈帧入栈
main 栈帧包含:
-
局部变量表 (
String[] args,以及方法内定义的局部变量) -
操作数栈(用于字节码计算)
-
方法返回地址 (
main返回后去哪里,通常回到 JVM 启动代码) -
常量池引用(指向当前类的方法区常量池)
6.3 方法调用、入栈、出栈、PC 寄存器回溯
考虑以下代码:
java
public class Demo {
public static void main(String[] args) {
a();
}
static void a() { b(); }
static void b() { c(); }
static void c() { System.out.println("end"); }
}
执行过程:
| 步骤 | 动作 | 线程栈状态(从底到顶) |
|---|---|---|
| 1 | 主线程调用 main |
main 栈帧 |
| 2 | main 调用 a |
main → a |
| 3 | a 调用 b |
main → a → b |
| 4 | b 调用 c |
main → a → b → c |
| 5 | c 执行完毕 |
c 出栈,PC 回到 b 的调用下一条指令 |
| 6 | b 执行完毕 |
b 出栈,PC 回到 a |
| 7 | a 执行完毕 |
a 出栈,PC 回到 main |
| 8 | main 执行完毕 |
main 出栈,栈空,主线程结束 |
这正是操作系统执行机器码的同一套机制:栈帧 + PC 回溯 + 指令循环。
每个线程都有自己的 PC 寄存器(在 JVM 中指向当前执行的字节码指令地址),当方法调用时,当前 PC 被保存到栈帧中,返回时恢复。
6.4 一个生活比喻
| 概念 | 比喻 |
|---|---|
| JVM 进程 | 一家公司(独立运营,有独立办公区) |
| 主线程 | 公司的第一位员工 |
| 类加载 | 公司提前买好办公设备、准备好规章制度 |
main 栈帧 |
员工开始干第一项任务(填写表格) |
| 方法调用 | 为了完成大任务,去调用其他小任务(层层细化) |
| 栈帧入栈 | 在便签本上记录当前做到哪一步,然后去执行子任务 |
| 栈帧出栈 | 子任务完成,撕掉便签,回到上一层继续 |
| 多线程 | 多个员工各自有自己的便签本(私有栈),共享打印机、茶水间(堆) |
七、多线程执行:共享堆 + 私有栈的完美配合
text
┌─────────────────────────────────┐
│ JVM 进程地址空间 │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ 堆(共享) │ │ 方法区(共享)│ │
│ │ 对象、数组 │ │ 类信息、常量 │ │
│ └─────────────┘ └─────────────┘ │
│ │
┌───────────┼───────────┬───────────┬───────────┼───────────┐
│ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ 线程1栈 │ │ 线程2栈 │ │ 线程3栈 │ │ 线程4栈 │ │ 线程5栈 │
│ main() │ │ run() │ │ run() │ │ run() │ │ run() │
│ a() │ │ b() │ │ b() │ │ c() │ │ (空) │
│ PC │ │ PC │ │ PC │ │ PC │ │ PC │
└──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘
▲ ▲ ▲ ▲ ▲
└────────────┴────────────┴────────────┴────────────┘
每个线程独立PC、独立栈
-
每个线程有自己独立的栈:存储该线程的方法调用链和局部变量,互不干扰。
-
所有线程共享堆:对象在堆上分配,任何线程只要有引用就能访问。
-
方法区共享:类信息、常量池、静态变量所有线程可见。
-
PC 寄存器私有:每个线程记录自己执行到哪条字节码指令。
调度权在 OS 内核:JVM 的 Java 线程与 OS 线程是 1:1 映射,由 OS 决定哪个线程跑在哪个 CPU 核上。JVM 本身不参与 CPU 时间片分配。
对比第四章的 OS 思想:
-
OS 进程内多线程:共享页表 → 不换页 → 轻量切换
-
JVM 进程内多线程:共享堆 + 方法区 → 线程栈私有 → 切换时不碰堆
JVM 的多线程模型,完全遵循 OS 的线程设计思想:共享资源(堆/方法区) + 私有执行上下文(栈/PC)。
八、总结:JVM 是对"计算机 + 操作系统"的完美模拟
回顾整篇文章,我们其实只讲了一件事:
CPU 只会机械执行内存中的机器码。操作系统通过进程和线程管理资源与调度。JVM 则完美模拟了"一台计算机 + 一个操作系统"的模型。
这个统一模型的核心对比:
| 计算机 / 操作系统 | JVM 模拟 |
|---|---|
| CPU 执行机器码 | 解释器/JIT 执行字节码 |
| PC 寄存器 | 每个 Java 线程有独立 PC |
| 函数调用栈帧 | Java 方法栈帧(含局部变量表、操作数栈) |
| 进程地址空间(含页表) | 堆 + 方法区(底层仍是 OS 虚拟内存) |
| 进程切换(换页表、TLB 清空) | 不同 JVM 进程之间(由 OS 完成) |
| 线程切换(不换页表) | Java 线程切换(1:1 映射到 OS 线程) |
| 系统调用(用户态→内核态) | JNI 调用 / native 方法(边界类似) |
Java 程序运行的完整自洽闭环:
OS 创建 JVM 进程 → JVM 初始化 → 类加载(早于一切方法调用)→ 创建主线程 → 主线程调用
main→main栈帧入栈 → 方法调用导致栈帧入栈/出栈 → PC 寄存器回溯 → 多线程各用私有栈、共享堆 → 所有非守护线程结束 → JVM 进程退出
这不是玄学,这是一套经过 30 年验证的"操作系统虚拟化"实现。 JVM 并没有重新发明轮子,而是完美复用了操作系统已有的进程和线程模型,在上面加了一层 平台无关的字节码执行环境。
作者寄语
计算机科学没有魔法。所有看似神奇的效果------java -jar 一键启动、多线程自动切换、内存自动回收------底层都是简单的规则层层组合。
-
编译型语言:源码 → 直接变成机器码 → CPU 直接执行
-
解释型语言:源码 → 作为数据输入预制程序(解释器) → 解释器的机器码在跑
-
Java:源码 → 字节码 → JVM(一个特殊的预制程序) → JIT 编译后变成机器码
希望本文能帮你搭建起 从硬件 → 操作系统 → JVM → Java 代码 的完整认知桥梁,让你在今后的学习和工作中"胸有成竹"。
👍 如果觉得有收获
-
点赞 👍 让系统推荐给更多爱钻底层的朋友
-
收藏 ⭐ 面试 JVM 之前一定用得上
-
评论 💬 说说你曾经在哪一行代码上对"栈帧"产生了真正的理解
-
关注 🔔 作者 CodeStats,获取更多硬核底层技术分享
下期预告:《从 JIT 编译到 AOT:Java 执行速度是如何一步步逼近 C++ 的?》