欢迎光临
我们一直在努力

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

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

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

  • 从 CPU 指令执行到权限管控:对比三大操作系统,梳理编程语言演进,解读 HTML/CSS/JS 浏览器解析的共通底层逻辑-CSDN博客


📖 目录

  • 思考一个问题:你真的理解 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++ 的?》

    赞(0)
    未经允许不得转载:171主机测评 » 从 CPU 指令到 JVM 进程:彻底讲透 Java 执行 main 方法时,类加载、主线程、栈帧入栈的完整底层逻辑
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址