基本概念
JIT 的全称是 Just-In-Time Compilation,翻译成中文就是即时编译。如果只从字面意思上理解,JIT 的含义就是:程序不是在运行之前就把所有代码都编译成机器码,而是在程序运行过程中,根据实际执行情况,把一部分代码动态编译成机器码。
为了更好地理解 JIT,可以先把程序执行方式大致分成三类:
| 执行方式 | 典型语言 / 平台 | 基本思路 |
|---|---|---|
| AOT 编译 | C / C++ / Go / Rust | 程序运行之前就编译成机器码 |
| 解释执行 | 早期 Python / Shell / 一些脚本语言 | 程序运行时一边读取代码一边解释执行 |
| JIT 编译 | Java / JavaScript V8 / LuaJIT / .NET | 程序运行时把热点代码编译成机器码 |
如果用一条流程表示 AOT 编译,大概如下:
源代码 -> 编译器 -> 机器码 -> 运行如果用一条流程表示解释执行,大概如下:
源代码 / 字节码 -> 解释器 -> 边解释边执行如果用一条流程表示 JIT 编译,大概如下:
源代码 / 字节码 -> 解释执行 -> 发现热点代码 -> 编译成本地机器码 -> 后续直接执行机器码所以,JIT 不是一种单独的语言特性,而是一种运行时编译技术。它关注的核心问题是:程序已经运行起来了,能不能根据当前程序的真实运行情况,生成更适合当前场景的机器码。
为什么需要
如果程序都可以提前编译成机器码,那为什么还需要 JIT 呢?原因主要有几个。
动态信息
以 JavaScript 为例:
function add(a, b) { return a + b;}这段代码里的 + 操作,在 JavaScript 中并不一定是整数加法。它可能是:
- 两个整数相加
- 两个浮点数相加
- 字符串拼接
- 对象经过隐式转换后再相加
比如:
add(1, 2); // 数字加法add("a", "b"); // 字符串拼接add({}, 1); // 对象转换后再计算如果每次调用 add 都走完整的动态类型判断,那么性能会比较差。但是如果运行时发现这个函数被调用了很多次,并且参数一直都是整数,那么 JIT 就可以生成一段专门处理整数加法的机器码。
概念上类似于:
mov eax, ediadd eax, esiret这就是 JIT 的一个重要价值:把运行时观察到的类型信息,变成更高效的机器码。
热点代码
在一个程序里,并不是所有代码都会被频繁执行。通常只有一小部分代码会占据大部分运行时间,这部分代码一般被称为热点代码。
比如一个服务程序中,可能有这些逻辑:
- 初始化配置
- 加载证书
- 建立网络连接
- 解析请求
- 执行业务规则
- 序列化返回结果
其中初始化配置可能只运行一次,而解析请求和业务规则可能运行几百万次。JIT 的思路就是:没必要对所有代码都做最激进的优化,只需要把真正频繁运行的代码优化好。
这和 CPU cache 的思想有点像:程序访问的数据有冷热之分,代码执行也有冷热之分。JIT 会把更多编译成本花在热点代码上。
运行时信息
AOT 编译器在编译程序时,并不知道程序运行时的真实输入是什么,也不知道某个分支是否经常成立。JIT 在运行时可以收集到这些信息,例如:
- 某个函数被调用了多少次
- 某个变量大部分时候是什么类型
- 某个分支是否经常成立
- 某个数组访问是否总是在边界范围内
有了这些信息以后,JIT 可以做更激进的优化。
例如下面这段伪代码:
if user.level > 10 { fast_path()} else { slow_path()}如果运行时发现 user.level > 10 几乎总是成立,那么 JIT 就可以让 fast_path 的代码布局更靠前,让 CPU 更容易走到预测正确的路径。
解释执行
为了理解 JIT,先来看一个极简的字节码解释器。假设我们有一个表达式:
a + b * 3可以把它编译成一组简单的字节码:
LOAD aLOAD bCONST 3MULADDRET解释器的执行过程大概如下:
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 的思路是:既然表达式已经确定了,能不能直接生成一段等价的机器码?
概念上类似于:
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["后续直接执行机器码"]
常见的中间表示有:
- AST:抽象语法树
- Bytecode:字节码
- IR:中间表示
例如 Java 会先把 .java 编译成 .class 字节码,然后 JVM 在运行时解释或 JIT 编译这些字节码。JavaScript 引擎也类似,源码通常会先被解析成 AST,然后再生成字节码或 IR,最后再根据情况生成机器码。
如果一个函数只执行一次,那么花很多时间优化它并不划算。因此,很多运行时系统会先解释执行代码,并记录一些统计信息。
常见的统计信息包括:
- 函数调用次数
- 循环执行次数
- 分支命中次数
- 参数类型分布
- 对象形状变化
- 字段访问频率
这些信息一般被称为 profile data,也就是运行时画像。
例如:
- 函数 foo 被调用 10000 次
- 循环 loop 执行 50000 次
- 表达式 filter 执行 1000000 次
这些代码就值得进入 JIT 编译流程。
如果运行在 x86-64 上,就生成 x86-64 机器码。如果运行在 ARM64 上,就生成 ARM64 机器码。
这一层会涉及很多传统编译器技术,例如:
- 指令选择
- 寄存器分配
- 栈帧布局
- 常量折叠
- 死代码删除
- 函数内联
- 分支优化
概念上可以理解成:
原来: call interpreter(foo_bytecode)JIT 后: call foo_native_code这样后续再调用这个函数时,就不用进入解释器了。
实践
下面用 C 写一个非常小的 JIT 示例。这个例子不做完整编译器,只是手动生成一段 x86-64 机器码。
目标是生成一个更接近真实使用场景的规则过滤函数:
int filter(int status, int cost) { return status >= 500 && cost > 100;}这个函数可以类比日志过滤、规则引擎或者数据库查询中的 where 条件。规则本身是在运行时确定的,但规则确定以后会被执行很多次,这才是 JIT 比较有意义的地方。
在 x86-64 System V ABI 下,前两个 int 参数通常分别放在 edi 和 esi 中,返回值放在 eax 中。因此可以生成如下汇编:
xor eax, eaxcmp edi, 500jl donecmp esi, 100setg aldone:ret对应的机器码如下:
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完整代码如下:
#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;}这段代码的执行流程如下:
-
准备一段机器码。
-
通过
mmap申请一块内存。 -
先把这块内存设置为可读可写,用于写入机器码。
-
使用
memcpy把机器码复制进去。 -
使用
mprotect把内存权限改成可读可执行。 -
把这块内存强转成函数指针。
-
像调用普通函数一样调用它。
这就是最小形态的 JIT:运行时生成机器码,并把机器码当成函数执行。
如果这个规则只是执行一次,那么这样做完全没有必要。但如果规则来自配置中心、SQL 条件或者风控策略,并且需要对几百万条记录反复判断,那么把通用解释逻辑变成专用机器码就有意义了。
对比一下两种方式:
解释执行: 每条记录都解释 AST 每条记录都判断字段名、操作符、常量 每条记录都进行分发JIT 执行: 规则确定后生成机器码 每条记录只执行比较和跳转 省掉解释器分发成本为什么不一开始就设置成:PROT_READ | PROT_WRITE | PROT_EXEC。
原因是安全。现代操作系统和运行时一般会尽量遵守 W^X 原则(Writable xor Executable)
意思是:一块内存最好不要同时可写又可执行。
如果内存同时可写可执行,攻击者一旦找到写内存漏洞,就可能把恶意机器码写进去,然后跳过去执行。这会明显增加代码注入攻击的风险。
所以 JIT 系统一般会采用下面的方式:
- 生成代码阶段:内存可写,不可执行
- 执行代码阶段:内存可执行,不可写
这也是 JIT 比普通 AOT 编译更复杂的原因之一。JIT 不只是编译器问题,还涉及运行时内存管理和安全策略。
优化手段
JIT 的优势不只是“能生成机器码”,更重要的是它可以根据运行时信息生成更激进的机器码。下面是一些常见优化手段。
类型特化
类型特化是 JIT 最常见的优化之一。还是以 JavaScript 的加法为例:
function add(a, b) { return a + b;}如果运行时发现 a 和 b 大部分时候都是整数,那么 JIT 可以生成整数加法版本。
如果后面突然传入字符串,那么原来的假设失效,就需要回退到更通用的路径。
可以理解成:
快速路径: 假设 a、b 都是 int 直接执行整数加法慢路径: 类型不符合假设 回退到通用逻辑这种方式的关键点是:大部分情况下走快速路径,少数异常情况走慢路径。
函数内联
函数调用本身有成本,例如:
- 参数传递
- 保存返回地址
- 进入新栈帧
- 返回调用方
如果一个小函数被频繁调用,JIT 可以把函数体直接展开到调用点。
例如:
function square(x) { return x * x;}function calc(a) { return square(a) + 1;}内联后可以变成:
function calc(a) { return a * a + 1;}内联的价值不只是减少函数调用成本,更重要的是内联后暴露了更多优化机会。比如常量折叠、死代码删除、寄存器分配都可能因此变得更好。
对象访问
动态语言访问对象字段通常比较复杂。
例如:
function getName(user) { return user.name;}从语义上看,user.name 是一次字段访问。但在动态语言中,运行时要考虑:
- user 是否为 null
- user 是否是对象
- name 字段是否存在
- name 字段在对象中的位置
- 原型链上是否存在 name
如果每次访问都完整查找字段,性能会比较差。
JIT 引擎通常会记录对象的结构信息。假设运行时发现传入的 user 对象结构一直相同,那么就可以把字段访问优化成固定偏移访问。
概念上类似:
mov rax, [rdi + name_offset]这和 C 语言访问结构体字段很像:
typedef struct { int id; char *name;} User;user->name;C 编译器在编译时就知道 name 字段的偏移,而 JIT 是在运行时根据对象形状推导出这个偏移。
这类优化对于数组、字符串、矩阵计算等场景非常重要。
逃逸分析
逃逸分析用于判断一个对象是否会逃出当前函数。例如:
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 函数内部使用,没有被返回,也没有被其他线程访问,那么它可能不需要真的在堆上分配这个对象。优化后概念上类似:
int calc() { int x = 1; int y = 2; return x + y;}这样可以减少堆分配和 GC 压力。
反优化
JIT 经常基于运行时信息做假设。例如:
- 假设 add 的参数都是整数
- 假设 user 对象的字段布局不变
- 假设某个分支大部分时候成立
这些假设可以带来性能提升,但假设也可能失效。
例如:
add(1, 2);add(3, 4);add(5, 6);add("hello", "world");前面几次调用让 JIT 以为 add 是整数加法函数,于是生成了整数加法机器码。但最后一次传入了字符串,此时继续执行整数加法机器码就错了。
所以 JIT 系统需要支持反优化,也就是 deoptimization。
反优化的大致思路是:
- 在优化机器码里插入类型检查
- 如果检查通过,继续走快速路径
- 如果检查失败,跳回解释器或低级优化代码
- 恢复解释器需要的栈帧和变量状态
这也是 JIT 实现复杂的一个重要原因。它不仅要能生成快代码,还要在假设失效时正确回退。
Go 和 C
C、Go 这类语言通常使用 AOT 编译。也就是说,程序运行之前就已经被编译成机器码。
但是这并不意味着 C 和 Go 不能使用 JIT 技术。只要程序在运行时生成机器码,并把这段机器码执行起来,就可以说使用了 JIT 思路。
一个 C 语言 JIT 系统一般会涉及:
mmap申请内存mprotect修改内存权限- 生成目标 CPU 的机器码
- 转换成函数指针
- 按 ABI 规则传参和返回
难点在于:
- 不同 CPU 指令集不同
- 不同系统 ABI 不同
- 寄存器分配复杂
- 栈对齐不能出错
- 可执行内存管理有安全限制
原因包括:
- Go 函数调用有自己的 ABI 细节
- Go runtime 管理栈增长、GC、调度
- 直接跳转到手写机器码时要谨慎处理 GC 指针
- Go 不鼓励随意执行 runtime 外部生成的机器码
所以在 Go 里做 JIT,常见路线有三种:
-
使用 C / 汇编实现底层 JIT,再由 Go 调用。
-
不生成本地机器码,而是生成 bytecode,通过高效 VM 执行。
-
运行时生成 Go / C 源码,再调用编译器生成动态库。
第一种性能最好,但工程复杂度最高。第二种更安全,适合做规则引擎、表达式引擎。第三种实现简单,但编译延迟较高。
JSON 场景
以高性能 JSON 库为例,JIT 的价值主要体现在减少反射和通用分发成本。
假设有一个 Go 结构体:
type User struct { ID int64 `json:"id"` Name string `json:"name"` Age int `json:"age,omitempty"`}通用 JSON 序列化器通常要做这些事:
拿到 interface{}-> reflect 判断真实类型-> 遍历字段-> 读取 json tag-> 判断 omitempty-> 根据字段类型选择编码函数-> 写入结果 buffer这些逻辑很通用,但对于 User 这个具体类型来说,很多信息其实是固定的:
ID字段偏移固定Name字段偏移固定Age字段偏移固定- JSON key 固定是
"id"、"name"、"age" ID一定按 int64 编码Name一定按 string 编码Age一定按 int 编码
JIT 的思路就是:第一次看到 User 类型时分析它的结构,后续生成一条专门处理 User 的路径。概念上类似:
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}底层看起来更像:
; 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 做的事情包括:
- 先解释执行代码。
- 收集运行时信息。
- 找出热点代码。
- 根据当前类型、分支、对象布局等信息生成机器码。
- 后续直接执行机器码。
- 如果优化假设失效,再回退到通用路径。
对于熟悉 C、Go 和汇编的开发者来说,可以把 JIT 理解成:
- 程序运行时临时生成一段汇编 / 机器码,
- 把它放进可执行内存,
- 然后通过函数指针跳过去执行。
JIT 真正有价值的地方,不只是“动态生成代码”,而是能够把运行时观察到的信息转化成更具体、更少分支、更接近手写专用代码的机器码。所以,在规则引擎、表达式引擎、JSON 编解码、SQL 执行器、正则引擎、虚拟机等场景中,JIT 都是一种非常重要的性能优化技术。