JIT 编译技术详解

标签:编译原理首次发布:2026-07-20最近修改:2026-07-20

基本概念

JIT 的全称是 Just-In-Time Compilation,翻译成中文就是即时编译。如果只从字面意思上理解,JIT 的含义就是:程序不是在运行之前就把所有代码都编译成机器码,而是在程序运行过程中,根据实际执行情况,把一部分代码动态编译成机器码。

为了更好地理解 JIT,可以先把程序执行方式大致分成三类:

执行方式 典型语言 / 平台 基本思路
AOT 编译 C / C++ / Go / Rust 程序运行之前就编译成机器码
解释执行 早期 Python / Shell / 一些脚本语言 程序运行时一边读取代码一边解释执行
JIT 编译 Java / JavaScript V8 / LuaJIT / .NET 程序运行时把热点代码编译成机器码

如果用一条流程表示 AOT 编译,大概如下:

bash
源代码 -> 编译器 -> 机器码 -> 运行

如果用一条流程表示解释执行,大概如下:

bash
源代码 / 字节码 -> 解释器 -> 边解释边执行

如果用一条流程表示 JIT 编译,大概如下:

bash
源代码 / 字节码 -> 解释执行 -> 发现热点代码 -> 编译成本地机器码 -> 后续直接执行机器码

所以,JIT 不是一种单独的语言特性,而是一种运行时编译技术。它关注的核心问题是:程序已经运行起来了,能不能根据当前程序的真实运行情况,生成更适合当前场景的机器码。

为什么需要

如果程序都可以提前编译成机器码,那为什么还需要 JIT 呢?原因主要有几个。

动态信息

以 JavaScript 为例:

javascript
function add(a, b) {    return a + b;}

这段代码里的 + 操作,在 JavaScript 中并不一定是整数加法。它可能是:

  • 两个整数相加
  • 两个浮点数相加
  • 字符串拼接
  • 对象经过隐式转换后再相加

比如:

javascript
add(1, 2);        // 数字加法add("a", "b");    // 字符串拼接add({}, 1);       // 对象转换后再计算

如果每次调用 add 都走完整的动态类型判断,那么性能会比较差。但是如果运行时发现这个函数被调用了很多次,并且参数一直都是整数,那么 JIT 就可以生成一段专门处理整数加法的机器码。

概念上类似于:

asm
mov eax, ediadd eax, esiret

这就是 JIT 的一个重要价值:把运行时观察到的类型信息,变成更高效的机器码

热点代码

在一个程序里,并不是所有代码都会被频繁执行。通常只有一小部分代码会占据大部分运行时间,这部分代码一般被称为热点代码

比如一个服务程序中,可能有这些逻辑:

  • 初始化配置
  • 加载证书
  • 建立网络连接
  • 解析请求
  • 执行业务规则
  • 序列化返回结果

其中初始化配置可能只运行一次,而解析请求和业务规则可能运行几百万次。JIT 的思路就是:没必要对所有代码都做最激进的优化,只需要把真正频繁运行的代码优化好。

这和 CPU cache 的思想有点像:程序访问的数据有冷热之分,代码执行也有冷热之分。JIT 会把更多编译成本花在热点代码上。

运行时信息

AOT 编译器在编译程序时,并不知道程序运行时的真实输入是什么,也不知道某个分支是否经常成立。JIT 在运行时可以收集到这些信息,例如:

  • 某个函数被调用了多少次
  • 某个变量大部分时候是什么类型
  • 某个分支是否经常成立
  • 某个数组访问是否总是在边界范围内

有了这些信息以后,JIT 可以做更激进的优化。

例如下面这段伪代码:

go
if user.level > 10 {    fast_path()} else {    slow_path()}

如果运行时发现 user.level > 10 几乎总是成立,那么 JIT 就可以让 fast_path 的代码布局更靠前,让 CPU 更容易走到预测正确的路径。

解释执行

为了理解 JIT,先来看一个极简的字节码解释器。假设我们有一个表达式:

bash
a + b * 3

可以把它编译成一组简单的字节码:

bash
LOAD aLOAD bCONST 3MULADDRET

解释器的执行过程大概如下:

c
int run(ByteCode *code, Context *ctx) {    int stack[128];    int sp = 0;    for (int pc = 0; ; pc++) {        switch (code[pc].op) {        case LOAD:            stack[sp++] = ctx->vars[code[pc].arg];            break;        case CONST:            stack[sp++] = code[pc].arg;            break;        case ADD:            stack[sp - 2] = stack[sp - 2] + stack[sp - 1];            sp--;            break;        case MUL:            stack[sp - 2] = stack[sp - 2] * stack[sp - 1];            sp--;            break;        case RET:            return stack[sp - 1];        }    }}

