跳转到内容

第 2 章 · 编译、解释、字节码、JIT 与 AOT

解决什么问题:同一段源码要变成 CPU 能执行的指令,这一步放在什么时候做、做到哪一步,决定了启动快慢、分发方式和运行时还剩下什么信息。

四种语言的态度:Python 编译成字节码交给解释器,JS 由引擎分层 JIT,C# 编译成 IL 再分层 JIT 或提前编译,Go 直接提前编译成带运行时的单个可执行文件。

写一个最平凡的函数 square_sum(n),求 1 到 n 的平方和,打印 n 为 10 和 1000 的结果。代码本身谁都会写,本章关心的是它周围发生的事:敲下运行命令之后,这几行源码在四种工具链里分别经过哪些步骤,留下哪些中间产物。这些步骤决定了一串很实际的问题:程序启动要多久,目标机器上要不要先装运行时,能不能只拷一个文件过去就跑,运行时还知不知道变量是什么类型。

CPU 只认机器码,源码总得在某个时刻被翻译。第一条路是解释(interpretation):解释器读入源码,解析成语法树,边遍历边执行,每次运行都从源码开始,没有中间产物。第二条路是编译成机器码(compilation):编译器在运行之前把整个程序翻译成目标 CPU 的指令,运行时直接交给 CPU。两条路各有代价:纯解释每执行一个加法都要先判断“这是什么节点、操作数是什么类型”,慢;编译成机器码要为每种 CPU 和操作系统各出一份,改一行就得重新编译。

第三条路在两者之间:先编译成字节码(bytecode),一种为虚拟机(virtual machine)设计的紧凑指令,再由虚拟机逐条解释执行。字节码不依赖 CPU,一份可以到处跑;比起遍历语法树,解释扁平的指令序列也快得多。虚拟机还可以更进一步,在运行过程中把字节码翻译成机器码,这就是即时编译(JIT,just-in-time)。与之相对,在运行之前就翻译完叫提前编译(AOT,ahead-of-time)。

三条路和四种语言的落点

看图中间那条字节码的路:它在虚拟机处分出一条虚线通往机器码,这就是 JIT;Go 走最右边直接到机器码的路,C# 既可以走 JIT 也可以走 AOT 那条虚线。

JIT 的优势在于它看得到运行时的情况:哪些函数被调用了上万次,某个参数是不是总是整数。它可以只编译热点,并且按观察到的类型做大胆的优化。代价是编译本身占运行时间,于是有了分层编译(tiered compilation):代码先用最便宜的方式跑起来(解释或快速但粗糙的编译),调用次数超过阈值再交给更慢、更强的优化编译器;优化时做的假设(比如“参数总是整数”)一旦被推翻,就退回低层重新来过,叫去优化(deoptimization)。AOT 则把这些时间全挪到构建阶段:启动时没有编译开销,产物可以直接分发,代价是只能按静态能知道的信息优化。

Python 的官方实现 CPython 走字节码加虚拟机这条路,编译这一步是自动的,用户几乎感觉不到。

def square_sum(n: int) -> int:
total = 0
for i in range(1, n + 1):
total += i * i
return total
if __name__ == "__main__":
assert square_sum(3) == 14 # 用 python -O 运行时,这一行在编译阶段就被删掉
for n in (10, 1000):
print(f"square_sum({n}) = {square_sum(n)}")
# 输出:
# square_sum(10) = 385
# square_sum(1000) = 333833500

运行:python squares.py。CPython 先把整个文件编译成代码对象(code object),里面是字节码、常量表和变量名表,再交给求值循环(evaluation loop)执行。被 import 的模块,编译结果会缓存到同目录的 __pycache__/模块名.cpython-312.pyc(文件名里带着解释器版本),下次导入时源文件没变就直接读缓存,省掉编译。

标准库 dis 能把字节码反汇编出来。在 Python 3.12 上对 square_sum 调用 dis.dis,循环体 total += i * i 那一行大致是下面这样(字节码格式随版本变化,3.13 就会把前两条合并成一条 LOAD_FAST_LOAD_FAST):

4 42 LOAD_FAST 1 (total)
44 LOAD_FAST 2 (i)
46 LOAD_FAST 2 (i)
48 BINARY_OP 5 (*)
52 BINARY_OP 13 (+=)
56 STORE_FAST 1 (total)
58 JUMP_BACKWARD 12 (to 36)

