本文为下半部分,聚焦 Java/Python/Go/Rust 运行时原理与并发模型。上篇已覆盖操作系统内核与 C/C++ 底层原理,建议先阅读上篇再继续。

📖 目录(下篇)

  • 五、Java 和 Python 底层是 C/C++ 实现的,底层原理是什么?

    • 5.1 Java:JVM 架构与执行引擎

    • 5.2 Python:CPython 解释器与 GIL 锁

    • 5.3 为什么 Java/Python 比 C/C++ 慢?

    • 5.4 Java 本地方法(JNI)是如何执行 C/C++ 代码的?

  • 六、Go 和 Rust 都编译成机器码执行,和 C/C++ 有何区别?

  • 七、Rust 如何实现安全?Go 为什么内存占用少、适合高并发?

    • 7.1 Rust 的所有权与借用检查

    • 7.2 Go 为什么内存占用少?

    • 7.3 Go 为什么适合高并发?GMP 调度模型

    • 7.4 Go 协程 vs Java 虚拟线程

  • 八、操作系统内核各模块属于进程吗?第一个进程如何启动?进程如何切换?

    • 8.1 内核模块属于进程吗?

    • 8.2 第一个进程是如何启动的?

    • 8.3 操作系统如何实现进程切换?

  • 终极对比:六大语言从源码到 CPU 指令的完整路径

👨‍💻 作者介绍

大家好,我是 CodeStats

一个在底层技术上“考古”了四年的硬核爱好者,也是 WWAIC(全周项目 AI 编程) 范式的提出者和实践者。我曾手写过一个完整的 Java Web 框架(从 IoC 容器到嵌入式 Tomcat,代码全开源),也喜欢用通俗的语言拆解 CPU、JVM、操作系统的运行本质。

本文适合谁? 如果你是刚入门的开发者,本文能帮你建立从硬件到软件的全景认知;如果你是有经验的工程师,本文的底层视角或许能帮你解开一些长期困惑。无论你主攻哪门语言,理解底层原理都会让你走得更远。

五、Java 和 Python 底层是 C/C++ 实现的,底层原理是什么?

5.1 Java:JVM 架构与执行引擎

Java 的核心设计是 “一次编写,到处运行”,通过引入 JVM(Java 虚拟机) 隔离操作系统和 CPU 差异。

编译产物javac 将 .java 源码编译成 .class 字节码(Bytecode),字节码是栈式指令集,如 aload_0invokevirtualiadd.class 文件包含魔数 0xCAFEBABE、版本号、常量池和方法字节码,与具体 CPU 架构无关。

JVM 运行时进程:执行 java Main 时,操作系统启动一个名为 java 的进程,内部加载 JVM 核心动态库(libjvm.so)。

类加载机制ClassLoader 在运行时按需将 .class 字节码加载到内存(方法区/元空间),采用双亲委派机制,保证核心类库(如 java.lang.String)不被用户篡改。

执行引擎:解释器 + JIT 编译器混合模式

模式 原理 速度 触发条件
解释器 switch-case 循环逐条读取并翻译字节码 慢(C 语言的 1/10 ~ 1/100) 程序启动时、冷方法
JIT 编译器 一次性将热点字节码编译成 CPU 原生机器码 快(接近 C 语言) 方法调用次数超过阈值(~10000 次)

JIT 编译后的机器码存放在 CodeCache 区域,后续调用直接执行,无需再次解释。

零开销抽象 vs JIT 激进优化:C++ 的零开销抽象在编译期完成,运行时无额外负担;Java 的 JIT 在运行时收集性能数据(Profile),进行激进的内联去虚拟化,极限性能可接近甚至反超静态编译语言,但代价是需要预热。

5.2 Python:CPython 解释器与 GIL 锁

绝大多数 Python 运行时是 CPython(用 C 语言写成的解释器)。

编译过程python3 main.py 执行时,解释器先进行词法/语法分析,生成抽象语法树(AST),再编译成 Python 字节码.pyc 文件),存放在 PyCodeObject 结构体中。