这种解释执行的优点是实现简单,缺点也很明显:

  • 每执行一条字节码都要进入 switch
  • 每条指令都要做一次分发
  • 数据通常要在解释器维护的栈里来回移动
  • 编译器很难跨越解释器边界做优化

如果这个表达式只执行一次,解释执行没有问题。但如果这个表达式要执行一千万次,那么每次都走 switch 分发就比较浪费。

JIT 的思路是:既然表达式已经确定了,能不能直接生成一段等价的机器码?

概念上类似于:

asm
mov eax, [ctx + a_offset]mov ecx, [ctx + b_offset]imul ecx, ecx, 3add eax, ecxret

这样后续执行时就不需要解释器的 switch 分发了,而是直接执行 CPU 可以理解的机器码。

整体流程

不同语言和不同虚拟机的 JIT 实现差别很大,但整体流程通常可以抽象成下面几个阶段:

flowchart LR
    A["源码"] --> B["词法分析 / 语法分析"]
    B --> C["AST / 字节码 / IR"]
    C --> D["解释执行"]
    D --> E["收集运行时信息"]
    E --> F["发现热点代码"]
    F --> G["生成优化后的机器码"]
    G --> H["后续直接执行机器码"]
生成 IR:很多 JIT 系统不会直接从源码生成机器码,而是先把源码转成某种中间表示。

常见的中间表示有:

  • AST:抽象语法树
  • Bytecode:字节码
  • IR:中间表示

例如 Java 会先把 .java 编译成 .class 字节码,然后 JVM 在运行时解释或 JIT 编译这些字节码。JavaScript 引擎也类似,源码通常会先被解析成 AST,然后再生成字节码或 IR,最后再根据情况生成机器码。

采集信息:程序刚开始运行时,JIT 通常不会立刻把所有代码都编译成机器码。原因是编译本身也有成本。

如果一个函数只执行一次,那么花很多时间优化它并不划算。因此,很多运行时系统会先解释执行代码,并记录一些统计信息。

常见的统计信息包括:

  • 函数调用次数
  • 循环执行次数
  • 分支命中次数
  • 参数类型分布
  • 对象形状变化
  • 字段访问频率

这些信息一般被称为 profile data,也就是运行时画像。

识别热点:当某段代码的执行次数超过某个阈值时,运行时系统会认为它是热点代码。

例如:

  • 函数 foo 被调用 10000 次
  • 循环 loop 执行 50000 次
  • 表达式 filter 执行 1000000 次

这些代码就值得进入 JIT 编译流程。

生成代码:JIT 编译器会根据字节码、IR 和运行时统计信息,生成当前 CPU 架构可以直接执行的机器码。

如果运行在 x86-64 上,就生成 x86-64 机器码。如果运行在 ARM64 上,就生成 ARM64 机器码。

这一层会涉及很多传统编译器技术,例如:

  • 指令选择
  • 寄存器分配
  • 栈帧布局
  • 常量折叠
  • 死代码删除
  • 函数内联
  • 分支优化
替换入口:机器码生成之后,运行时系统通常会把原来的解释执行入口替换成机器码入口。

概念上可以理解成:

bash
原来:    call interpreter(foo_bytecode)JIT 后:    call foo_native_code

这样后续再调用这个函数时,就不用进入解释器了。

实践

下面用 C 写一个非常小的 JIT 示例。这个例子不做完整编译器,只是手动生成一段 x86-64 机器码。

目标是生成一个更接近真实使用场景的规则过滤函数:

c
int filter(int status, int cost) {    return status >= 500 && cost > 100;}

这个函数可以类比日志过滤、规则引擎或者数据库查询中的 where 条件。规则本身是在运行时确定的,但规则确定以后会被执行很多次,这才是 JIT 比较有意义的地方。

在 x86-64 System V ABI 下,前两个 int 参数通常分别放在 ediesi 中,返回值放在 eax 中。因此可以生成如下汇编:

asm
xor eax, eaxcmp edi, 500jl  donecmp esi, 100setg aldone:ret

对应的机器码如下:

text
31 c0                xor eax, eax81 ff f4 01 00 00    cmp edi, 5007c 06                jl  done83 fe 64             cmp esi, 1000f 9f c0             setg alc3                   ret