这是基于栈的指令:两次把 i 压栈,乘法弹出两个、压回乘积,再和 total 相加后存回。注意字节码里没有任何类型信息,BINARY_OP 不知道操作数是整数,类型标注 n: int 也没有进入字节码。

底层怎么实现。 求值循环是 C 写的一个大循环:取一条指令,跳到对应的处理代码,执行完取下一条。每次 BINARY_OP 都要查操作数的类型、找到整数加法、分配新的整数对象,这是 CPython 慢的主要原因。3.11 起加入了特化自适应解释器(specializing adaptive interpreter):一条指令执行几次后,如果操作数总是整数,就原地换成专用版本(如 BINARY_OP_ADD_INT),类型一变再换回通用版本,这本身就是一种在字节码层面做的分层。3.13 起 CPython 带了实验性 JIT,采用复制粘贴(copy-and-patch)技术把热点字节码拼成机器码,需要编译解释器时显式开启;3.14 的官方 Windows 和 macOS 安装包已包含它,但默认关闭,要用环境变量 PYTHON_JIT=1 打开,目前对多数程序提速有限。同在 3.13,还出现了可选的无 GIL 构建(free-threaded build,可执行文件名带 t,如 python3.13t),3.14 起转为官方支持,但仍不是默认构建。

  • 用 python -O 运行时,编译器直接丢掉所有 assert 语句(.pyc 文件名里会多一个 opt-1)。拿 assert 检查用户输入,在优化模式下检查就消失了。
  • 看到 .pyc 就以为 Python 是“编译型语言”:.pyc 里是字节码,仍然逐条解释执行,它省掉的只是解析和编译源码的时间,不改变运行速度。另外直接运行的主脚本不会写缓存,每次都重新编译,只有被导入的模块才有 .pyc。
  • 类型标注不影响执行:写成 square_sum("10") 照样运行到 range 那一行才报错,标注只给 mypy、pyright 这类外部工具看。

JavaScript 要在浏览器里从源码直接启动,引擎只能在运行时完成全部翻译。下面以 Node 和 Chrome 使用的 V8 引擎为例;TypeScript 则在这之前多一步。

function squareSum(n: number): number {
let total = 0;
for (let i = 1; i <= n; i++) {
total += i * i;
}
return total;
}
for (const n of [10, 1000]) {
console.log(`square_sum(${n}) = ${squareSum(n)}`);
}
// 输出:
// square_sum(10) = 385
// square_sum(1000) = 333833500

运行:tsx squares.ts。TypeScript 编译器 tsc 做两件事:检查类型,然后把类型标注擦掉,输出普通的 JavaScript。tsx 底层用 esbuild,只擦不查,所以快;类型错误要另跑 tsc --noEmit 才看得到。Node 自己也能直接运行 .ts 文件:22.6 起可以用 --experimental-strip-types 开关,23.6 和 22.18 起默认开启,24.12 和 25.2 起文档标为稳定。它的做法是把类型标注替换成空白,同样不做类型检查,而且只支持能直接擦掉的语法,enum、带参数属性的构造函数这类需要生成代码的写法不行。

拿到 JavaScript 之后,V8 先把源码解析成抽象语法树(abstract syntax tree,AST),再由解释器 Ignition 把它编译成字节码并解释执行。为了启动快,V8 对暂时没被调用的函数只做预解析,等第一次调用时才完整编译。执行时 Ignition 顺手记录类型反馈(type feedback):total += i * i 这一处见到的是不是总是小整数。

分层编译:冷代码在低层执行,热了升层,假设失效时退回

底层怎么实现。 按上图从上往下走:函数刚开始在 Ignition 里解释执行;调用或循环次数一多,交给 Sparkplug,它不做优化,直接把字节码逐条翻译成机器码,省掉取指令和分派的开销;再热一些,交给中层优化编译器 Maglev,它利用类型反馈快速生成较好的代码;最热的代码交给 TurboFan 做最重的优化,比如把 total 当成机器整数放在寄存器里,内联被调用的函数(TurboFan 的后端近年正逐步换成名为 Turboshaft 的新实现)。优化后的代码里埋着检查:如果某次 n 传进来是字符串,或者 total 超出了小整数范围,检查失败,V8 丢掉这份机器码,把执行状态还原成 Ignition 的帧继续跑,这就是图中往回的那条去优化的边。

  • 运行时没有 TypeScript 类型:JSON.parse 读进来的数据标成 Config 类型,运行时也不会检查它是不是真的有那些字段。外部数据要自己写校验。
  • 测性能不预热:一段代码只跑几次就计时,量到的是 Ignition 解释执行的速度;跑几万次之后才是优化后的速度。反过来,在测试里喂的参数类型和真实情况不同,可能触发反复去优化。
  • tsx 和 Node 的类型剥离都不报类型错误,程序照样跑。只用它们而不跑 tsc,等于放弃了 TypeScript 的全部检查。