执行过程:PVM(Python 虚拟机)是一个巨大的 switch (opcode) 循环,逐条读取字节码并执行。关键差异:CPython 没有 JIT 编译器(默认),所以永远停留在“解释一条、执行一条”的阶段。

GIL(全局解释器锁):CPython 的内存管理不是线程安全的,因此设计了一把全局锁。同一时刻,只有一个线程能执行 Python 字节码,即使有多核 CPU,Python 进程也只能跑满一个核心。这是 Python CPU 密集型任务慢的根本原因。

Python 的内存模型a = 1 中的 1 是堆上的 PyLongObject 结构体,包含引用计数(ob_refcnt)和类型指针(ob_type),变量 a 只是一个指向该堆地址的指针。Python 的 int 通常占用 28 字节(C 语言 int 仅 4 字节)。

5.3 为什么 Java/Python 比 C/C++ 慢?

因素 Java Python
指令执行方式 热点代码 JIT 编译后直接执行,冷代码解释执行 始终解释执行(默认),每条字节码都要经过 switch 分发
内存访问 对象访问需要解引用,且有对象头开销 万物皆指针,int 也是堆对象,大量间接访问
类型绑定 运行时动态绑定(虚方法),JIT 可优化但需预热 动态类型,每次操作都要查类型信息
GC 开销 有 STW 暂停,但 ZGC 已控制在 <1ms 引用计数 + 循环检测 GC,有额外开销

5.4 Java 本地方法(JNI)是如何执行 C/C++ 代码的?

Java 的 native 方法通过 JNI(Java Native Interface) 调用外部 C/C++ 动态库(.so/.dll)。

CPU 指令层面的执行流切换

  1. Java 代码执行 invokenative 字节码(JIT 后为 CALL 指令)

  2. JVM 通过 dlopen() + dlsym() 查找本地函数的绝对内存地址

  3. CPU 执行 CALL 0x7f1234567000RIP 跳转到 .so 文件的代码段

  4. C/C++ 函数执行,内部可调用 SYSCALL 陷入内核

  5. C 函数 RETRIP 返回 JVM

  6. JVM 检查返回值并处理可能的异常

JVM 在此处扮演的角色:将 Java 参数转换为 C 能读懂的格式(字符串转 char*)、查找函数地址、捕获 C 代码的段错误并转换为 Java 异常。

六、Go 和 Rust 都编译成机器码执行,和 C/C++ 有何区别?

三者都编译成 CPU 原生机器码,但在编译时运行时有重大差异:

维度 C/C++ Go Rust
内存管理 手动 free / RAII 带 GC(垃圾回收) 无 GC,所有权模型编译期确定
并发模型 操作系统线程(1:1) Goroutine(M:N 协程) 操作系统线程 + async/await
运行时 极小(libc) 包含调度器 + GC + 网络轮询器 极简(基本无运行时)
编译速度 中慢(C++ 模板慢) 极快(秒级) 慢(借用检查 + LLVM 优化)
二进制大小 小(动态链接更小) 较大(静态链接含运行时) 小(静态链接,无运行时)
内存安全 依赖程序员 依赖 GC 编译期保证(无悬垂指针、无数据竞争)
预热 无需预热,立即全速 无需预热 无需预热

关键点:三者最终都是 MOV/ADD/CALL 指令,但 Go 和 Rust 在编译期插入了更多“安全检查”或“调度逻辑”的机器码。Go 的优势在于开发效率并发模型,Rust 的优势在于内存安全无 GC 开销

七、Rust 如何实现安全?Go 为什么内存占用少、适合高并发?

7.1 Rust 的所有权与借用检查

Rust 通过编译期静态分析保证内存安全,无需 GC,无需手动 free

三大核心规则

① 每个值只有一个所有者(Owner) —— 当所有者离开作用域,值被自动销毁(调用 drop)。

② 引用(借用)不能超过值的生命周期 —— 编译器会分析每个引用的存活范围(生命周期),如果引用试图超过被引用值的生存期,编译直接报错

③ 可变引用与不可变引用互斥 —— 同一时刻,要么有任意数量的不可变引用(&T),要么有且仅有一个可变引用(&mut T),从编译期杜绝数据竞争