完整代码如下:

c
#include <stdint.h>#include <stdio.h>#include <string.h>#include <sys/mman.h>#include <unistd.h>typedef int (*jit_func_t)(int status, int cost);int main() {    unsigned char code[] = {        0x31, 0xc0,                         // xor eax, eax        0x81, 0xff, 0xf4, 0x01, 0x00, 0x00, // cmp edi, 500        0x7c, 0x06,                         // jl done        0x83, 0xfe, 0x64,                   // cmp esi, 100        0x0f, 0x9f, 0xc0,                   // setg al        0xc3                                // ret    };    size_t page_size = (size_t)sysconf(_SC_PAGESIZE);    void *mem = mmap(NULL, page_size, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);    if (mem == MAP_FAILED) {        perror("mmap");        return 1;    }    memcpy(mem, code, sizeof(code));    if (mprotect(mem, page_size, PROT_READ | PROT_EXEC) != 0) {        perror("mprotect");        return 1;    }    jit_func_t fn = (jit_func_t)mem;    printf("%d\n", fn(503, 180)); // 1    printf("%d\n", fn(404, 8));   // 0    munmap(mem, page_size);    return 0;}

这段代码的执行流程如下:

  1. 准备一段机器码。

  2. 通过 mmap 申请一块内存。

  3. 先把这块内存设置为可读可写,用于写入机器码。

  4. 使用 memcpy 把机器码复制进去。

  5. 使用 mprotect 把内存权限改成可读可执行。

  6. 把这块内存强转成函数指针。

  7. 像调用普通函数一样调用它。

这就是最小形态的 JIT:运行时生成机器码,并把机器码当成函数执行

如果这个规则只是执行一次,那么这样做完全没有必要。但如果规则来自配置中心、SQL 条件或者风控策略,并且需要对几百万条记录反复判断,那么把通用解释逻辑变成专用机器码就有意义了。

对比一下两种方式:

txt
解释执行:    每条记录都解释 AST    每条记录都判断字段名、操作符、常量    每条记录都进行分发JIT 执行:    规则确定后生成机器码    每条记录只执行比较和跳转    省掉解释器分发成本
内存安全:上面的代码中有一个细节:内存一开始是 PROT_READ | PROT_WRITE,写完机器码以后又通过 mprotect 改成了 PROT_READ | PROT_EXEC。

为什么不一开始就设置成:PROT_READ | PROT_WRITE | PROT_EXEC

原因是安全。现代操作系统和运行时一般会尽量遵守 W^X 原则(Writable xor Executable)

意思是:一块内存最好不要同时可写又可执行。

如果内存同时可写可执行,攻击者一旦找到写内存漏洞,就可能把恶意机器码写进去,然后跳过去执行。这会明显增加代码注入攻击的风险。

所以 JIT 系统一般会采用下面的方式:

  • 生成代码阶段:内存可写,不可执行
  • 执行代码阶段:内存可执行,不可写

这也是 JIT 比普通 AOT 编译更复杂的原因之一。JIT 不只是编译器问题,还涉及运行时内存管理和安全策略。

优化手段

JIT 的优势不只是“能生成机器码”,更重要的是它可以根据运行时信息生成更激进的机器码。下面是一些常见优化手段。

类型特化

类型特化是 JIT 最常见的优化之一。还是以 JavaScript 的加法为例:

javascript
function add(a, b) {    return a + b;}

如果运行时发现 ab 大部分时候都是整数,那么 JIT 可以生成整数加法版本。

如果后面突然传入字符串,那么原来的假设失效,就需要回退到更通用的路径。

可以理解成:

text
快速路径:    假设 a、b 都是 int    直接执行整数加法慢路径:    类型不符合假设    回退到通用逻辑

这种方式的关键点是:大部分情况下走快速路径,少数异常情况走慢路径。

函数内联

函数调用本身有成本,例如:

  • 参数传递
  • 保存返回地址
  • 进入新栈帧
  • 返回调用方

如果一个小函数被频繁调用,JIT 可以把函数体直接展开到调用点。

例如:

javascript
function square(x) {    return x * x;}function calc(a) {    return square(a) + 1;}

内联后可以变成:

javascript
function calc(a) {    return a * a + 1;}

内联的价值不只是减少函数调用成本,更重要的是内联后暴露了更多优化机会。比如常量折叠、死代码删除、寄存器分配都可能因此变得更好。

对象访问

动态语言访问对象字段通常比较复杂。

例如:

