从 CPU 指令到 JVM 进程:彻底讲透 Java 执行 main 方法时,类加载、主线程、栈帧入栈的完整底层逻辑

作者介绍 :本文作者 CodeStats,资深底层技术爱好者,专注计算机体系结构、操作系统内核与 Java 虚拟机实现原理。长期在 CSDN 分享硬核技术文章,致力于用通俗语言讲透 Java 程序从源码到 CPU 执行的完整运行逻辑。

参考文章:本文核心思想可与作者的以下前置文章配合阅读(点击跳转):


📖 目录

  1. 思考一个问题:你真的理解 java -jar 背后发生了什么吗?

  2. 程序执行的终极真相:CPU 只做一件"蠢事

  3. 操作系统与 CPU 权限:进程是"运行中的程序",线程是"执行单元

  4. 思想基础:操作系统如何管理进程与线程?(重点

  5. Java 程序的真相:一个 JVM 进程 + 多个线

  6. 从 main 方法执行看完整内存与栈帧模型(重点

  7. 多线程执行:共享堆 + 私有栈的完美配合

  8. 总结: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 没有理解能力,不会"读懂"任何高级语言。它只会机械地重复一个无限循环:

  1. 查看 程序计数器(PC)------一个记录下一条指令内存地址的寄存器;

  2. 从内存中取出该地址的指令;

  3. 执行指令(算术运算、数据搬移、地址跳转等);

  4. 更新程序计数器,指向下一条指令;

  5. 回到第 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 进程的本质:虚拟内存 + 页表

操作系统给每个进程一个幻觉:它独占整个内存。

实现方式:

  • 虚拟地址空间 :每个进程从 02^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 方法,而是:

  1. OS 创建一个新进程 (JVM 进程),加载 java 命令对应的可执行文件(如 /usr/bin/javajava.exe)。

  2. 这个进程的入口点不是 Java 的 main ,而是 JVM 的 C++ 启动代码 (如 java.c 中的 JLI_Launch 函数)。

  3. 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 栈帧。正确的完整顺序是:

  1. JVM 进程启动(OS 分配进程地址空间)

  2. 类加载 :JVM 的类加载器找到并加载 MainClass

    • 加载(查找并读入 .class 文件)

    • 链接(验证、准备、解析)

    • 初始化:执行静态变量赋值、静态代码块

  3. 此时还没有任何栈帧,只有方法区中有了类的元数据。

  4. JVM 创建主线程(主线程的栈初始为空)。

  5. 主线程调用 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 maina
3 a 调用 b mainab
4 b 调用 c mainabc
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 初始化 → 类加载(早于一切方法调用)→ 创建主线程 → 主线程调用 mainmain 栈帧入栈 → 方法调用导致栈帧入栈/出栈 → PC 寄存器回溯 → 多线程各用私有栈、共享堆 → 所有非守护线程结束 → JVM 进程退出

这不是玄学,这是一套经过 30 年验证的"操作系统虚拟化"实现。 JVM 并没有重新发明轮子,而是完美复用了操作系统已有的进程和线程模型,在上面加了一层 平台无关的字节码执行环境


作者寄语

计算机科学没有魔法。所有看似神奇的效果------java -jar 一键启动、多线程自动切换、内存自动回收------底层都是简单的规则层层组合。

  • 编译型语言:源码 → 直接变成机器码 → CPU 直接执行

  • 解释型语言:源码 → 作为数据输入预制程序(解释器) → 解释器的机器码在跑

  • Java:源码 → 字节码 → JVM(一个特殊的预制程序) → JIT 编译后变成机器码

希望本文能帮你搭建起 从硬件 → 操作系统 → JVM → Java 代码 的完整认知桥梁,让你在今后的学习和工作中"胸有成竹"。


👍 如果觉得有收获

  • 点赞 👍 让系统推荐给更多爱钻底层的朋友

  • 收藏 ⭐ 面试 JVM 之前一定用得上

  • 评论 💬 说说你曾经在哪一行代码上对"栈帧"产生了真正的理解

  • 关注 🔔 作者 CodeStats,获取更多硬核底层技术分享

下期预告:《从 JIT 编译到 AOT:Java 执行速度是如何一步步逼近 C++ 的?》

相关推荐
摇滚侠1 小时前
Spring 零基础入门到进阶 基于注解管理 Bean 38-43
xml·java·后端·spring·intellij-idea
SamDeepThinking1 小时前
我们当年是如何真实落地BFF的?
java·后端·架构
码语智行1 小时前
Shapefile获取空间数据和中心点坐标
java·arcgis
caoyc1 小时前
RAG 赛道全景扫描:ragflow 一骑绝尘、微软谷歌跟进乏力、下半场属于 Agent
java
阿正的梦工坊1 小时前
【Rust】09-泛型、Trait 与生命周期基础
开发语言·rust·c#
屋外雨大,惊蛰出没1 小时前
深入浅出Spring Boot
java·spring boot·ioc·aop
阿正的梦工坊2 小时前
【Rust】07-错误处理:Option、Result 与 ? 运算符
开发语言·算法·rust
Zella折耳根2 小时前
复习篇-继承和接口
java·开发语言·python