底层实现:Rust 编译后生成的机器码中,没有任何运行时 GC 开销Box<T>(堆分配)底层就是 mallocRc<T>(引用计数)底层是原子操作。所有权检查只存在于编译阶段,运行时只留下纯粹的 MOV/ADD/CALL

7.2 Go 为什么内存占用少?

因素 Java Go 说明
线程栈 1MB/线程 2KB/Goroutine 1 万个并发连接:Java 约 10GB,Go 约 20MB
对象头 12~16 字节/对象 无对象头 Java 对象含 Mark Word + Klass Pointer
GC 账簿 堆的 5%~10% 堆的 1%~2% 卡表/Remembered Set vs 全局位图
启动基线 150~400MB(空 JVM) 5~15MB(空 Go 程序) JIT 编译器 + rt.jar 加载
分配器 预分配(-Xms 占满) 按需分配(缺页中断时分配)

7.3 Go 为什么适合高并发?GMP 调度模型

Go 的并发模型是 M:N 调度器,由 3 个核心组件构成:

组件 全称 说明
G Goroutine 用户态轻量级协程,初始栈仅 2KB
M Machine 操作系统线程(由内核调度)
P Processor 逻辑处理器,持有 Goroutine 运行队列

调度机制:Go 运行时将 G 分配到 P 的本地队列,P 再挂载到 M(系统线程)上执行。当 G 阻塞(如读取网络数据),Go 调度器直接把该 G 挂起,在同一根 M 上换另一个 G 运行——全程不触发 SYSCALL,CPU 只需保存/恢复 PC 和 SP 几个寄存器,切换耗时纳秒级

7.4 Go 协程 vs Java 虚拟线程

Java 21 引入了虚拟线程(Virtual Thread),与 Go 的 Goroutine 有相似的“轻量级协程”目标,但实现差异显著:

