【编程语言】从内核到应用:操作系统与六大编程语言底层原理完全指南(下)
本文为下半部分,聚焦 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_0、invokevirtual、iadd。.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 指令层面的执行流切换:
-
Java 代码执行
invokenative字节码(JIT 后为CALL指令) -
JVM 通过
dlopen()+dlsym()查找本地函数的绝对内存地址 -
CPU 执行
CALL 0x7f1234567000,RIP跳转到.so文件的代码段 -
C/C++ 函数执行,内部可调用
SYSCALL陷入内核 -
C 函数
RET,RIP返回 JVM -
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>(堆分配)底层就是 malloc,Rc<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 指令视角):
-
保存现场(数据传送指令):内核执行大量
MOV指令,将当前进程的 RAX、RBX、RIP、RSP 等所有通用寄存器值保存到该进程的 PCB(task_struct) 中 -
选择新进程(软件调度算法):内核根据优先级/CFS 算法,从运行队列中选择下一个进程的 PCB
-
切换地址空间(特权指令):执行
MOV CR3, 新进程页表地址—— 这是最高特权指令,只有内核态可执行。它瞬间切换整个内存映射(MMU 改用新页表翻译地址) -
恢复现场(数据传送指令):从新进程 PCB 中恢复寄存器值到 CPU
-
返回用户态(转移指令):执行
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)上有所不同,但核心原理相通。如有错误或遗漏,欢迎指正。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)