C# 走字节码加虚拟机的路线,在 .NET 里字节码叫中间语言(Intermediate Language,IL);同时 .NET 提供两种提前编译的办法。

using System;
foreach (var n in new[] { 10, 1000 })
{
Console.WriteLine($"square_sum({n}) = {SquareSum(n)}");
}
static int SquareSum(int n)
{
var total = 0;
for (var i = 1; i <= n; i++)
{
total += i * i;
}
return total;
}
// 输出:
// square_sum(10) = 385
// square_sum(1000) = 333833500

运行:dotnet run squares.cs。编译器 Roslyn 把源码编译成 IL,连同类型、方法签名等元数据(metadata)一起放进程序集(assembly,一个 .dll 文件)。IL 也是基于栈的指令,但和 Python 字节码不同,它带着完整的类型:SquareSum 的参数是 int32,total += i * i 编译成 mul 和 add,栈上操作数的类型在编译时就确定为 int32,JIT 不必再猜。运行时由公共语言运行时(Common Language Runtime,CLR)加载程序集,在方法第一次被调用时用 JIT 把它的 IL 编译成机器码。.NET 10 的单文件运行 dotnet run x.cs 并不是解释执行:SDK 在后台为这个文件生成一个隐藏的项目,完整构建一遍,结果缓存起来,所以第一次要多等几秒,文件不变时再运行就快了。

底层怎么实现。 CLR 从 .NET Core 3.0 起默认开启分层编译,形状和上面那张图一样。方法第一次被调用时,JIT 用第 0 层(Tier 0)快速编译,几乎不做优化;调用计数超过阈值(默认 30 次)后,后台线程用第 1 层重新编译出优化版本,之后的调用换到新代码上。含长循环的方法用栈上替换(on-stack replacement,OSR)在循环中途切到优化代码。.NET 8 起默认开启动态 PGO(profile-guided optimization,按运行数据优化):热点方法升层前先运行一段插桩版本,记下虚调用实际落在哪个类型、哪些分支常走,第 1 层据此内联和排布代码。和 V8 不同,这些优化在假设不成立时走预留的慢路径,不需要退回低层。

另外两种模式把编译挪到构建时。ReadyToRun 在发布时预先把 IL 编译成机器码放进程序集,启动时省掉大部分 JIT,热点方法仍会被重新编译成第 1 层;IL 也留着,所以产物更大。Native AOT 则彻底不要 JIT:发布时把用到的代码和运行时一起编译、裁剪成一个本机可执行文件,目标机器不用装 .NET。.NET 10 的单文件程序用 dotnet publish x.cs 发布时默认就走 Native AOT。

  • Native AOT 限制运行时反射和动态代码生成:裁剪会删掉看起来没人用的类型,Type.GetType("某个名字") 这类按字符串找类型的代码可能找不到;System.Reflection.Emit 无法使用,依赖它的序列化库要换成源生成器(source generator)版本。
  • 第一次运行慢不代表程序慢:dotnet run x.cs 的首次构建、Tier 0 的粗糙代码都只在开头出现。测性能要用 Release 构建并预热,或者用 BenchmarkDotNet 这类会自动预热的工具。
  • 程序集里的 IL 和元数据几乎能原样反编译回 C#,用 ILSpy 一看便知。发布的 .dll 里没有秘密可藏,密钥不要写在代码里。

Go 走最直接的一条路:官方的 gc 编译器在构建时把源码编译成机器码,没有字节码,也没有 JIT。

package main
import "fmt"
func squareSum(n int) int {
total := 0
for i := 1; i <= n; i++ {
total += i * i
}
return total
}
func main() {
for _, n := range []int{10, 1000} {
fmt.Printf("square_sum(%d) = %d\n", n, squareSum(n))
}
}
// 输出:
// square_sum(10) = 385
// square_sum(1000) = 333833500