javascript
function getName(user) {    return user.name;}

从语义上看,user.name 是一次字段访问。但在动态语言中,运行时要考虑:

  • user 是否为 null
  • user 是否是对象
  • name 字段是否存在
  • name 字段在对象中的位置
  • 原型链上是否存在 name

如果每次访问都完整查找字段,性能会比较差。

JIT 引擎通常会记录对象的结构信息。假设运行时发现传入的 user 对象结构一直相同,那么就可以把字段访问优化成固定偏移访问。

概念上类似:

asm
mov rax, [rdi + name_offset]

这和 C 语言访问结构体字段很像:

c
typedef struct {    int id;    char *name;} User;user->name;

C 编译器在编译时就知道 name 字段的偏移,而 JIT 是在运行时根据对象形状推导出这个偏移。

这类优化对于数组、字符串、矩阵计算等场景非常重要。

逃逸分析

逃逸分析用于判断一个对象是否会逃出当前函数。例如:

java
class Point {    int x;    int y;}int calc() {    Point p = new Point();    p.x = 1;    p.y = 2;    return p.x + p.y;}

如果 JIT 发现 p 只在 calc 函数内部使用,没有被返回,也没有被其他线程访问,那么它可能不需要真的在堆上分配这个对象。优化后概念上类似:

java
int calc() {    int x = 1;    int y = 2;    return x + y;}

这样可以减少堆分配和 GC 压力。

反优化

JIT 经常基于运行时信息做假设。例如:

  • 假设 add 的参数都是整数
  • 假设 user 对象的字段布局不变
  • 假设某个分支大部分时候成立

这些假设可以带来性能提升,但假设也可能失效。

例如:

javascript
add(1, 2);add(3, 4);add(5, 6);add("hello", "world");

前面几次调用让 JIT 以为 add 是整数加法函数,于是生成了整数加法机器码。但最后一次传入了字符串,此时继续执行整数加法机器码就错了。

所以 JIT 系统需要支持反优化,也就是 deoptimization。

反优化的大致思路是:

  1. 在优化机器码里插入类型检查
  2. 如果检查通过,继续走快速路径
  3. 如果检查失败,跳回解释器或低级优化代码
  4. 恢复解释器需要的栈帧和变量状态

这也是 JIT 实现复杂的一个重要原因。它不仅要能生成快代码,还要在假设失效时正确回退。

Go 和 C

C、Go 这类语言通常使用 AOT 编译。也就是说,程序运行之前就已经被编译成机器码。

但是这并不意味着 C 和 Go 不能使用 JIT 技术。只要程序在运行时生成机器码,并把这段机器码执行起来,就可以说使用了 JIT 思路。

C 语言:非常适合做 JIT 实验,因为它允许比较直接地操作内存、函数指针和系统调用。

一个 C 语言 JIT 系统一般会涉及:

  • mmap 申请内存
  • mprotect 修改内存权限
  • 生成目标 CPU 的机器码
  • 转换成函数指针
  • 按 ABI 规则传参和返回

难点在于:

  • 不同 CPU 指令集不同
  • 不同系统 ABI 不同
  • 寄存器分配复杂
  • 栈对齐不能出错
  • 可执行内存管理有安全限制
Go 语言:也可以做 JIT,但会更麻烦一些。

原因包括:

  • Go 函数调用有自己的 ABI 细节
  • Go runtime 管理栈增长、GC、调度
  • 直接跳转到手写机器码时要谨慎处理 GC 指针
  • Go 不鼓励随意执行 runtime 外部生成的机器码

所以在 Go 里做 JIT,常见路线有三种:

  1. 使用 C / 汇编实现底层 JIT,再由 Go 调用。

  2. 不生成本地机器码,而是生成 bytecode,通过高效 VM 执行。

  3. 运行时生成 Go / C 源码,再调用编译器生成动态库。

第一种性能最好,但工程复杂度最高。第二种更安全,适合做规则引擎、表达式引擎。第三种实现简单,但编译延迟较高。

JSON 场景

以高性能 JSON 库为例,JIT 的价值主要体现在减少反射和通用分发成本

假设有一个 Go 结构体:

go
type User struct {    ID   int64  `json:"id"`    Name string `json:"name"`    Age  int    `json:"age,omitempty"`}

通用 JSON 序列化器通常要做这些事:

text
拿到 interface{}-> reflect 判断真实类型-> 遍历字段-> 读取 json tag-> 判断 omitempty-> 根据字段类型选择编码函数-> 写入结果 buffer