维度 Go Goroutine Java 虚拟线程
调度器位置 Go 运行时内嵌,与程序一起编译 JVM 内部,依赖 JDK 版本
调度时机 主动抢占式(网络 I/O 时自动让出) 载体线程阻塞时卸载(LockSupport.unpark
栈大小 初始 2KB,动态扩容 初始与 OS 线程类似(由 JVM 管理)
原生支持 语言级内置 JDK 21+ 标准库 java.lang.Thread 新增虚拟线程
生态适配 标准库原生支持非阻塞 I/O 需框架适配(如 Spring Boot 3.2+ 已支持)
性能 极轻,百万级别轻松 轻量,但仍依赖 JVM 底层实现

八、操作系统内核各模块属于进程吗?第一个进程如何启动?进程如何切换?

8.1 内核模块属于进程吗?

不属于。

操作系统内核本身不是进程。它没有 PID,没有独立的虚拟地址空间(内核映射在每块物理内存的高端区域),不参与进程调度时间片竞争。

内核模块在以下三种上下文中获得 CPU 控制权:

执行上下文 触发方式 代表场景
进程上下文 用户程序发起 SYSCALL 系统调用(如 read/write),借用当前进程的身份运行
中断上下文 硬件设备发出中断信号 键盘敲击、网卡收包,完全不属于任何进程
内核线程 内核主动创建(PID 2 kthreadd 孵化) kswapd(内存回收)、flush(刷脏页),拥有独立的 PID

8.2 第一个进程是如何启动的?

在第一个进程启动之前,CPU 执行的是 “无主代码” —— 内核自举代码,不属于任何进程。

启动流程

① BIOS/UEFI 固件(ROM 芯片)→ ② GRUB Bootloader(硬盘 MBR)→ ③ 内核入口汇编(开启分页、设置 CR3)→ ④ start_kernel()(C 语言)→ ⑤ rest_init()

c

static void __init rest_init(void) {
    // 创建 PID=1(用户态始祖 init/systemd)
    kernel_thread(kernel_init, NULL, CLONE_FS);
    // 创建 PID=2(内核线程总管 kthreadd)
    kernel_thread(kthreadd, NULL, CLONE_FS | CLONE_FILES);
    // 当前 CPU 降级为 PID=0(Idle 进程)
    cpu_startup_entry(CPUHP_ONLINE);
}

历史性瞬间:当 CPU 执行 IRET 指令从内核态返回用户态时,RIP 跳转到人为构造的 kernel_init 入口,CPU 状态从内核态切换为用户态。这一刻,世界上才有了第一个进程(PID=1)。

8.3 操作系统如何实现进程切换?

进程切换的核心是上下文切换(Context Switch),依赖以下硬件机制:

触发方式

  • 主动让出:进程调用 sleep() 或等待 I/O(SYSCALL 进入内核后主动调用 schedule()

  • 被动抢占:硬件时钟中断(通常每毫秒一次),CPU 强制跳转到内核的中断处理函数

切换过程(CPU 指令视角)

  1. 保存现场(数据传送指令):内核执行大量 MOV 指令,将当前进程的 RAX、RBX、RIP、RSP 等所有通用寄存器值保存到该进程的 PCB(task_struct 中

  2. 选择新进程(软件调度算法):内核根据优先级/CFS 算法,从运行队列中选择下一个进程的 PCB

  3. 切换地址空间(特权指令):执行 MOV CR3, 新进程页表地址 —— 这是最高特权指令,只有内核态可执行。它瞬间切换整个内存映射(MMU 改用新页表翻译地址)

  4. 恢复现场(数据传送指令):从新进程 PCB 中恢复寄存器值到 CPU

  5. 返回用户态(转移指令):执行 IRET 指令,同时完成“内核态 → 用户态”切换和“RIP 跳转到新进程上次执行位置”两个动作

终极对比:六大语言从源码到 CPU 指令的完整路径

语言 编译产物 运行时 执行方式 CPU 指令来源
C ELF 机器码 libc 直接执行 .text 段 编译时确定
C++ ELF 机器码 libc + libstdc++ 直接执行 .text 段 编译时确定 + 虚表动态查表
Java .class 字节码 JVM(C++ 写的进程) 解释执行 → JIT 编译成机器码 运行时 JIT 生成
Python .pyc 字节码 CPython(C 写的进程) 始终解释执行(默认) 解释器 switch-case 模拟
Go 静态 ELF 机器码 Go Runtime(内嵌) 直接执行 编译时确定
Rust 静态 ELF 机器码 极简运行时 直接执行 编译时确定

💎 全文总结

从操作系统内核到 C/C++,再到 Java/Python/Go/Rust,所有编程语言和运行时最终都在做同一件事:生成 CPU 执行的 MOV/ADD/CALL/SYSCALL 指令流

C 语言站在离 CPU 最近的位置,是操作系统的“母语”,但牺牲了工程抽象和开发效率。
C++ 在 C 的基础上加了零开销抽象和 RAII,让开发者既能控制硬件,又能组织庞大代码。
Java 用 JVM 字节码 + JIT 编译换来了跨平台和自动化内存管理,代价是更高内存占用和预热时间。
Python 用极简的解释执行换来了无与伦比的开发效率,代价是极低的执行速度和 GIL 限制。
Go 用静态编译 + Goroutine 换来了快速的启动、极低的内存占用和简单的高并发编程。
Rust 用所有权机制换来了编译期内存安全和无 GC 的高性能,代价是陡峭的学习曲线。

理解底层原理,不是为了在面试中背诵八股文,而是为了在遇到性能瓶颈、疑难 Bug、框架选型时,能一眼看到问题的本质。

📌 如果觉得本文有帮助

  • 👍 点赞 —— 让更多人看到硬核内容

  • ⭐ 收藏 —— 方便随时查阅底层原理

  • 🔔 关注 —— 第一时间收到后续深度文章

  • 💬 评论 —— 你的反馈是我最大的动力

下期预告:手写 JVM 核心模块(类加载 + 字节码解释器)—— 代码开源,欢迎关注!


本文基于 x86-64 Linux 环境,部分细节在其他架构(ARM、Windows)上有所不同,但核心原理相通。如有错误或遗漏,欢迎指正。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