运行:go run squares.go。go run 先编译再执行,产物不留在当前目录(1.24 起放进构建缓存);要留下可执行文件用 go build。gc 编译器把源码解析后转成静态单赋值(static single assignment,SSA)形式的中间表示,在上面做内联、逃逸分析(escape analysis)、消除边界检查等优化,最后生成目标平台的机器码。Go 1.21 起也支持 PGO,但它是 AOT 式的:把线上采集的性能剖析文件 default.pgo 放在 main 包的目录下,下次构建时据此决定内联哪些函数。

底层怎么实现。 链接器默认把所有依赖的包和 Go 运行时(goroutine 调度器、垃圾回收器(garbage collector)、内存分配器)一起链接进同一个可执行文件。纯 Go 程序在 Linux 上用 CGO_ENABLED=0 构建时是完全静态链接的,不依赖 C 运行库;开着 cgo 时,导入 net、os/user 或直接调 C 代码,都会动态链接系统的 C 库。交叉编译只需设两个环境变量,比如 GOOS=linux GOARCH=arm64 go build(bash 写法),就能在 Windows 上生成 ARM Linux 的程序,因为编译器本身就带着所有平台的后端,标准库也大多用 Go 写成。运行时还保留着类型信息,供接口的动态分派、反射和垃圾回收使用,但它是编译时就定下的,不会再被拿来生成新代码。

  • 二进制比同样功能的 C 程序大:只打印一行的程序也有一两兆字节,因为调度器、垃圾回收器和类型信息都编进去了。可以用 -ldflags="-s -w" 去掉符号表和调试信息,运行时本身去不掉。
  • go run 每次都要经过编译(Go 1.24 起编译结果会进构建缓存,源码不变时第二次快很多),拿它计时会把编译时间算进去。测性能用 go build 后运行,或者用 go test -bench。
  • 交叉编译遇到 cgo 就不灵了:一旦依赖 C 代码,就需要目标平台的 C 编译器和库,GOOS/GOARCH 不再够用。
