「解释器与 JIT 编译」

从 AST 解释器到字节码虚拟机再到 JIT 编译,讲解树遍历解释、内联缓存、热点探测、分层编译与代码缓存的实现原理,对比各执行模型取舍并给出伪代码示例。

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、JVMLua 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,是一条逐渐复杂但性能阶梯上升的路线。本专题六篇文章从词法一路走到运行时,构成了编译原理与实现的完整地图。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「compiler」更多文章

  1. 「错误恢复与诊断」
  2. 「运行时与内存管理」
  3. 「现代优化 Pass 管线」