1. 解释执行与编译执行
一句话总结: 解释执行逐句即时翻译,编译执行预先翻译成机器码,两者的启动与峰值性能权衡贯穿语言运行时设计。
解释器把源码逐条翻译并立即执行,不产持久化目标文件,启动快、便于交互与动态修改;编译器把源码整体翻译成机器码,执行前有编译期开销,但运行期更快。现代语言运行时很少纯解释或纯编译,普遍采用解释起步、热点路径 JIT 编译的混合策略,兼顾启动速度与峰值性能。
| 执行模型 | 启动 | 峰值性能 | 动态性 | 代表 |
|---|---|---|---|---|
| 纯 AST 解释 | 最快 | 低 | 极强 | 早期 Lisp |
| 字节码解释 | 快 | 中 | 强 | CPython、JVM 初始 |
| JIT 编译 | 慢 | 高 | 中 | V8、LuaJIT |
| AOT 编译 | 最慢 | 最高 | 弱 | Go、Rust |
执行模型对比:
源码 ─→ AST ─→ 字节码 ─→ 机器码
↑解释 ↑解释 ↑JIT
选型受语言目标驱动:脚本语言看重启动与部署便利,倾向解释器;系统语言看重性能与可预测性,倾向 AOT。同一语言也可多实现并存,例如 JavaScript 有解释型与 JIT 型引擎。解释器与编译器的边界本身是流动的,字节码既是解释的输入也是 JIT 的输入。
2. 树遍历解释器与 AST 解释
一句话总结: AST 解释器直接递归遍历语法树执行,实现最简单但每个节点都要分派,性能受限。
树遍历解释器把语义分析与解释合为一体:每个 AST 节点对应一个求值函数,递归调用子节点求值再组合结果。优点是与语法分析器共享数据结构、无需单独的字节码编译步骤,调试直观。缺点是每次访问节点都要做类型分派(node 的 kind 分支),且树节点对象开销大、缓存局部性差。
def evaluate(node, env):
if node.kind == "Lit":
return node.value
if node.kind == "Ref":
return env.lookup(node.name)
if node.kind == "BinOp":
left = evaluate(node.left, env)
right = evaluate(node.right, env)
if node.op == "+":
return left + right
if node.op == "*":
return left * right
if node.kind == "Call":
fn = evaluate(node.callee, env)
args = [evaluate(a, env) for a in node.args]
return fn(*args)
raise InterpError(f"unknown node {node.kind}")
| 求值组件 | 职责 | 常见实现 |
|---|---|---|
| 字面量 | 返回常量值 | 直接返回 |
| 引用 | 查环境变量 | 哈希查找 |
| 二元运算 | 组合两子式 | 分派运算 |
| 调用 | 求值函数与实参 | 尾递归或栈 |
| 条件 | 短路控制流 | 分支求值 |
AST 解释的性能瓶颈在分派与对象分配。改进手段包括:把 AST 节点对象池化、对常见节点类型做快速路径、用一次性 case 分派表替代 if 链。许多教育语言与配置语言(如早期的 Python、部分 DSL)用树遍历解释起步,等到语言规模变大再迁移到字节码。对绝大多数解释器而言,AST 解释只是起点而非终点。
3. 字节码虚拟机
一句话总结: 字节码虚拟机把 AST 编译成紧凑指令数组,用循环分派执行,显著提升解释性能与可移植性。
字节码是平台无关的中间指令序列,紧凑且加载快。AST 先编译成字节码,虚拟机用一个大循环逐条取指分派执行。相比树遍历解释,字节码消除了对象树遍历与逐节点分派,指令紧凑让指令缓存命中更好。CPython 的 3.11 之后引入按行缓存与自适应解释,正是为了逼近 JIT 的解释吞吐。
字节码示例(模拟 Python):
LOAD_NAME a
LOAD_CONST 10
BINARY_ADD
STORE_NAME x
解释循环:
for opcode in code:
dispatch[opcode]()
typedef enum {
OP_CONST, OP_LOAD, OP_ADD, OP_STORE, OP_JMP, OP_CALL
} Opcode;
void interpret(Instruction *code, size_t n) {
int stack[256];
int sp = 0;
size_t pc = 0;
while (pc < n) {
switch (code[pc++].op) {
case OP_CONST: stack[sp++] = code[pc++].val; break;
case OP_ADD: stack[sp-2] += stack[sp-1]; sp--; break;
case OP_LOAD: stack[sp++] = globals[code[pc++].idx]; break;
case OP_STORE: globals[code[pc++].idx] = stack[--sp]; break;
case OP_JMP: pc = code[pc].target; break;
}
}
}
| 优化技巧 | 效果 | 风险 |
|---|---|---|
| 计算跳转 | 免顺序 if 分派 | 目标平台差异 |
| 超指令 | 合并常见指令对 | 复杂度上升 |
| 寄存器字节码 | 减少栈顶搬运 | 编解码变繁 |
| 自适应内联 | 动态改写热点 | 复杂度高 |
字节码设计要在紧凑性与分派成本间平衡:指令越多取指偏移越大,指令太粗则语义重复。栈机字节码简单直观,寄存器机字节码(如 Lua 5.x、Dalvik)减少访栈次数但编译复杂。JIT 编译器常直接在字节码上做分析,字节码越接近低层 IR,JIT 的翻译越顺畅。
3.1 栈机与寄存器机对比
栈机字节码的每条指令隐式操作栈顶,指令短而紧凑,但相同运算需要多次压栈弹栈。寄存器机字节码显式携带操作数寄存器号,指令稍长却减少栈顶搬运,解释循环的取指与执行都更直接。Lua 5.x 从栈机改为寄存器机换来约三成的性能提升,代价是编译阶段要做寄存器分配。
| 维度 | 栈机 | 寄存器机 |
|---|---|---|
| 指令长度 | 短 | 较长 |
| 取指开销 | 低 | 中 |
| 栈操作 | 频繁 | 少 |
| 编译复杂度 | 简单 | 需寄存器分配 |
| 代表实现 | CPython、JVM | Lua 5.x、Dalvik |
4. 内联缓存与多态内联
一句话总结: 内联缓存缓存属性与方法查找的最终结果,把昂贵的动态查找变成快速路径检查。
动态语言属性访问(obj.foo)默认要做类查找、继承链遍历与访问器调用,开销大。内联缓存(inline cache)在首次查找后缓存键值,后续相同形状的对象直接命中缓存。单态缓存只适配一种对象形状,遇到多形状时退化为多态缓存或回退到通用查找。这种技术在 V8、LuaJIT 与 CPython 3.12 中广泛使用。
def load_field(obj, name, icache):
# icache: (shape_id, offset, version)
shape = obj.shape()
if shape.id == icache.shape_id and \
shape.version == icache.version:
return obj.memory[icache.offset]
# 缓存未命中: 完整查找并更新缓存
offset = lookup_slow(obj.cls, name)
icache.shape_id = shape.id
icache.version = shape.version
icache.offset = offset
return obj.memory[offset]
| 缓存类型 | 适配对象形状 | 性能特征 |
|---|---|---|
| 单态缓存 | 一种 | 最快 |
| 多态缓存 | 少量(2-8) | 次快 |
| 巨型缓存 | 多分支 | 折中 |
| 回退路径 | 任意 | 慢速查找 |
缓存有效性依赖对象形状稳定:同一类创建的对象通常形状一致,因此大量命中单态路径。形状(hidden class)技术把对象布局记录为树,属性增删导致形状变化,同时缓存也随之失效。方法调用还可做内联:命中单态时直接内联被调函数体,这就是多态内联的基础。缓存失效的准确性至关重要,错误的缓存会返回错误结果。
5. 热点探测与分层编译
一句话总结: 运行时统计执行频度识别热点,分层编译让热点方法逐渐升级到更激进的优化层级。
热点探测跟踪方法或循环的执行次数,超过阈值就标记为热点并触发编译。常见的探测手段包括方法计数、回边计数与按分支计数。分层编译(tiered compilation)让解释执行的代码先跑,热点方法先编译成低优化层级的机器码,更高频的热点再升级到高优化层级。这样既避免冷代码被过度优化拖累启动,又让热代码得到深度优化。
分层编译层级(类似 HotSpot):
Tier 0: 解释执行
Tier 1: 简单 C1 编译
Tier 2: 受限 C2 编译
Tier 3: 完整 C2 编译(激进优化)
方法执行计数超阈值 → 从 Tier 0 升级到 Tier 1
再次超阈值 → 升级到 Tier 3
| 热点判定 | 触发条件 | 代表系统 |
|---|---|---|
| 方法计数 | 方法调用次数超阈值 | JVM |
| 回边计数 | 循环迭代超阈值 | JVM |
| 执行剖面 | 分支概率超阈值 | V8 |
| 采样 | 定时器采样栈顶 | Java Flight Recorder |
分层编译让每个层级承担不同职责:低层级编译快、含基本优化,高层级编译慢、含深度优化。高层级优化依赖之前层级收集的剖面数据,例如分支概率与类型分布。探测本身有开销,因此阈值与采样频率要权衡。JIT 的编译发生在后台线程时,需要与应用执行并发,这对编译器的线程模型提出要求。
6. JIT 编译的代码生成
一句话总结: JIT 在运行时把热点字节码翻译成机器码,直接复用寄存器分配与指令选择等后端技术。
JIT 编译器复用常规编译器的后端管线:字节码或低层 IR 经指令选择、寄存器分配与指令调度生成机器码。与 AOT 不同的是,JIT 拥有运行时信息(类型分布、分支概率),可以做针对性优化,如去虚拟化、类型特化与循环优化。JIT 编译时间计入运行时,因此编译策略要快速,常用线性扫描分配与局部指令选择。
// JIT 发射机器码: 把 add 指令发射为 x86-64 add
void emit_add(JitCtx *jc, Reg dst, Reg src) {
// addq %src, %dst opcode: 0x48 0x01 0xC0 | (src<<3) | dst
uint8_t rex = 0x48;
uint8_t opc = 0x01;
uint8_t modrm = 0xC0 | (src << 3) | dst;
jit_emit(jc, rex); jit_emit(jc, opc); jit_emit(jc, modrm);
}
void *compile_hot_method(JitCtx *jc, Bytecode *bc) {
for (each instruction in bc) {
switch (opcode) {
case OP_ADD: emit_add(jc, rdst(), rsrc()); break;
case OP_MUL: emit_mul(jc, rdst(), rsrc()); break;
case OP_CALL: emit_call(jc, resolve_target(bc)); break;
}
}
emit_ret(jc);
return jit_finalize(jc);
}
| JIT 环节 | 采用技术 | 与 AOT 差异 |
|---|---|---|
| 类型特化 | 剖面类型 | 仅在运行时可得 |
| 寄存器分配 | 线性扫描 | 求快 |
| 内联 | 多态内联 | 基于缓存 |
| 代码生成 | 直接发射 | 无汇编步骤 |
JIT 生成代码的性能关键在于把动态语义变成静态假设:若某调用 99% 时目标相同,就按该目标特化并在入口做类型检查,检查失败时回退。现代 JIT 还会做影子栈与安全点,用于精确垃圾回收。直接发射机器码意味着要处理重定位与指令对齐,这些细节都封装在目标后端中。
6.1 类型特化示例
对热点循环里的加法,JIT 依据剖面数据假设操作数总是整数,发射整数加法指令并插入类型检查:操作数不是整数则跳到去优化桩。特化把原本要分派多分支的解释逻辑压缩成一次类型检查加一条机器指令,这是 JIT 相对解释器最大的性能来源。
// 特化后的伪代码: 假设 a b 均为 int
if (unlikely(!is_int(a) || !is_int(b)))
goto deopt_stub; // 恢复解释器
int r = a->ival + b->ival; // 直接整型加法
store_result(r);
7. 代码缓存与优化去优化
一句话总结: 代码缓存管理已编译代码的生命周期,去优化在运行时假设失效时回退到低层级执行。
JIT 编译出的机器码需要缓存避免重复编译,缓存按方法或循环组织,条目要记录依赖的假设(如目标类、形状版本)。当假设被打破,例如内联缓存的类出现新子类,已编译代码可能不再正确,此时触发去优化(deoptimization):栈上的执行状态被恢复,控制权交回解释器或低层级编译代码,从安全点重新解释执行。
去优化流程:
机器码内假设: callee 恒为 ClassA
→ 运行时发现实际 callee 为 ClassB
→ 挂起机器码执行, 重建解释器栈帧
→ 回退到解释器继续执行, 标记方法需重编译
| 缓存维度 | 作用 | 失效时机 |
|---|---|---|
| 方法代码 | 避免重复编译 | 类层级变化 |
| 内联假设 | 内联函数体 | 目标多态化 |
| 类型剖面 | 特化分支 | 分布突变 |
| 常量化 | 字段值假设 | 字段被写 |
代码缓存还涉及容量控制:缓存太大占内存、太小频繁重编译,常配 LRU 淘汰。多态化的调用会降级回退路径,缓存条目保留退化状态。去优化要求运行时能重建完整执行上下文,这就是 JIT 维护安全点与 OSR(栈上替换)的原因。JIT 的复杂度集中在这些跨层机制的衔接上,实现不当会引发正确性灾难。
7.1 OSR 与安全点
栈上替换(OSR)允许正在解释执行的长循环在运行中途切换到编译后的机器码,无需等循环结束。OSR 需要在循环回边处插入切换检查,并把当前解释器栈帧映射为机器码期望的寄存器与栈布局。安全点是可安全触发垃圾回收或去优化的程序位置,编译器在调用点与回边处放置安全点记录。
| 机制 | 触发点 | 作用 |
|---|---|---|
| OSR 入口 | 循环回边 | 中途切换机器码 |
| 安全点 | 调用点、回边 | 精确 GC、去优化 |
| 去优化桩 | 假设失效 | 恢复解释栈帧 |
| 栈映照 | 编译入口 | 寄存器与栈布局映射 |
8. 总结
| 环节 | 要点 |
|---|---|
| 解释与编译 | 启动与峰值性能的权衡 |
| AST 解释 | 实现简单、分派开销大 |
| 字节码 VM | 紧凑指令、循环分派 |
| 内联缓存 | 缓存查找结果加速动态访问 |
| 热点探测 | 计数与采样识别热点 |
| 分层编译 | 按热度升级优化层级 |
| JIT 代码生成 | 复用后端技术发射机器码 |
| 代码缓存 | 生命周期管理与去优化 |
解释器与 JIT 编译把编译技术的终点与运行时融合,让动态语言也能逼近原生性能。从 AST 解释到字节码再到分层 JIT,是一条逐渐复杂但性能阶梯上升的路线。本专题六篇文章从词法一路走到运行时,构成了编译原理与实现的完整地图。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。