维度 Python JS/TS C# Go
支持程度 字节码解释;JIT 实验性 分层 JIT 分层 JIT;可选 AOT 只有 AOT
实现机制 编译成字节码 + 求值循环 + 特化 AST → Ignition 字节码 → Sparkplug / Maglev / TurboFan Roslyn → IL → CLR 分层 JIT + 动态 PGO gc 编译器 → SSA → 机器码
中间产物 __pycache__/*.pyc TS 擦成 JS;字节码只在内存里 程序集(.dll,IL + 元数据) 无,直接得到可执行文件
需要的运行时 目标机器装 Python 浏览器或 Node .NET 运行时;Native AOT 不需要 不需要,已编进二进制
启动速度 快 快 JIT 模式较慢;AOT 快 快
分发形式 源码或 .pyc,加依赖清单 JS 文件或打包后的 bundle 程序集;或单个本机可执行文件 单个可执行文件
运行时还有没有类型信息 值都带类型,标注不参与执行 JS 值带类型,TS 类型全被擦掉 有,元数据完整,可反射 有,供接口和反射用
看中间产物的工具 dis node --print-bytecode ILSpy、DOTNET_JitDisasm go build -gcflags=-S、go tool objdump

最大的差异在“需要的运行时”和“分发形式”两行:Python、JS 和 C# 的 JIT 模式都要先在目标机器上准备好运行时,Go 和 C# 的 Native AOT 只交一个文件。

Python 诞生在 1991 年,目标是写起来像伪代码、能在交互式环境里一行一行试。字节码解释正好满足这个需要:编译一行几乎不花时间,同一份代码在任何装了 CPython 的机器上都能跑,C 扩展接口也简单,大量科学计算库因此用 C 写核心、用 Python 粘合。后果是纯 Python 循环慢,计算密集的部分习惯交给 C 写的库;而 C 扩展依赖解释器内部的对象布局和引用计数,正是它让 JIT 和去掉 GIL 都推进得格外小心,直到 3.13 才以可选形式出现。

JavaScript 的约束最硬:网页加载时浏览器拿到的只有源码,必须立刻开始执行,不能先花几秒编译;而网页应用越做越大,又要求长时间运行的代码足够快。两头都要,就只能走最激进的分层 JIT:先用解释器保证启动,再一层层把热点编译得更快。几大浏览器引擎竞争了十几年,层数和复杂度都在增加。TypeScript 选择完全擦除类型,是为了不改变 JavaScript 的运行时,任何 JS 引擎都能直接跑它的输出,代价是运行时一点类型都不剩。

C# 继承了 Java 的虚拟机路线:编译成与平台无关的中间语言,由运行时 JIT,换来跨平台、完整的元数据和强大的反射,ASP.NET 的依赖注入和许多序列化库都建立在反射之上。云原生时代这条路的弱点暴露出来:容器和无服务器函数常常冷启动,几十毫秒的 JIT 和要预装的运行时都成了成本。.NET 于是补上 ReadyToRun 和 Native AOT,但 AOT 和反射天然冲突,生态正逐步改用源生成器把原本运行时做的事挪到编译时。

Go 是 Google 为自家服务器软件设计的,首要目标是构建快、部署简单:把一个文件拷到服务器上就能跑,不用管服务器装了什么版本的运行时。所以它选 AOT,并把运行时编进二进制。后果是没有 JIT 可以按运行数据优化(PGO 只能离线做),二进制偏大,也不能在不重启的情况下替换代码;但冷启动快、内存占用稳定,容器镜像可以只放一个文件。

四种选择落到实际上,是四种权衡:冷启动看 Go 和 AOT 模式;长时间运行的峰值性能,分层 JIT 能按真实数据优化,有时超过静态编译;热更新只有解释和 JIT 的路线做得到,Python 改完代码重新导入即可,Go 必须重新编译并重启进程。

Java 的 HotSpot 虚拟机是分层 JIT 的老前辈:C1 编译器快速编译,C2 编译器做重优化,.NET 和 V8 的分层思路都和它相通。GraalVM 的 Native Image 则把 Java 程序提前编译成本机可执行文件,同样要为反射写配置,和 .NET 的 Native AOT 面对的是同一个问题。

  • 发布时按目标环境选模式:命令行工具和无服务器函数在意冷启动,Go 默认就合适,C# 考虑 Native AOT;长时间运行的服务,C# 的 JIT 模式和 Node 都能靠分层编译达到很高的峰值性能。
  • 测性能先排除工具链的开销:不要拿 go run、dotnet run x.cs 的首次运行计时;JS 和 C# 要预热,最好用 go test -bench、BenchmarkDotNet 这类专门工具。
  • TypeScript 项目在持续集成里单独跑 tsc --noEmit,日常运行用 tsx 或 Node 的类型剥离都可以,但别把“能跑”当成“类型正确”。
  • 想知道代码被翻译成了什么,就去看中间产物:dis、--print-bytecode、ILSpy、-gcflags=-S。一个热点循环比预期慢时,这往往比猜测管用。

用 Python 和 Go 各写一个程序,打印“自己是被哪种实现运行的”。同一门语言可以有多种实现:Python 除了 CPython 还有 PyPy(带 JIT)、GraalPy;Go 除了官方的 gc 还有基于 GCC 的 gccgo 和面向嵌入式的 TinyGo。

提示:Python 的 sys 模块里有一个描述当前解释器实现的属性;Go 的 runtime 包里有一个常量记着编译这个程序用的编译器。

  • 源码到机器码有三条路:解释、编译成字节码再由虚拟机执行、直接编译成机器码;JIT 让虚拟机在运行中把热点字节码编译成机器码,AOT 把编译全部挪到构建时。
  • 分层编译先用便宜的方式跑,热了再交给优化编译器;V8 的 Ignition、Sparkplug、Maglev、TurboFan 和 CLR 的 Tier 0、Tier 1 都是这个思路,V8 在假设失效时还会去优化。
  • Python 的 .pyc 是字节码缓存,不是编译成机器码;3.13 起的 JIT 和无 GIL 构建都是可选的。TypeScript 的类型在运行前就被擦掉。
  • C# 的 IL 带着完整类型,可以 JIT 也可以 ReadyToRun 或 Native AOT;Go 的 gc 编译器把运行时一起编进单个可执行文件。
  • 这些选择决定了冷启动、峰值性能、要不要预装运行时和能不能热更新,测性能时要先排除编译和预热的影响。