本页目录

JIT 编译

在运行时把字节码编译成机器码。分层编译用 baseline 保启动速度,用优化 JIT 提峰值性能;inline caching 和推测+反优化让 JIT 敢做 AOT 不敢做的激进优化——推测失败就退回到解释执行。

概述

传统编译(AOT)在程序运行前生成全部机器码。JIT(Just-In-Time)在运行时将字节码或 IR 编译成机器码——只有在"这段代码确实被调用了"时才编译它。这带来两个独特优势:⁠运行时信息⁠(知道哪个分支真的热、哪个类型真的被用),和不需要编译没跑到的代码⁠(缩小编译范围、加速启动)。代价是编译时间算在运行时,所以 JIT 设计的核心矛盾是"编译质量 vs 编译开销"——这一篇讲 JIT 如何用分层编译推测+反优化来解决这个矛盾。

分层编译:不止一个 JIT,是几个互补

现代 JIT 不止一个编译器,而是分层的:

baseline/JIT (Ignition/V8, 或 C1/HotSpot):
  → 快速编译, 几乎零优化
  → 每个函数只编译一次, 编译时间 << 解释执行节省的时间

优化 JIT (TurboFan/V8, 或 C2/HotSpot):
  → 仅对"热点"函数 (被调用了很多次, 或循环迭代很多轮)
  → 做全套优化: inlining, GVN, LICM, loop unrolling, SROA
  → 编译时间长, 但热点函数运行时占比大, 值得

调用计数和循环回边计数决定"该不该升层":

counter[function] += 1                                  ← 每次调用加一
if counter[function] >= threshold_baseline:
    编译为 baseline code

backedge_counter[loop] += 1                             ← 每次回边加一
if backedge_counter[loop] >= threshold_optimized:
    进优化编译器

阈值是动态调的(V8 的 TurboFan 根据启动阶段放宽阈值,让冷启动快;进入稳态后降低阈值,让热点更快被优化)。

Inline Caching:多态调用的快速路径

JIT 最核心的优化之一,针对"同一条 obj.method() 调用了 1000 次,每次都同一个类型"这种场景:

普通路径 vs 内联缓存(IC)路径

普通路径 (无 IC) 查 obj 的类型 类型表查 method 偏移 跳转到偏移

内联缓存路径 (有 IC) obj.type == prev_type ?

命中 >95% 直接跳到 prev_offset 代码 fast path 未命中 查类型表 (慢路径 slow path) 更新 prev_type prev_offset fast path 只需 2–3 条指令(类型比较+条件跳转),命中率通常 >95%——本质是把"类型和上次一样"当推测来做

这不只是在编译器里做——inline cache 生成为机器码中的数据+代码组合⁠,每次调用来时只做一次类型比较 + 条件跳转(2–3 条指令),命中率通常 >95%。fast path 命中时,相当于把这 2–3 条指令当作一个推测(speculation):推测这次调用的类型和上次一样。inline caching 是多态调用的典型推测优化。

推测 + Guards + 反优化:JIT 的独特武器

AOT 编译器不知道运行时的类型分布,只能生成保守代码。JIT 看到"这个变量 1000 次里 999 次是 int"后,可以推测它是 int,生成最优路径 + guard:

guard: if (typeof(x) != INT) goto deopt_entry      ← guard
fast_code: return x + 1                             ← 推测路径

deopt_entry:
    保存当前状态到栈帧 (on-stack replacement)
    切回解释器或 baseline code
    在解释器里重做这一操作

关键机制是反优化(deoptimization):当推测被打破时,JIT 必须把当前优化帧的状态(寄存器、局部变量)映射回"未优化 IR 在这一点该有的状态",然后跳回解释器或 baseline 继续执行。这要求编译器维护从优化代码到未优化代码的映射表⁠(deoptimization metadata)——每个优化点在未优化 IR 的哪个位置,每个优化变量对应哪个未优化变量。

On-Stack Replacement(OSR)

反优化的特例:函数正在执行循环(可能跑了 10000 次),此时触发优化或反优化,需要在栈帧中间把执行状态从旧版本转移到新版本——这就是 OSR。OSR 要求 JIT 能"把正在跑的代码的栈帧转译成目标代码期望的栈帧"——这依赖 deopt metadata 的完备性。

代码缓存与碎片

JIT 编译的代码存在 code cache 里。和 GC 管理对象类似,code cache 也需要管理:

  • 空间⁠:编译太多函数,code cache 爆满 → 丢弃不太热的编译后代码(Code Cache Flush),回退到解释执行或 baseline。
  • 碎片⁠:编译后的函数大小不一致,频繁 flush 产生碎片 → code cache 用 slab 分配器(类似 malloc 的 arena),对常用尺寸做预切分。

分层与 AOT 的边界越来越模糊

现代运行时很多已不是纯 JIT:

  • Android ART:安装时把 dex 字节码 AOT 编译成机器码(利用安装时间),运行时只对热点做 JIT 补充。
  • GraalVM Native Image:AOT 全量编译到 native binary —— 无 JIT,但用推测优化⁠(如果知道类层次封闭,就 devirtualize)。
  • Wasm 的 JIT:浏览器里跑 Wasm,先做快速 baseline 编译(几毫秒),然后对热点做优化 JIT(几十毫秒)——和 JS JIT 是同一套引擎。

JIT 和 AOT 的共性是:⁠编译器 = 从一种表示翻译到一种更接近机器的表示⁠,区别只在"翻译发生在何时"。分层编译把这个时间拆成多层,就是让每种翻译在最适合它的时刻发生。

权衡与失败模式

  • 编译时间吃掉收益⁠:短期脚本只跑一次,编译比解释还慢 → 用分层,只对热点做重编译,宁可先解释。
  • 推测过激⁠:类型推测在离奇输入上连续 miss → 反优化风暴(不断 deopt→recompile→deopt)——监控 deopt 率,超过阈值停止对该函数重优化。
  • code cache 爆满⁠:长进程里不断编译新代码 → 设 code cache 上限,LRU 淘汰,满时降级到 baseline/解释。
  • 调试地狱⁠:JIT 的机器码和源码之间的映射是动态的 — deopt 改变栈帧,inline 改变调用栈 → 调试信息必须把"优化后的调用栈"映射回"源码层面的调用栈"(JVM 的 -XX:+PrintDeoptimizationDetails、V8 的 --trace-deopt)。

参考

  • V8: Ignition(解释器) + Sparkplug(baseline JIT) + Maglev/TurboFan(优化 JIT) — V8 博客有每层的设计文档
  • JVM: HotSpot C1(client compiler) + C2(server compiler), -XX:+PrintCompilation 观察分层
  • Aycock (2003): "A Brief History of Just-In-Time" — JIT 早期的学术综述

Keywords: JIT, just-in-time, method JIT, tracing JIT, tiered compilation, baseline compiler, optimizing compiler, hotspot, inline caching, hidden class, speculation, guard, deoptimization, on-stack replacement, OSR, deopt metadata, code cache, PGO, profile-guided optimization, AOT