这些逻辑很通用,但对于 User 这个具体类型来说,很多信息其实是固定的:

  • ID 字段偏移固定
  • Name 字段偏移固定
  • Age 字段偏移固定
  • JSON key 固定是 "id""name""age"
  • ID 一定按 int64 编码
  • Name 一定按 string 编码
  • Age 一定按 int 编码

JIT 的思路就是:第一次看到 User 类型时分析它的结构,后续生成一条专门处理 User 的路径。概念上类似:

go
func marshalUser(p *User, buf []byte) []byte {    buf = append(buf, `{"id":`...)    buf = appendInt64(buf, p.ID)    buf = append(buf, `,"name":`...)    buf = appendString(buf, p.Name)    if p.Age != 0 {        buf = append(buf, `,"age":`...)        buf = appendInt(buf, p.Age)    }    buf = append(buf, '}')    return buf}

底层看起来更像:

asm
; rdi = *Usermov rax, [rdi + 0]     ; 读取 ID; 调用 int64 encodermov rcx, [rdi + 8]     ; 读取 Name.datamov rdx, [rdi + 16]    ; 读取 Name.len; 调用 string encoder

这里的核心变化是:从“每次运行时都反射判断”变成“第一次分析类型,后续直接按固定偏移访问”

这正是 JIT 在很多序列化、反序列化、规则引擎、RPC 框架里的价值。

适合场景

JIT 并不是所有场景都适合。它更适合下面这类场景:

场景 为什么适合 JIT
动态语言运行时 类型信息运行时才能确定
正则表达式 规则固定后会反复匹配大量输入
JSON / RPC 编解码 类型结构固定,调用频繁
SQL 查询引擎 查询计划确定后要扫描大量数据
规则引擎 规则运行时下发,但执行频率高
WASM / eBPF VM 字节码可以编译成本地机器码
表达式引擎 AST 解释分发成本较高

不适合 JIT 的场景也很明显:

  • 代码只执行一次
  • 输入规模很小
  • 规则变化非常频繁
  • 编译成本大于执行收益
  • 对安全隔离要求极高但实现能力不足

一句话概括就是:JIT 适合规则确定以后反复执行的场景

优缺点

优点
  • 可以根据运行时信息做优化。

  • 可以减少解释器分发成本。

  • 可以针对具体类型、具体规则生成专用代码。

  • 对动态语言性能提升明显。

  • 对规则引擎、查询引擎、编解码器等场景很有价值。

缺点
  • 实现复杂。

  • 启动阶段可能更慢。

  • 需要维护代码缓存。

  • 需要处理可执行内存权限。

  • 需要考虑不同 CPU 架构和 ABI。

  • 调试难度比普通代码高。

  • 生成错误机器码时可能直接崩溃。

执行方式对比

维度 AOT 编译 解释执行 JIT 编译
编译时间 运行前 基本没有单独编译阶段 运行时
启动速度 通常较快 通常较快 可能较慢
峰值性能 较低
运行时信息利用 较弱 可以观察但不生成机器码 很强
实现复杂度 编译器复杂 解释器相对简单 编译器和运行时都复杂
典型场景 C / Go / Rust Shell / 简单脚本 JVM / V8 / LuaJIT

可以看到,JIT 本质上是在解释执行和 AOT 编译之间做折中:

  • 解释执行启动快,但峰值性能低
  • AOT 编译峰值性能高,但缺少运行时信息
  • JIT 先运行,再根据真实情况优化热点代码

总结

JIT 的核心思想并不神秘,它本质上就是:在程序运行过程中,把频繁执行的通用逻辑编译成专用机器码

从底层看,JIT 做的事情包括:

  1. 先解释执行代码。
  2. 收集运行时信息。
  3. 找出热点代码。
  4. 根据当前类型、分支、对象布局等信息生成机器码。
  5. 后续直接执行机器码。
  6. 如果优化假设失效,再回退到通用路径。

对于熟悉 C、Go 和汇编的开发者来说,可以把 JIT 理解成:

  • 程序运行时临时生成一段汇编 / 机器码,
  • 把它放进可执行内存,
  • 然后通过函数指针跳过去执行。

JIT 真正有价值的地方,不只是“动态生成代码”,而是能够把运行时观察到的信息转化成更具体、更少分支、更接近手写专用代码的机器码。所以,在规则引擎、表达式引擎、JSON 编解码、SQL 执行器、正则引擎、虚拟机等场景中,JIT 都是一种非常重要的性能优化技术。